diff --git a/Cargo.lock b/Cargo.lock
index 049ab21b4..f9f0e4432 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4877,6 +4877,8 @@ dependencies = [
name = "oakstorage"
version = "0.1.0"
dependencies = [
+ "chrono",
+ "oakcommon",
"oakcore-rs",
"oaknode",
"oakotio",
diff --git a/crates/oakstorage/Cargo.toml b/crates/oakstorage/Cargo.toml
index 7b6ae1b7a..1156b4cba 100644
--- a/crates/oakstorage/Cargo.toml
+++ b/crates/oakstorage/Cargo.toml
@@ -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"
diff --git a/crates/oakstorage/src/backends/database.rs b/crates/oakstorage/src/backends/database.rs
deleted file mode 100644
index a9f5f2414..000000000
--- a/crates/oakstorage/src/backends/database.rs
+++ /dev/null
@@ -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 .
-
-//! 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 {
- todo!()
- }
-
- fn save(
- &self,
- _project: crate::handle::CHandle,
- _uri: &crate::uri::StorageUri,
- _options: u32,
- ) -> crate::error::Result<()> {
- todo!()
- }
-}
diff --git a/crates/oakstorage/src/backends/database/entities/journal.rs b/crates/oakstorage/src/backends/database/entities/journal.rs
new file mode 100644
index 000000000..f545105a8
--- /dev/null
+++ b/crates/oakstorage/src/backends/database/entities/journal.rs
@@ -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 .
+
+//! 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,
+ /// After image (whole node XML; NULL for deleted nodes).
+ pub new_xml: Option,
+ /// Journal write time.
+ pub at: DateTime,
+}
+
+/// `journal` has no relations yet.
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}
+
+impl ActiveModelBehavior for ActiveModel {}
diff --git a/crates/oakstorage/src/backends/database/entities/mod.rs b/crates/oakstorage/src/backends/database/entities/mod.rs
new file mode 100644
index 000000000..7dee44324
--- /dev/null
+++ b/crates/oakstorage/src/backends/database/entities/mod.rs
@@ -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 .
+
+//! 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`, `NaiveDateTime`) map to BIGINT/TEXT/TIMESTAMP on
+//! both.
+
+pub mod journal;
+pub mod project;
+pub mod settings;
+pub mod snapshot;
diff --git a/crates/oakstorage/src/backends/database/entities/project.rs b/crates/oakstorage/src/backends/database/entities/project.rs
new file mode 100644
index 000000000..b97da8f88
--- /dev/null
+++ b/crates/oakstorage/src/backends/database/entities/project.rs
@@ -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 .
+
+//! 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 {}
diff --git a/crates/oakstorage/src/backends/database/entities/settings.rs b/crates/oakstorage/src/backends/database/entities/settings.rs
new file mode 100644
index 000000000..a57ca907d
--- /dev/null
+++ b/crates/oakstorage/src/backends/database/entities/settings.rs
@@ -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 .
+
+//! 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 {}
diff --git a/crates/oakstorage/src/backends/database/entities/snapshot.rs b/crates/oakstorage/src/backends/database/entities/snapshot.rs
new file mode 100644
index 000000000..b161dc4b8
--- /dev/null
+++ b/crates/oakstorage/src/backends/database/entities/snapshot.rs
@@ -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 .
+
+//! 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 {}
diff --git a/crates/oakstorage/src/backends/database/migration.rs b/crates/oakstorage/src/backends/database/migration.rs
new file mode 100644
index 000000000..7d27bd09b
--- /dev/null
+++ b/crates/oakstorage/src/backends/database/migration.rs
@@ -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 .
+
+//! 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(())
+}
diff --git a/crates/oakstorage/src/backends/database/mod.rs b/crates/oakstorage/src/backends/database/mod.rs
new file mode 100644
index 000000000..ca023dc62
--- /dev/null
+++ b/crates/oakstorage/src/backends/database/mod.rs
@@ -0,0 +1,1509 @@
+// 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 .
+
+//! The database backend (plan M13 §1/§2), SQLite first.
+//!
+//! URI forms:
+//! - `oakdb+sqlite:///absolute/path.db[?project=]` — local file
+//! database; the optional `project` query selects the library row to
+//! load (default: the most recently modified project).
+//! - `oakdb+pg://user:pass@host:5432/db[?project=]` — PostgreSQL
+//! (D3). The target is parsed into [`DbTarget::Pg`] today, but every
+//! operation rejects it with [`crate::error::Error::NoBackend`] until
+//! the D3 client lands.
+//!
+//! The async sea-orm API is driven by a private current-thread tokio
+//! runtime so the public surface stays synchronous (plan §6: "后端内嵌
+//! 私有 current_thread runtime").
+//!
+//! ## Storage model (plan §0 — the aggregation-granularity design)
+//!
+//! A project's state is its node graph plus its settings — nothing else.
+//! Every command is persisted as a *diff*: the project is re-serialized
+//! in memory (the same per-node XML the `.ove` writer emits) and
+//! compared node-by-node against the previous state, so each affected
+//! node lands in the journal as one row with its before image
+//! (`old_xml`) and after image (`new_xml`). The journal is therefore the
+//! persistent undo history: replaying `new_xml` rows forward reconstructs
+//! any later state, replaying `old_xml` backwards undoes to any point.
+//! Node addressing uses the packed `NodeId::identity`, stored offset by
+//! +1 so that journal identity 0 can be reserved for the settings
+//! pseudo-node (a real graph slot can have identity 0 — the root folder
+//! is slot 0).
+//!
+//! Snapshots are periodic full payloads that only accelerate loading:
+//! the newest snapshot at or before a target seq is the replay base, and
+//! the journal rows after it are applied on top. A snapshot is written
+//! when the project is dirty and `Storage/SnapshotIntervalSec` (default
+//! 600) seconds have passed since the last one; pruning keeps the newest
+//! [`SNAPSHOT_KEEP`] copies. Journal retention follows
+//! `Storage/JournalRetentionDays` (default 0 = keep everything); rows
+//! older than the window are dropped only when the newest snapshot
+//! already covers them, so the head state always stays reconstructible.
+
+pub mod entities;
+pub mod migration;
+
+use std::collections::HashMap;
+use std::future::Future;
+use std::path::Path;
+use std::sync::{Mutex, OnceLock};
+use std::time::Duration;
+
+use sea_orm::entity::prelude::*;
+use sea_orm::sea_query::Expr;
+use sea_orm::{QueryOrder, QuerySelect, Set, TransactionTrait};
+
+use entities::{journal, project, settings, snapshot};
+use oaknode::id::NodeId;
+use oaknode::project::Project;
+
+use crate::backend::{LoadResult, StorageBackend};
+use crate::error::{Error, Result};
+use crate::handle::CHandle;
+use crate::uri::StorageUri;
+
+/// Journal kind for redo commands (the D1 save path).
+pub const KIND_REDO: &str = "redo";
+/// Journal kind for undo commands (D2 write-through hook).
+pub const KIND_UNDO: &str = "undo";
+/// Journal kind for playhead/time jump commands (D2).
+pub const KIND_JUMP: &str = "jump";
+/// Journal kind for grouped commands (D2 `group_end`).
+pub const KIND_GROUP: &str = "group";
+/// Journal kind of a first-entry import command (plan §2).
+pub const KIND_IMPORT: &str = "import";
+/// Journal identity of the settings pseudo-node (plan §1).
+pub const SETTINGS_NODE: i64 = 0;
+/// Snapshots kept after pruning (plan §0: the newest 3).
+pub const SNAPSHOT_KEEP: u64 = 3;
+/// The empty settings element (replay base before any settings row).
+const EMPTY_SETTINGS: &str = "";
+
+/// A parsed `oakdb+…` target: the database location plus the optional
+/// `?project=` selector.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum DbTarget {
+ /// SQLite file database at an absolute path.
+ Sqlite {
+ /// Absolute database file path.
+ path: String,
+ /// `?project=` uuid; `None` = most recently modified row.
+ project: Option,
+ },
+ /// PostgreSQL (parsed for D3; rejected until then).
+ Pg {
+ /// Connection string (`user:pass@host:port/db`).
+ conn: String,
+ /// `?project=` uuid; `None` = most recently modified row.
+ project: Option,
+ },
+}
+
+impl DbTarget {
+ /// True for the PostgreSQL variant (all ops return E_NO_BACKEND
+ /// until D3).
+ fn is_pg(&self) -> bool {
+ matches!(self, DbTarget::Pg { .. })
+ }
+
+ /// The SQLite path (`None` for PG).
+ fn sqlite_path(&self) -> Option<&str> {
+ match self {
+ DbTarget::Sqlite { path, .. } => Some(path),
+ DbTarget::Pg { .. } => None,
+ }
+ }
+
+ /// The `?project=` selector.
+ fn project(&self) -> Option<&str> {
+ match self {
+ DbTarget::Sqlite { project, .. } | DbTarget::Pg { project, .. } => {
+ project.as_deref()
+ }
+ }
+ }
+}
+
+/// Parse a storage URI into a [`DbTarget`].
+///
+/// `oakdb+sqlite://` requires an absolute path (E_INVALID otherwise);
+/// the body may carry `?project=` (no percent-decoding — uuid
+/// values contain only hex/braces/hyphens). Unknown `oakdb` variants
+/// are E_NO_BACKEND.
+pub(crate) fn parse_target(uri: &StorageUri) -> Result {
+ match uri.scheme.as_str() {
+ "oakdb+sqlite" => {
+ let (path, query) = split_query(&uri.body);
+ if !Path::new(path).is_absolute() {
+ return Err(Error::Invalid);
+ }
+ Ok(DbTarget::Sqlite {
+ path: path.to_string(),
+ project: query_param(query, "project"),
+ })
+ }
+ "oakdb+pg" => {
+ let (conn, query) = split_query(&uri.body);
+ Ok(DbTarget::Pg {
+ conn: conn.to_string(),
+ project: query_param(query, "project"),
+ })
+ }
+ _ => Err(Error::NoBackend),
+ }
+}
+
+/// Split `body` at the first `?` (the query part, if any).
+fn split_query(body: &str) -> (&str, Option<&str>) {
+ match body.split_once('?') {
+ Some((base, query)) => (base, Some(query)),
+ None => (body, None),
+ }
+}
+
+/// Look up `key` in a `k=v&…` query string.
+fn query_param(query: Option<&str>, key: &str) -> Option {
+ let query = query?;
+ for pair in query.split('&') {
+ if let Some((k, v)) = pair.split_once('=') {
+ if k == key {
+ return Some(v.to_string());
+ }
+ }
+ }
+ None
+}
+
+/// Derived per-project manager stats (plan §4 — derived from the node
+/// graph when a project is opened, never stored).
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct ProjectStats {
+ /// Timeline duration in milliseconds (longest sequence).
+ pub duration_ms: i64,
+ /// Total tracks across all sequences.
+ pub track_count: i32,
+ /// Total clip blocks across all tracks.
+ pub clip_count: i32,
+ /// Total footage nodes in the graph.
+ pub footage_count: i32,
+}
+
+/// Derive the manager stats from a live project (plan §4).
+///
+/// Traversal: sequences contribute their track lists' tracks and blocks;
+/// the duration is the longest sequence's end point. Footage nodes are
+/// counted directly in the graph. A clip that appears in more than one
+/// track is counted once per membership.
+pub fn derive_stats(p: &Project) -> ProjectStats {
+ use oaknode::block::{ClipBlockBehavior, GapBlockBehavior};
+ use oaknode::sequence::SequenceBehavior;
+ use oaknode::track::{TrackBehavior, TrackListBehavior};
+
+ let mut stats = ProjectStats::default();
+ for id in p.graph.node_ids() {
+ let entry = match p.graph.get(id) {
+ Some(e) => e,
+ None => continue,
+ };
+ if let Some(seq) = entry
+ .behavior
+ .as_any()
+ .and_then(|a| a.downcast_ref::())
+ {
+ let mut seq_len = oakcore_rs::Rational::new(0, 1);
+ for tl_id in &seq.track_lists {
+ let tl = match p.graph.get(*tl_id).and_then(|e| {
+ e.behavior
+ .as_any()
+ .and_then(|a| a.downcast_ref::())
+ }) {
+ Some(t) => t,
+ None => continue,
+ };
+ stats.track_count += tl.tracks.len() as i32;
+ for t_id in &tl.tracks {
+ let t = match p.graph.get(*t_id).and_then(|e| {
+ e.behavior
+ .as_any()
+ .and_then(|a| a.downcast_ref::())
+ }) {
+ Some(t) => t,
+ None => continue,
+ };
+ for b in &t.blocks {
+ let entry = p.graph.get(*b);
+ if let Some(c) = entry.and_then(|e| {
+ e.behavior
+ .as_any()
+ .and_then(|a| a.downcast_ref::())
+ }) {
+ stats.clip_count += 1;
+ if c.core.out() > seq_len {
+ seq_len = c.core.out();
+ }
+ } else if let Some(g) = entry.and_then(|e| {
+ e.behavior
+ .as_any()
+ .and_then(|a| a.downcast_ref::())
+ }) {
+ if g.core.out() > seq_len {
+ seq_len = g.core.out();
+ }
+ }
+ }
+ }
+ }
+ let ms = (seq_len.to_f64() * 1000.0).round() as i64;
+ if ms > stats.duration_ms {
+ stats.duration_ms = ms;
+ }
+ } else if entry.behavior.type_id() == "org.olivevideoeditor.Olive.footage" {
+ stats.footage_count += 1;
+ }
+ }
+ stats
+}
+
+/// One library entry as returned by [`DatabaseBackend::list_projects`].
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ProjectInfo {
+ /// Surrogate row id.
+ pub id: i64,
+ /// Project uuid (serializer-identical).
+ pub uuid: String,
+ /// Display name.
+ pub name: String,
+ /// Payload format version (the serializer's CURRENT_VERSION).
+ pub schema_ver: i32,
+ /// Row creation time.
+ pub created_at: DateTime,
+ /// Last-write time (manager sort key).
+ pub modified_at: DateTime,
+ /// Head command seq.
+ pub command_seq: i64,
+}
+
+/// The database backend (schemes `oakdb+sqlite` / `oakdb+pg`).
+///
+/// Connections and the private runtime are created lazily on the first
+/// operation; the runtime is a single current-thread tokio runtime that
+/// every synchronous call drives with `block_on`. An `op_lock` mutex
+/// serializes calls into one backend (single-writer assumption, plan
+/// §6) and the SQLite pool itself is capped at one connection, so a
+/// transaction owns the only connection for its whole lifetime.
+pub struct DatabaseBackend {
+ /// Private current-thread tokio runtime (created on first use).
+ runtime: OnceLock,
+ /// Serializes all database operations of this backend.
+ op_lock: Mutex<()>,
+ /// Open connections by database path.
+ connections: Mutex>,
+}
+
+impl DatabaseBackend {
+ /// Construct (no connection is opened until first use).
+ pub fn new() -> Self {
+ DatabaseBackend {
+ runtime: OnceLock::new(),
+ op_lock: Mutex::new(()),
+ connections: Mutex::new(HashMap::new()),
+ }
+ }
+
+ /// Build the private current-thread runtime.
+ fn build_runtime() -> tokio::runtime::Runtime {
+ tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("tokio current_thread runtime")
+ }
+
+ /// Run `f` against the database at `key` (an absolute SQLite path).
+ /// The op lock is held for the whole call; the connection is opened
+ /// and migrated on first use, then cached.
+ fn run(&self, key: &str, f: F) -> Result
+ where
+ F: FnOnce(DatabaseConnection) -> Fut,
+ Fut: Future