feat(storage): database backend D1 — SQLite, node-granular journal, snapshots, persistent undo

- sea-orm schema: projects/settings/snapshots/journal (journal rows
  are per-node before/after images produced by diffing the serializer
  output, never full-project copies)
- replay = latest snapshot + journal; load_at() rewinds to any command
  seq (cross-session undo history); snapshot interval and journal
  retention configurable; snapshots pruned to the newest 3
- project manager API: list/delete/duplicate/rename, export/import
  (.ove/.otio/.fcpxml), stats derivation; PG surfaces as E_NO_BACKEND
  until D3
- docs(riir): M13 finalized (aggregate-granular persistence)
This commit is contained in:
2026-08-16 01:38:27 +08:00
parent 530bc762dc
commit 3dfeed67f5
12 changed files with 3456 additions and 84 deletions
Generated
+2
View File
@@ -4877,6 +4877,8 @@ dependencies = [
name = "oakstorage"
version = "0.1.0"
dependencies = [
"chrono",
"oakcommon",
"oakcore-rs",
"oaknode",
"oakotio",
+5
View File
@@ -10,8 +10,13 @@ crate-type = ["staticlib", "rlib"]
[dependencies]
oakcore-rs = { path = "../oakcore" }
oakcommon = { path = "../oakcommon" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# Timestamp columns in the database schema (plan M13 §1: TIMESTAMP NOT
# NULL). sea-orm maps chrono NaiveDateTime via its default with-chrono
# feature; the direct dependency is needed for the entity field types.
chrono = "0.4"
# Error derive (Display + std::error::Error) for the crate error enum
# (src/error.rs). Same major version the other modules use (oakotio, ...).
thiserror = "2"
@@ -1,81 +0,0 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The database backend (PostgreSQL + SQLite via SeaORM).
//!
//! URI forms:
//! - `oakdb+sqlite:///absolute/path.db` — local file database
//! - `oakdb+pg://user:pass@host:5432/dbname` — PostgreSQL
//!
//! The async SeaORM API is driven by a private current-thread tokio
//! runtime so the public surface stays synchronous (M10: no callbacks,
//! sync commands only). The graph payload is the same serialized form
//! the ove-xml backend produces — one serialization truth.
/// The database backend (schemes `oakdb+sqlite` / `oakdb+pg`).
pub struct DatabaseBackend {
// Private current-thread runtime + SeaORM connection cache.
}
impl DatabaseBackend {
/// Construct (no connection is opened until first use).
pub fn new() -> Self {
todo!()
}
/// Parse the URI into a SeaORM connection URL + project key
/// (query param `?project=` or row id; default: singleton "default"
/// project row).
///
/// Dead until the database proxy lands (the whole backend is a
/// declared stub); kept for the schema contract.
#[allow(dead_code)]
pub(crate) fn parse_target(
_uri: &crate::uri::StorageUri,
) -> crate::error::Result<(String, String)> {
todo!()
}
}
impl crate::backend::StorageBackend for DatabaseBackend {
fn name(&self) -> &'static str {
todo!()
}
fn uri_scheme(&self) -> &'static str {
todo!()
}
fn can_handle(&self, uri: &crate::uri::StorageUri) -> bool {
uri.scheme.starts_with("oakdb")
}
fn load(
&self,
_uri: &crate::uri::StorageUri,
) -> crate::error::Result<crate::backend::LoadResult> {
todo!()
}
fn save(
&self,
_project: crate::handle::CHandle,
_uri: &crate::uri::StorageUri,
_options: u32,
) -> crate::error::Result<()> {
todo!()
}
}
@@ -0,0 +1,51 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The `journal` table: the command log (plan §1). One row per affected
//! node per command — the node's before image (`old_xml`) and after
//! image (`new_xml`), whole-node XML produced by the diff. `node_identity`
//! 0 is the settings pseudo-node; real node identities are stored
//! offset by +1 so the 0 sentinel never collides with a graph slot.
use sea_orm::entity::prelude::*;
/// The `journal` entity.
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "journal")]
pub struct Model {
/// Owning project (ON DELETE CASCADE).
pub project_id: i64,
/// Monotonic per-project command sequence.
#[sea_orm(primary_key)]
pub seq: i64,
/// The affected node's identity (+1; 0 = settings pseudo-node).
#[sea_orm(primary_key)]
pub node_identity: i64,
/// Command kind: 'redo' | 'undo' | 'jump' | 'group' | 'import'.
pub kind: String,
/// Before image (whole node XML; NULL for newly added nodes).
pub old_xml: Option<String>,
/// After image (whole node XML; NULL for deleted nodes).
pub new_xml: Option<String>,
/// Journal write time.
pub at: DateTime,
}
/// `journal` has no relations yet.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,29 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! SeaORM entity models for the four-table schema (plan M13 §1).
//!
//! One module per entity: the `DeriveEntityModel` macro generates
//! `Entity`/`Column`/`Model`/`ActiveModel` in the defining module, so
//! each table gets its own scope. The same definitions serve SQLite
//! today and PostgreSQL (D3) — the column types (`i64`, `String`,
//! `Option<String>`, `NaiveDateTime`) map to BIGINT/TEXT/TIMESTAMP on
//! both.
pub mod journal;
pub mod project;
pub mod settings;
pub mod snapshot;
@@ -0,0 +1,47 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The `projects` table: one row per project library entry (plan §1).
use sea_orm::entity::prelude::*;
/// The `projects` entity.
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "projects")]
pub struct Model {
/// Surrogate row id (BIGSERIAL/INTEGER PK).
#[sea_orm(primary_key)]
pub id: i64,
/// Project uuid (same value the serializer persists; unique).
#[sea_orm(unique)]
pub uuid: String,
/// Display name (the project manager lists this).
pub name: String,
/// Payload format version (the serializer's CURRENT_VERSION).
pub schema_ver: i32,
/// Row creation timestamp.
pub created_at: DateTime,
/// Last-write timestamp (manager sort key).
pub modified_at: DateTime,
/// Current head command seq (the newest journal seq).
pub command_seq: i64,
}
/// `projects` has no relations yet (queries join explicitly).
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,41 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The `settings` table: the project's KV settings (plan §1). The
//! current values are mirrored here on every save; the history lives in
//! the journal (node_identity = 0 pseudo-node).
use sea_orm::entity::prelude::*;
/// The `settings` entity.
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "settings")]
pub struct Model {
/// Owning project (ON DELETE CASCADE).
#[sea_orm(primary_key)]
pub project_id: i64,
/// Setting key.
#[sea_orm(primary_key)]
pub key: String,
/// Setting value.
pub value: String,
}
/// `settings` has no relations yet.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,44 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The `snapshots` table: periodic full payloads that only accelerate
//! loading (plan §0/§1). The newest snapshot at or before a target seq
//! is the replay base; older ones are pruned to keep the newest
//! [`crate::backends::database::SNAPSHOT_KEEP`].
use sea_orm::entity::prelude::*;
/// The `snapshots` entity.
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "snapshots")]
pub struct Model {
/// Owning project (ON DELETE CASCADE).
#[sea_orm(primary_key)]
pub project_id: i64,
/// The journal command seq this snapshot captures.
#[sea_orm(primary_key)]
pub command_seq: i64,
/// Full-feature project XML (identical to a `.ove` payload).
pub payload: String,
/// Snapshot write time.
pub written_at: DateTime,
}
/// `snapshots` has no relations yet.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,91 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Schema bootstrap (plan M13 §1).
//!
//! Chosen over the sea-orm-migration crate on purpose: the schema is
//! four tables, static for the whole D1 surface, and `CREATE TABLE IF
//! NOT EXISTS` is idempotent — no versioned migration machinery, no
//! extra crate in the tree. The DDL runs once at first connection open.
use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
/// SQLite DDL for the four tables (plan §1, SQLite dialect:
/// `INTEGER PRIMARY KEY AUTOINCREMENT` + `TIMESTAMP`).
const SQLITE_DDL: &[&str] = &[
"CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
schema_ver INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
modified_at TIMESTAMP NOT NULL,
command_seq BIGINT NOT NULL DEFAULT 0
)",
"CREATE TABLE IF NOT EXISTS settings (
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (project_id, key)
)",
"CREATE TABLE IF NOT EXISTS snapshots (
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
command_seq BIGINT NOT NULL,
payload TEXT NOT NULL,
written_at TIMESTAMP NOT NULL,
PRIMARY KEY (project_id, command_seq)
)",
"CREATE TABLE IF NOT EXISTS journal (
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
seq BIGINT NOT NULL,
node_identity BIGINT NOT NULL,
kind TEXT NOT NULL,
old_xml TEXT,
new_xml TEXT,
at TIMESTAMP NOT NULL,
PRIMARY KEY (project_id, seq, node_identity)
)",
];
/// Apply the schema (idempotent; safe to run on every connection open).
///
/// PostgreSQL (D3) gets its own `BIGSERIAL` DDL — same shape, currently
/// unreachable because the backend rejects `oakdb+pg://` before any
/// connection is opened.
pub async fn migrate(db: &impl ConnectionTrait) -> crate::error::Result<()> {
let backend = db.get_database_backend();
let statements: &[&str] = match backend {
DatabaseBackend::Sqlite => SQLITE_DDL,
// D3: `BIGSERIAL PRIMARY KEY` for projects.id, `BIGINT` for the
// FKs — the rest of the column set is identical.
DatabaseBackend::Postgres => {
return Err(crate::error::Error::Failed(
"oakdb+pg is a D3 milestone; the PostgreSQL schema is not wired yet".to_string(),
));
}
other => {
return Err(crate::error::Error::Failed(format!(
"unsupported database backend {other:?}"
)));
}
};
for sql in statements {
db.execute_raw(Statement::from_string(backend, sql.to_string()))
.await
.map_err(|e| crate::error::Error::Io(format!("schema migration: {e}")))?;
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -16,9 +16,9 @@
//! Built-in backends.
//!
//! The database backend (`database`) is *not* registered: it is the
//! future replacement (M10 §3 — "数据库替换路径"), provided by a later
//! proxy. The file backends cover today's surface.
//! Arbitration order inside `file://`: ove-xml before otio so a `.ove`
//! path (which the otio backend would also claim as JSON) stays on the
//! ove backend. The database backend claims its own `oakdb+…` schemes.
pub mod database;
pub mod otio;
@@ -32,5 +32,6 @@ pub fn builtins() -> Vec<Arc<dyn crate::backend::StorageBackend>> {
vec![
Arc::new(ove_xml::OveXmlBackend::new()),
Arc::new(otio::OtioBackend::new()),
Arc::new(database::DatabaseBackend::new()),
]
}
File diff suppressed because it is too large Load Diff