diff --git a/Cargo.lock b/Cargo.lock
index f9f0e4432..53701141e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4814,12 +4814,15 @@ dependencies = [
"oaknode",
"oakplugin",
"oakrender",
+ "oakstorage",
"oaktask",
"oaktimeline",
"oakundo",
+ "sea-orm",
"serde",
"serde_json",
"thiserror 2.0.20",
+ "tokio",
]
[[package]]
diff --git a/crates/oakengine/Cargo.toml b/crates/oakengine/Cargo.toml
index d31c35380..ed3842c1a 100644
--- a/crates/oakengine/Cargo.toml
+++ b/crates/oakengine/Cargo.toml
@@ -45,6 +45,10 @@ oaktask = { path = "../oaktask" }
oaknode = { path = "../oaknode" }
oakaudio = { path = "../oakaudio" }
oakplugin = { path = "../oakplugin" }
+# Live write-through to the project library (plan M13 D2): the facade's
+# storage session manager (src/storage.rs) drives the oakstorage database
+# backend (DatabaseBackend::save/snapshot) straight from the undo path.
+oakstorage = { path = "../oakstorage" }
[dev-dependencies]
# The facade is cdylib-only, so the former tests/*.rs integration tests
@@ -53,3 +57,9 @@ oakplugin = { path = "../oakplugin" }
# [dependencies] above. oakcommon stays here for the direct dev-only
# imports in the test support files.
oakcommon = { path = "../oakcommon" }
+# The write-through tests (src/test_support/it_storage.rs) inspect the
+# journal/snapshot rows of the temp libraries directly; sea-orm + tokio
+# unify with oakstorage's own versions (same features), so the entities
+# and the current-thread runtime pattern match the oakstorage tests.
+sea-orm = { version = "2", features = ["sqlx-postgres", "sqlx-sqlite", "runtime-tokio"] }
+tokio = { version = "1", features = ["rt", "macros"] }
diff --git a/crates/oakengine/src/lib.rs b/crates/oakengine/src/lib.rs
index bc96ee650..5fdedd46c 100644
--- a/crates/oakengine/src/lib.rs
+++ b/crates/oakengine/src/lib.rs
@@ -79,6 +79,7 @@ pub mod plugin;
pub mod pods;
pub mod render;
pub mod stubs;
+pub mod storage;
pub mod task;
pub mod testmedia;
pub mod timeline;
diff --git a/crates/oakengine/src/node.rs b/crates/oakengine/src/node.rs
index c9176567b..f2dcf07ab 100644
--- a/crates/oakengine/src/node.rs
+++ b/crates/oakengine/src/node.rs
@@ -423,8 +423,10 @@ pub unsafe extern "C" fn oakengine_project_free(self_: *mut OakEngineProject) {
return;
}
// The module's project_free releases the handle and clears `ctx`;
- // the box shell is then deallocated (no double release).
+ // the box shell is then deallocated (no double release). Flush the
+ // write-through binding first (save + snapshot of pending writes).
let mut h = (*self_).handle;
+ crate::storage::unbind_project(h);
n::oaknode_project_free(&mut h);
drop(Box::from_raw(self_));
})
@@ -442,6 +444,9 @@ pub unsafe extern "C" fn oakengine_project_new(self_: *mut OakEngineProject) ->
// Clearing the global undo stack mirrors the app's new-project
// behavior (see undo.rs `oakengine_undo_clear`).
crate::undo::oakengine_undo_clear();
+ // Bind the fresh project to the default library (plan M13 D2): its
+ // first undoable edit lands in the journal as the import command.
+ crate::storage::bind_project(h);
Ok(())
})
}
@@ -502,6 +507,10 @@ pub unsafe extern "C" fn oakengine_project_load(
// Success: clear the undo stack and the modified flag.
crate::undo::oakengine_undo_clear();
Error::from_module(n::oaknode_project_set_modified(h, 0))?;
+ // Bind the opened project to the default library (plan M13 D2): the
+ // row is keyed by the loaded uuid — an existing library row
+ // continues its journal, otherwise the first edit imports it.
+ crate::storage::bind_project(h);
if !err.is_null() && err_size > 0 {
*err = 0;
}
diff --git a/crates/oakengine/src/storage.rs b/crates/oakengine/src/storage.rs
new file mode 100644
index 000000000..be5c8b45a
--- /dev/null
+++ b/crates/oakengine/src/storage.rs
@@ -0,0 +1,583 @@
+// 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 .
+
+//! Live write-through to the oakstorage project library (plan M13 §2/§3).
+//!
+//! The facade binds every opened project to a database session — a
+//! `(library uri, project uuid)` pair — and re-saves it after every
+//! successful undo-path operation ([`note_command`], hooked from
+//! [`crate::undo`]), so each command's diff lands in the journal
+//! transactionally (the oakstorage database backend does the diff itself).
+//!
+//! ## Binding model
+//!
+//! The map is keyed by the project handle's `ctx` pointer (the module
+//! `RefBox` identity — one per in-memory project instance), so several
+//! projects can be bound at once (multi-project, plan §3) without
+//! confusing their library rows. Every undo-path operation re-saves ALL
+//! bound projects ([`note_command`]): the oakstorage backend diffs each
+//! project against its own library head, so untouched projects are
+//! no-op touches and only the project the command actually changed
+//! advances its journal. (The plan's "current project" phrasing maps to
+//! this — the undo stack is cleared on every project switch, so at most
+//! one project's graph changes per command; a bound-but-untouched
+//! project can gain its import row this way, which reflects its true
+//! state.) Closing a project ([`unbind_project`], hooked from
+//! `project_free`) flushes its pending writes and drops the binding.
+//!
+//! The library is selected from the `Storage` config group (all defaults
+//! are config-driven, plan §5):
+//!
+//! - `Storage/Backend` — `"sqlite"` (the documented default value) or
+//! `"database"` enable the write-through; any other value (e.g.
+//! `"off"`) disables it. When the key is absent, no library is
+//! configured: projects stay unbound and the undo path runs without
+//! touching a database (graceful degradation — this is what keeps
+//! headless consumers and the test suite from writing to the user's
+//! default library).
+//! - `Storage/SqlitePath` — the SQLite library file; default (used when
+//! storage is enabled) `/library.db` (the same
+//! location `FileFunctions::get_configuration_location` derives,
+//! honoring `OAK_CONFIG_DIR` and portable mode).
+//!
+//! ## Snapshot thread and exit flush
+//!
+//! A background thread re-snapshots every *dirty* binding every
+//! `Storage/SnapshotIntervalSec` seconds (default 600; ≤ 0 acts every
+//! wake). Snapshots are latest-wins: the backend writes the full payload
+//! at the current head seq and prunes to the newest three. The thread is
+//! notified on bind and stops on the exit flush. [`flush_all`] (exported
+//! as `oakengine_storage_flush`) is the facade's exit path: it stops the
+//! thread, then writes through and snapshots every still-bound project
+//! (save + snapshot drain). A write-through failure never propagates to
+//! the caller — it is recorded in the binding's `last_error`
+//! ([`oakengine_storage_last_error`]) and the project keeps working
+//! (graceful degradation, plan §5).
+
+use std::collections::HashMap;
+use std::ffi::{c_char, c_int};
+use std::panic::{catch_unwind, AssertUnwindSafe};
+use std::sync::{Condvar, Mutex, OnceLock};
+use std::time::Duration;
+
+use oakstorage::backend::StorageBackend;
+use oakstorage::backends::database::DatabaseBackend;
+use oakstorage::uri::StorageUri;
+
+use crate::handle::{guard_int, CHandle, OakEngineProject};
+
+/// One bound project: its session (library uri + row uuid) plus the
+/// facade-side write state.
+struct Binding {
+ /// The project handle (addref'd at bind, released at unbind; keeps
+ /// the project alive past the caller's own handle).
+ project: CHandle,
+ /// Library uri (`oakdb+sqlite:///…`).
+ uri: String,
+ /// Library row uuid.
+ uuid: String,
+ /// Whether a write-through happened since the last successful
+ /// snapshot (the snapshot thread and the flush drain on this).
+ dirty: bool,
+ /// Monotonic counter bumped on every write-through; a snapshot only
+ /// clears `dirty` when the generation still matches, so a write that
+ /// lands while the snapshot runs is not lost.
+ write_gen: u64,
+ /// Last write-through / snapshot error (graceful degradation).
+ last_error: Option,
+}
+
+/// The process-wide oakstorage backend (shared by the UI thread's
+/// write-throughs and the snapshot thread; the backend serializes its own
+/// operations).
+fn backend() -> &'static DatabaseBackend {
+ static BACKEND: OnceLock = OnceLock::new();
+ BACKEND.get_or_init(DatabaseBackend::new)
+}
+
+/// project identity (handle `ctx` pointer) -> binding.
+fn bindings() -> &'static Mutex> {
+ static BINDINGS: OnceLock>> = OnceLock::new();
+ BINDINGS.get_or_init(|| Mutex::new(HashMap::new()))
+}
+
+/// Snapshot-thread runtime state.
+struct SnapshotRuntime {
+ /// The background thread (None after the exit flush joined it).
+ handle: Option>,
+ /// Exit request (set by [`flush_all`]).
+ stop: bool,
+}
+
+static SNAPSHOT: Mutex = Mutex::new(SnapshotRuntime {
+ handle: None,
+ stop: false,
+});
+
+/// Wakes the snapshot thread on bind/flush/stop.
+static SNAPSHOT_CV: Condvar = Condvar::new();
+
+// ---------------------------------------------------------------------------
+// Binding
+// ---------------------------------------------------------------------------
+
+/// Bind `project` to the configured default library. No-op when the
+/// backend is disabled by config, the project cannot be addressed (no
+/// uuid), or it is already bound. No database write happens here — the
+/// first undo-path operation creates the library row.
+pub fn bind_project(project: CHandle) {
+ let _ = catch_unwind(AssertUnwindSafe(|| {
+ if project.is_null() {
+ return;
+ }
+ let key = project.ctx as usize;
+ {
+ let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ if g.contains_key(&key) {
+ return; // Already bound (re-bind): nothing to refresh.
+ }
+ }
+ if !storage_enabled() {
+ return;
+ }
+ let Some(uri) = library_uri() else {
+ return;
+ };
+ let uuid = {
+ let arc = unsafe { crate::handle::domain::project_of(&project) };
+ match arc {
+ Some(a) => a.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone(),
+ None => return,
+ }
+ };
+ if uuid.is_empty() {
+ return;
+ }
+ // Addref the handle so the binding owns a reference independent of
+ // the caller's.
+ let mut owned = project;
+ if let Some(addref) = owned.addref {
+ unsafe { addref(owned.ctx) };
+ }
+ bindings()
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .insert(
+ key,
+ Binding {
+ project: owned,
+ uri,
+ uuid,
+ dirty: false,
+ write_gen: 0,
+ last_error: None,
+ },
+ );
+ ensure_thread();
+ }));
+}
+
+/// Flush `project`'s pending writes and drop its binding (closing the
+/// project). No-op when not bound.
+pub fn unbind_project(project: CHandle) {
+ let _ = catch_unwind(AssertUnwindSafe(|| {
+ let key = project.ctx as usize;
+ flush_one(key);
+ let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ if let Some(b) = g.remove(&key) {
+ if let Some(release) = b.project.release {
+ unsafe { release(b.project.ctx) };
+ }
+ }
+ }));
+}
+
+/// Whether `project` currently has a binding (status-bar / D5 surface).
+pub fn is_bound(project: CHandle) -> bool {
+ if project.is_null() {
+ return false;
+ }
+ bindings()
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .contains_key(&(project.ctx as usize))
+}
+
+/// The last write-through / snapshot error of `project` (empty when none
+/// or not bound).
+pub fn last_error(project: CHandle) -> Option {
+ if project.is_null() {
+ return None;
+ }
+ bindings()
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .get(&(project.ctx as usize))
+ .and_then(|b| b.last_error.clone())
+}
+
+// ---------------------------------------------------------------------------
+// Write-through
+// ---------------------------------------------------------------------------
+
+/// Persist every bound project after a successful undo-path operation
+/// (push / group_end / jump). Hooked from [`crate::undo`]; the oakstorage
+/// backend diffs each project against its own head, so unchanged projects
+/// are no-op touches. No-op when nothing is bound.
+pub fn note_command() {
+ let _ = catch_unwind(AssertUnwindSafe(|| {
+ let keys: Vec = {
+ let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ g.keys().copied().collect()
+ };
+ for key in keys {
+ write_through(key);
+ }
+ }));
+}
+
+/// Save the project of `key` through the database backend (the backend
+/// diffs and journals internally). Failures are recorded, never returned.
+fn write_through(key: usize) {
+ let (project, uri, _uuid) = {
+ let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ match g.get_mut(&key) {
+ Some(b) => (b.project, b.uri.clone(), b.uuid.clone()),
+ None => return,
+ }
+ };
+ let parsed = match StorageUri::parse(&uri) {
+ Ok(u) => u,
+ Err(_) => {
+ record_error(key, "invalid storage uri");
+ return;
+ }
+ };
+ match backend().save(project, &parsed, 0) {
+ Ok(()) => {
+ let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ if let Some(b) = g.get_mut(&key) {
+ b.dirty = true;
+ b.write_gen = b.write_gen.wrapping_add(1);
+ b.last_error = None;
+ }
+ }
+ Err(e) => record_error(key, &format!("{e}")),
+ }
+}
+
+fn record_error(key: usize, message: &str) {
+ let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ if let Some(b) = g.get_mut(&key) {
+ b.last_error = Some(message.to_string());
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Config
+// ---------------------------------------------------------------------------
+
+/// Whether the write-through backend is enabled (config-driven, plan §5):
+/// `Storage/Backend` set to `"sqlite"` or `"database"` enables it; any
+/// other explicit value (e.g. `"off"`) disables it. When NO `Storage`
+/// configuration is present the backend is NOT enabled — "no library
+/// configured" degrades gracefully to plain unbound projects, which keeps
+/// headless consumers (the CLI) and the test suite from ever writing to
+/// the user's default library. The documented default *values* are
+/// `Backend = "sqlite"` and `SqlitePath = /library.db`
+/// (used once storage is enabled without an explicit path).
+pub(crate) fn storage_enabled() -> bool {
+ let store = oakcommon::configstore::ConfigStore::instance();
+ match store.get(Some("Storage"), "Backend") {
+ Ok(b) => b == "sqlite" || b == "database",
+ Err(_) => false,
+ }
+}
+
+/// The configured SQLite library path (empty value = the default).
+fn configured_sqlite_path() -> Option {
+ let store = oakcommon::configstore::ConfigStore::instance();
+ match store.get(Some("Storage"), "SqlitePath") {
+ Ok(p) if !p.trim().is_empty() => Some(p.trim().to_string()),
+ _ => None,
+ }
+}
+
+/// The default library file: `/library.db`, where
+/// the data directory is the standard per-user location
+/// (`FileFunctions::get_configuration_location`: macOS Application
+/// Support / XDG config, honoring `OAK_CONFIG_DIR` and portable mode).
+pub(crate) fn default_library_path() -> String {
+ let dir = oakcommon::filefunctions::FileFunctions::new()
+ .get_configuration_location()
+ .unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned());
+ format!("{}/library.db", dir)
+}
+
+/// The `oakdb+sqlite://…` uri of the configured library (None when the
+/// path cannot be made absolute).
+fn library_uri() -> Option {
+ let path = match configured_sqlite_path() {
+ Some(p) => p,
+ None => default_library_path(),
+ };
+ let abs = std::path::absolute(&path).ok()?;
+ Some(format!("oakdb+sqlite://{}", abs.display()))
+}
+
+// ---------------------------------------------------------------------------
+// Snapshot thread
+// ---------------------------------------------------------------------------
+
+/// Spawn the snapshot thread (a no-op when one is already running). The
+/// thread is (re)startable after an exit flush.
+fn ensure_thread() {
+ let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
+ let alive = rt
+ .handle
+ .as_ref()
+ .map(|h| !h.is_finished())
+ .unwrap_or(false);
+ if !alive {
+ rt.stop = false;
+ rt.handle = Some(
+ std::thread::Builder::new()
+ .name("oak-storage-snapshot".into())
+ .spawn(snapshot_loop)
+ .expect("snapshot thread spawn"),
+ );
+ }
+}
+
+/// The thread loop: sleep a tick, then snapshot every dirty binding.
+/// The condvar makes the sleep interruptible (bind notifications and the
+/// exit flush's stop signal wake it immediately).
+fn snapshot_loop() {
+ loop {
+ let interval = snapshot_tick();
+ let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
+ let (guard, _) = SNAPSHOT_CV
+ .wait_timeout(rt, interval)
+ .unwrap_or_else(|e| e.into_inner());
+ rt = guard;
+ if rt.stop {
+ break;
+ }
+ drop(rt);
+ snapshot_dirty();
+ }
+}
+
+/// The sleep between snapshot passes: `Storage/SnapshotIntervalSec`
+/// seconds (default 600), with ≤ 0 treated as "every wake" and a 100 ms
+/// floor so the thread stays responsive to bind/stop signals.
+fn snapshot_tick() -> Duration {
+ let secs = oakcommon::configstore::ConfigStore::instance()
+ .get_int(Some("Storage"), "SnapshotIntervalSec", 600);
+ if secs <= 0 {
+ Duration::from_millis(100)
+ } else {
+ Duration::from_secs(secs as u64)
+ }
+}
+
+/// Snapshot every dirty binding at its head seq (latest-wins: a project
+/// edited since the last pass snapshots at the newer head; an unchanged
+/// head is a backend no-op).
+fn snapshot_dirty() {
+ let jobs: Vec<(usize, String, String, u64)> = {
+ let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ g.iter()
+ .filter(|(_, b)| b.dirty)
+ .map(|(k, b)| (*k, b.uri.clone(), b.uuid.clone(), b.write_gen))
+ .collect()
+ };
+ for (key, uri, uuid, gen) in jobs {
+ let res = match StorageUri::parse(&uri) {
+ Ok(u) => backend().snapshot(&u, &uuid),
+ Err(_) => {
+ record_error(key, "invalid storage uri");
+ continue;
+ }
+ };
+ match res {
+ Ok(()) => {
+ let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ if let Some(b) = g.get_mut(&key) {
+ // Only clear when no write landed since the snapshot
+ // started (the generation counter detects it).
+ if b.write_gen == gen {
+ b.dirty = false;
+ }
+ }
+ }
+ Err(e) => record_error(key, &format!("{e}")),
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Exit flush
+// ---------------------------------------------------------------------------
+
+/// The facade exit path: stop the snapshot thread, then save + snapshot
+/// every still-bound project (plan §2 "退出前 flush"). Idempotent; safe
+/// to call again after new projects are bound (the thread restarts on the
+/// next bind).
+pub fn flush_all() {
+ let _ = catch_unwind(AssertUnwindSafe(|| {
+ // 1. Stop the thread and wait for it to finish its pass.
+ {
+ let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
+ rt.stop = true;
+ }
+ SNAPSHOT_CV.notify_all();
+ {
+ let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
+ if let Some(h) = rt.handle.take() {
+ drop(rt);
+ let _ = h.join();
+ }
+ }
+ // 2. Drain every binding (write-through + snapshot).
+ let keys: Vec = {
+ let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ g.keys().copied().collect()
+ };
+ for key in keys {
+ flush_one(key);
+ }
+ }));
+}
+
+/// Save + snapshot one binding when it has pending writes (nothing to do
+/// for a clean project — flush must not invent library rows).
+fn flush_one(key: usize) {
+ let dirty = {
+ let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ g.get(&key).map(|b| b.dirty).unwrap_or(false)
+ };
+ if !dirty {
+ return;
+ }
+ write_through(key);
+ // Snapshot the head the write-through just reached (capture the
+ // generation AFTER the write so the drain clears the dirty flag).
+ let job = {
+ let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ match g.get(&key) {
+ Some(b) => (b.uri.clone(), b.uuid.clone(), b.write_gen),
+ None => return,
+ }
+ };
+ let res = match StorageUri::parse(&job.0) {
+ Ok(u) => backend().snapshot(&u, &job.1),
+ Err(_) => {
+ record_error(key, "invalid storage uri");
+ return;
+ }
+ };
+ match res {
+ Ok(()) => {
+ let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
+ if let Some(b) = g.get_mut(&key) {
+ if b.write_gen == job.2 {
+ b.dirty = false;
+ }
+ }
+ }
+ Err(e) => record_error(key, &format!("{e}")),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Facade exports
+// ---------------------------------------------------------------------------
+
+/// `oakengine_storage_flush` — flush every bound project (write-through +
+/// snapshot) and stop the snapshot thread. The app calls this on exit
+/// (the facade's shutdown path; the write-through is already per-command,
+/// so this only drains the periodic snapshot backlog).
+#[no_mangle]
+pub extern "C" fn oakengine_storage_flush() -> c_int {
+ flush_all();
+ crate::error::OAKENGINE_OK
+}
+
+/// `oakengine_storage_is_bound` — 1 when `project` is bound to a library
+/// session, 0 otherwise (NULL project -> 0).
+#[no_mangle]
+pub unsafe extern "C" fn oakengine_storage_is_bound(
+ project: *mut OakEngineProject,
+) -> c_int {
+ guard_int(|| unsafe {
+ let h = crate::handle::unbox(project)?;
+ Ok(is_bound(h) as c_int)
+ })
+}
+
+/// `oakengine_storage_last_error` — the last write-through / snapshot
+/// error of `project` (buf/size convention; empty when none or not
+/// bound).
+#[no_mangle]
+pub unsafe extern "C" fn oakengine_storage_last_error(
+ project: *mut OakEngineProject,
+ buf: *mut c_char,
+ buf_size: c_int,
+) -> c_int {
+ guard_int(|| unsafe {
+ let h = crate::handle::unbox(project)?;
+ let msg = last_error(h).unwrap_or_default();
+ Ok(crate::handle::write_string(&msg, buf, buf_size))
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn library_uri_resolves_configured_path() {
+ use oakcommon::configstore::ConfigStore;
+ let store = ConfigStore::instance();
+ let _g = crate::tests::common::STORAGE_CONFIG_LOCK
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+
+ // A configured path wins and yields an absolute oakdb+sqlite uri.
+ let dir =
+ std::env::temp_dir().join(format!("oakengine_storage_uri_{}", std::process::id()));
+ let _ = std::fs::create_dir_all(&dir);
+ let lib = dir.join("lib.db");
+ store.set(Some("Storage"), "Backend", "sqlite");
+ store.set(Some("Storage"), "SqlitePath", &lib.to_string_lossy());
+ let uri = library_uri().expect("configured path resolves");
+ assert!(uri.starts_with("oakdb+sqlite://"), "{uri}");
+ assert!(uri.ends_with("lib.db"), "{uri}");
+ assert!(storage_enabled());
+
+ // Leave the store in a safe state: backend off (the config store
+ // has no remove API, so later unguarded tests see "off" and never
+ // bind a project to a library).
+ store.set(Some("Storage"), "Backend", "off");
+ assert!(!storage_enabled());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/crates/oakengine/src/task.rs b/crates/oakengine/src/task.rs
index 635907142..81ed5c68f 100644
--- a/crates/oakengine/src/task.rs
+++ b/crates/oakengine/src/task.rs
@@ -895,7 +895,9 @@ pub unsafe extern "C" fn oakengine_task_save_get_project(
///
/// The module's `oaktask_load_take_project` is the load-result getter the
/// engine facade has no export for (the app's interchange-open path); it is
-/// wrapped here so the app can stay on the `oakengine_*` surface.
+/// wrapped here so the app can stay on the `oakengine_*` surface. A taken
+/// project is bound to the default library (plan M13 D2 — the "task load
+/// 完成" hook).
#[no_mangle]
pub unsafe extern "C" fn oakengine_task_load_take_project(
task: *mut OakEngineTask,
@@ -906,6 +908,7 @@ pub unsafe extern "C" fn oakengine_task_load_take_project(
if project.is_null() {
return Ok(std::ptr::null_mut());
}
+ crate::storage::bind_project(project);
Ok(box_handle::(project))
})
}
diff --git a/crates/oakengine/src/test_support/common/mod.rs b/crates/oakengine/src/test_support/common/mod.rs
index c59a4e45a..b0d7ad602 100644
--- a/crates/oakengine/src/test_support/common/mod.rs
+++ b/crates/oakengine/src/test_support/common/mod.rs
@@ -76,6 +76,35 @@ pub fn with_manager(f: impl FnOnce()) {
f()
}
+/// Serializes every test that reads or writes the `Storage` config group
+/// (the facade's write-through library selection — see src/storage.rs).
+/// The config store is process-global, so the write-through tests and the
+/// tests that disable the backend must take this lock for their whole
+/// body instead of racing on the shared store.
+pub static STORAGE_CONFIG_LOCK: Mutex<()> = Mutex::new(());
+
+/// Run `f` with the write-through storage backend disabled
+/// (`Storage/Backend = "off"`). Tests that push undo commands on real
+/// projects (e.g. `oakengine_project_add_node`) would otherwise bind them
+/// to the default user library and write there; disabling the backend
+/// keeps them side-effect-free. The value intentionally persists — every
+/// storage test sets its own backend explicitly under
+/// [`STORAGE_CONFIG_LOCK`].
+pub fn with_storage_off(f: impl FnOnce() -> R) -> R {
+ let _g = storage_off_guard();
+ f()
+}
+
+/// Take the storage-config lock AND disable the write-through backend,
+/// returning the guard: a test that pushes undo commands throughout its
+/// body holds the guard (and thus the lock) for its whole lifetime, so a
+/// concurrently running storage test cannot flip the backend mid-test.
+pub fn storage_off_guard() -> std::sync::MutexGuard<'static, ()> {
+ let g = STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ oakcommon::configstore::ConfigStore::instance().set(Some("Storage"), "Backend", "off");
+ g
+}
+
// ---------------------------------------------------------------------------
// oakcore_* stubs (see module docs)
// ---------------------------------------------------------------------------
diff --git a/crates/oakengine/src/test_support/common_smoke.rs b/crates/oakengine/src/test_support/common_smoke.rs
index 30d2d96ea..e90491038 100644
--- a/crates/oakengine/src/test_support/common_smoke.rs
+++ b/crates/oakengine/src/test_support/common_smoke.rs
@@ -38,8 +38,19 @@ use crate::common::{
};
/// Config: load/save, string and int round-trips, missing-key behavior.
+///
+/// Serialized with the write-through tests (the config store is
+/// process-global) and redirected to a temp `OAK_CONFIG_DIR` — without the
+/// redirect, `oakengine_config_save` would write the real `config.ini`.
#[test]
fn config_round_trip() {
+ let _config = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let dir = std::env::temp_dir().join(format!(
+ "oakengine_common_smoke_config_{}",
+ std::process::id()
+ ));
+ let _ = std::fs::create_dir_all(&dir);
+ std::env::set_var("OAK_CONFIG_DIR", &dir);
assert_eq!(unsafe { oakengine_config_load() }, 0);
// Missing key reads as 0 / empty.
@@ -92,6 +103,10 @@ fn config_round_trip() {
);
assert_eq!(unsafe { oakengine_config_save() }, 0);
+ assert!(dir.join("config.ini").exists());
+
+ std::env::remove_var("OAK_CONFIG_DIR");
+ let _ = std::fs::remove_dir_all(&dir);
}
/// Config error handler: registered, then invoked via report_error.
diff --git a/crates/oakengine/src/test_support/it_common.rs b/crates/oakengine/src/test_support/it_common.rs
index 83bb3d9b4..07ccd13cc 100644
--- a/crates/oakengine/src/test_support/it_common.rs
+++ b/crates/oakengine/src/test_support/it_common.rs
@@ -40,7 +40,6 @@ use super::common;
use std::ffi::{c_char, c_int};
use std::path::Path;
use std::sync::atomic::{AtomicI32, Ordering};
-use std::sync::Mutex;
use crate::common::{
oakengine_config_get_int, oakengine_config_get_string, oakengine_config_load,
@@ -71,8 +70,11 @@ unsafe fn read_buf(buf: &mut [c_char]) -> String {
/// redirects `OAK_CONFIG_DIR` to a fresh temp dir for the duration of `f`
/// (same pattern as the oakcommon crate's own test support). The only
/// readers of `OAK_CONFIG_DIR` in this binary are these serialized tests.
+/// The serialization uses the SHARED storage-config lock (common), so the
+/// config tests never race the write-through tests on the singleton store
+/// or the env override.
fn with_temp_config_dir(f: impl FnOnce(&Path) -> T) -> T {
- let _guard = CONFIG_LOCK.lock().unwrap();
+ let _guard = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir =
std::env::temp_dir().join(format!("oakengine_it_common_config_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
@@ -84,7 +86,7 @@ fn with_temp_config_dir(f: impl FnOnce(&Path) -> T) -> T {
}
/// The process-wide config store is a singleton; see module doc.
-static CONFIG_LOCK: Mutex<()> = Mutex::new(());
+// (Serialization now uses the shared `common::STORAGE_CONFIG_LOCK`.)
// ---------------------------------------------------------------------------
// config.h
diff --git a/crates/oakengine/src/test_support/it_export.rs b/crates/oakengine/src/test_support/it_export.rs
index 1a788da8b..243fb3307 100644
--- a/crates/oakengine/src/test_support/it_export.rs
+++ b/crates/oakengine/src/test_support/it_export.rs
@@ -78,9 +78,23 @@ const CODEC_AAC: c_int = 12;
/// does not cascade into `PoisonError` failures in every later test.
static SERIAL: Mutex<()> = Mutex::new(());
-/// Take the [`SERIAL`] lock, recovering from any poisoning.
-fn serial() -> std::sync::MutexGuard<'static, ()> {
- SERIAL.lock().unwrap_or_else(|e| e.into_inner())
+/// Both lock guards held by [`serial`].
+struct SerialGuard {
+ /// The [`SERIAL`] lock.
+ _task: std::sync::MutexGuard<'static, ()>,
+ /// The facade-wide undo-stack lock, so the `oakengine_project_new`
+ /// calls in these tests never race the it_undo / it_storage stack tests.
+ _stack: std::sync::MutexGuard<'static, ()>,
+}
+
+/// Take the [`SERIAL`] lock AND the global undo-stack lock, recovering
+/// from any poisoning.
+fn serial() -> SerialGuard {
+ let _task = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
+ let _stack = super::it_undo::GLOBAL_STACK_LOCK
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ SerialGuard { _task, _stack }
}
/// A unique temp path (per-process, so parallel test binaries never
@@ -105,6 +119,10 @@ unsafe fn assemble_test_sequence(
frame_count: i64,
fps: c_int,
) -> (*mut OakEngineProject, *mut OakEngineSequence) {
+ // The undoable import/add-track/add-clip commands below would bind the
+ // project to the default user library; disable the write-through for
+ // the assembly (and keep the config lock held while pushing).
+ let _storage = common::storage_off_guard();
let media_c = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap();
assert_eq!(
oakengine_testmedia_write_clip(media_c.as_ptr(), width, height, frame_count as c_int, fps),
diff --git a/crates/oakengine/src/test_support/it_storage.rs b/crates/oakengine/src/test_support/it_storage.rs
new file mode 100644
index 000000000..3e4e6f167
--- /dev/null
+++ b/crates/oakengine/src/test_support/it_storage.rs
@@ -0,0 +1,642 @@
+// 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 .
+
+//! D2 integration tests: the facade's live write-through (plan M13 §2/§3).
+//!
+//! End-to-end against real SQLite library files in temp directories,
+//! driving the facade exactly like the app: `oakengine_project_new` binds
+//! the project, undoable edits (`oakengine_project_add_node`) write
+//! through on push, and the journal is verified directly with raw sea-orm
+//! reads (the same pattern as the oakstorage database tests) plus fresh
+//! oakstorage sessions for cross-process recovery.
+//!
+//! Coverage: write-through lands journal rows without any flush; a
+//! kill-9-style session (no cleanup, a fresh session reading the same
+//! file) recovers the last command; undo history persists across sessions
+//! (`load_at`); the background snapshot thread writes and prunes
+//! snapshots and the exit flush drains; multiple bound projects keep
+//! their rows apart; and the graceful-degradation paths (backend off /
+//! unwritable library) record `last_error` without breaking the undo
+//! stack.
+//!
+//! Every test holds the shared undo-stack lock (the facade's stack is
+//! process-wide, same as the it_undo family) and the storage-config lock
+//! (the config store is process-global too), so the suite never races on
+//! either singleton.
+
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use sea_orm::entity::prelude::*;
+use sea_orm::QueryOrder;
+use oakstorage::backend::StorageBackend;
+use oakstorage::backends::database::entities::{journal, project, snapshot};
+use oakstorage::backends::database::DatabaseBackend;
+use oakstorage::error::OAKSTORAGE_OK;
+use oakstorage::handle::CHandle;
+use oakstorage::nodeutil::project_arc;
+use oakstorage::uri::StorageUri;
+
+use super::common;
+use super::it_undo::GLOBAL_STACK_LOCK;
+
+use crate::handle::OakEngineProject;
+
+/// The node type the tests add (a real graph node with an input the
+/// serializer round-trips).
+const MATH: &str = "org.olivevideoeditor.Olive.math";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/// Serialize a storage test: hold the process-global undo-stack lock AND
+/// the storage-config lock for the whole body, then point the write-through
+/// backend at a temp library.
+fn with_storage(db: &Path, interval: i32, f: impl FnOnce() -> R) -> R {
+ let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let _config = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let store = oakcommon::configstore::ConfigStore::instance();
+ store.set(Some("Storage"), "Backend", "sqlite");
+ store.set(Some("Storage"), "SqlitePath", &db.to_string_lossy());
+ store.set_int(Some("Storage"), "SnapshotIntervalSec", interval);
+ f()
+}
+
+/// Serialize a storage test with the backend disabled (`Storage/Backend =
+/// "off"`): projects bind to nothing and the undo stack stays untouched by
+/// write-throughs.
+fn with_storage_off(f: impl FnOnce() -> R) -> R {
+ let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let _config = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ oakcommon::configstore::ConfigStore::instance().set(Some("Storage"), "Backend", "off");
+ f()
+}
+
+/// A fresh, unique temp directory for one test.
+fn temp_dir(tag: &str) -> PathBuf {
+ let dir =
+ std::env::temp_dir().join(format!("oakengine_storage_{}_{}", std::process::id(), tag));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+/// `oakdb+sqlite:///…` uri for a database file.
+fn db_uri(path: &Path) -> String {
+ format!("oakdb+sqlite://{}", path.display())
+}
+
+/// `…?project=` uri selecting one library row.
+fn project_uri(db: &str, uuid: &str) -> String {
+ format!("{db}?project={uuid}")
+}
+
+/// Release an owned handle (refcount 1).
+fn release(h: CHandle) {
+ if let Some(release) = h.release {
+ unsafe { release(h.ctx) };
+ }
+}
+
+/// Create a project through the facade and initialize it (bind + active).
+fn new_project() -> *mut OakEngineProject {
+ let project = unsafe { crate::node::oakengine_project_create() };
+ assert!(!project.is_null());
+ assert_eq!(unsafe { crate::node::oakengine_project_new(project) }, 0);
+ project
+}
+
+/// The project's uuid (from its in-memory payload).
+fn project_uuid(project: *mut OakEngineProject) -> String {
+ let h = unsafe { crate::handle::unbox(project) }.expect("project handle");
+ let arc = unsafe { crate::handle::domain::project_of(&h) }.expect("project payload");
+ let guard = arc.lock().unwrap_or_else(|e| e.into_inner());
+ guard.uuid.clone()
+}
+
+/// Add a math node (an undoable command; pushes and write-throughs).
+fn add_math_node(project: *mut OakEngineProject) -> *mut crate::handle::OakEngineNode {
+ unsafe {
+ crate::node::oakengine_project_add_node(
+ project,
+ c"org.olivevideoeditor.Olive.math".as_ptr(),
+ )
+ }
+}
+
+/// Load the head state through a *fresh* database backend (a new
+/// connection pool = a new session, as after a process restart).
+fn load_head(uri: &str) -> std::sync::Arc> {
+ let parsed = StorageUri::parse(uri).unwrap();
+ let result = DatabaseBackend::new().load(&parsed).unwrap();
+ assert_eq!(result.version_info, OAKSTORAGE_OK);
+ let handle = result.project;
+ let loaded = unsafe { project_arc(&handle) }.unwrap();
+ release(handle);
+ loaded
+}
+
+/// Load the state at `seq` through a fresh database backend.
+fn load_at(uri: &str, uuid: &str, seq: i64) -> std::sync::Arc> {
+ let parsed = StorageUri::parse(uri).unwrap();
+ let handle = DatabaseBackend::new()
+ .load_at(&parsed, uuid, seq)
+ .unwrap();
+ let loaded = unsafe { project_arc(&handle) }.unwrap();
+ release(handle);
+ loaded
+}
+
+/// Count the math nodes of a loaded project (the root folder is slot 0).
+fn math_count(p: &oaknode::project::Project) -> usize {
+ p.graph
+ .node_ids()
+ .into_iter()
+ .filter(|id| {
+ p.graph
+ .get(*id)
+ .map(|e| e.behavior.type_id() == MATH)
+ .unwrap_or(false)
+ })
+ .count()
+}
+
+/// Open a raw sea-orm connection to the library file and drive one future
+/// against it on a private current-thread runtime (inspection behind the
+/// backend's back — same pattern as the oakstorage database tests).
+fn inspect_db(path: &Path, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R
+where
+ Fut: std::future::Future