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, +{ + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let options = sea_orm::sqlx::sqlite::SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true) + .journal_mode(sea_orm::sqlx::sqlite::SqliteJournalMode::Wal) + .busy_timeout(Duration::from_secs(5)) + .foreign_keys(true); + let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + f(sea_orm::DatabaseConnection::from(pool)).await + }) +} + +/// The `(command_seq, journal rows)` of a library row. +fn journal_rows( + path: &Path, + uuid: &str, +) -> (i64, Vec) { + let uuid = uuid.to_string(); + inspect_db(path, move |conn| async move { + let proj = project::Entity::find() + .filter(project::Column::Uuid.eq(&uuid)) + .one(&conn) + .await + .unwrap() + .expect("library row exists"); + let rows = journal::Entity::find() + .filter(journal::Column::ProjectId.eq(proj.id)) + .order_by_asc(journal::Column::Seq) + .order_by_asc(journal::Column::NodeIdentity) + .all(&conn) + .await + .unwrap(); + (proj.command_seq, rows) + }) +} + +/// The snapshot seqs of a library row (newest first). +fn snapshot_seqs(path: &Path, uuid: &str) -> Vec { + let uuid = uuid.to_string(); + inspect_db(path, move |conn| async move { + let proj = project::Entity::find() + .filter(project::Column::Uuid.eq(&uuid)) + .one(&conn) + .await + .unwrap() + .expect("library row exists"); + snapshot::Entity::find() + .filter(snapshot::Column::ProjectId.eq(proj.id)) + .order_by_desc(snapshot::Column::CommandSeq) + .all(&conn) + .await + .unwrap() + .into_iter() + .map(|s| s.command_seq) + .collect() + }) +} + +// --------------------------------------------------------------------------- +// Write-through +// --------------------------------------------------------------------------- + +/// Every undoable edit lands in the journal synchronously (no flush): the +/// first edit is the import command, later edits are diffs. +#[test] +fn write_through_persists_commands() { + common::force_link(); + let dir = temp_dir("wt"); + let db = dir.join("lib.db"); + with_storage(&db, 600, || { + let project = new_project(); + let node1 = add_math_node(project); + assert!(!node1.is_null()); + let node2 = add_math_node(project); + assert!(!node2.is_null()); + let uuid = project_uuid(project); + + let (head, rows) = journal_rows(&db, &uuid); + assert_eq!(head, 2, "two commands written through"); + // Seq 1 (import): the root folder + the first math node + the + // settings pseudo-node, all with only after-images. + let seq1: Vec<_> = rows.iter().filter(|r| r.seq == 1).collect(); + assert_eq!(seq1.len(), 3, "root + first math node + settings row"); + assert!(seq1.iter().all(|r| r.kind == "import")); + assert!(seq1.iter().all(|r| r.old_xml.is_none() && r.new_xml.is_some())); + // Seq 2 (redo diff): exactly the second math node. + let seq2: Vec<_> = rows.iter().filter(|r| r.seq == 2).collect(); + assert_eq!(seq2.len(), 1, "one changed node in the diff"); + assert_eq!(seq2[0].kind, "redo"); + assert!(seq2[0].new_xml.as_deref().unwrap().contains(MATH)); + + // The head state reads back through a fresh session (3 nodes: root + // + two math nodes), and the import point has exactly one math node. + let uri = db_uri(&db); + let head_loaded = load_head(&project_uri(&uri, &uuid)); + let guard = head_loaded.lock().unwrap(); + assert_eq!(guard.graph.node_count(), 3); + assert_eq!(math_count(&guard), 2); + drop(guard); + let at1 = load_at(&uri, &uuid, 1); + let guard = at1.lock().unwrap(); + assert_eq!(guard.graph.node_count(), 2); + assert_eq!(math_count(&guard), 1); + + unsafe { crate::node::oakengine_project_free(project) }; + }); + let _ = std::fs::remove_dir_all(&dir); +} + +/// kill -9 recovery: the write-through is synchronous, so a fresh session +/// reading the same file (no flush, no cleanup) sees the state after the +/// LAST command. +#[test] +fn kill_nine_recovers_last_command() { + common::force_link(); + let dir = temp_dir("k9"); + let db = dir.join("lib.db"); + with_storage(&db, 600, || { + let project = new_project(); + for _ in 0..3 { + let node = add_math_node(project); + assert!(!node.is_null()); + } + let uuid = project_uuid(project); + + // NOTE: the project is intentionally NOT freed — the process "dies" + // here. The journal already holds every command. + let uri = db_uri(&db); + let (head, rows) = journal_rows(&db, &uuid); + assert_eq!(head, 3); + assert_eq!(rows.len(), 5, "3 import rows + 2 diff rows"); + + let loaded = load_head(&project_uri(&uri, &uuid)); + let guard = loaded.lock().unwrap(); + assert_eq!(guard.graph.node_count(), 4, "root + three math nodes"); + assert_eq!(math_count(&guard), 3); + + // Cleanup without flush would leave the file behind; free the + // project so the temp dir can be removed. + unsafe { crate::node::oakengine_project_free(project) }; + }); + let _ = std::fs::remove_dir_all(&dir); +} + +// --------------------------------------------------------------------------- +// Undo across sessions +// --------------------------------------------------------------------------- + +/// The journal is the persistent undo history: three commands, one undo, +/// then a NEW session's `load_at(2)` reproduces the post-command-2 state +/// while `load_at(3)` still yields the pre-undo (post-command-3) state. +/// +/// The undoable operations are label renames: their undo closure captures +/// `(project, id)` and reliably reverts, unlike the add-node command +/// (whose factory handle is released at push — a facade limitation). +#[test] +fn undo_history_crosses_sessions() { + common::force_link(); + let dir = temp_dir("ux"); + let db = dir.join("lib.db"); + with_storage(&db, 600, || { + let project = new_project(); + // Command 1: add a math node (import). + let node = add_math_node(project); + assert!(!node.is_null()); + // Command 2: rename to "Alpha". + assert_eq!( + unsafe { crate::node::oakengine_node_set_label(node, c"Alpha".as_ptr()) }, + 0 + ); + // Command 3: rename to "Beta". + assert_eq!( + unsafe { crate::node::oakengine_node_set_label(node, c"Beta".as_ptr()) }, + 0 + ); + let uuid = project_uuid(project); + + // Undo one command through the facade (jump + write-through). + assert_eq!(unsafe { crate::node::oakengine_project_undo(project) }, 0); + + let uri = db_uri(&db); + let (head, _) = journal_rows(&db, &uuid); + assert_eq!(head, 4, "the undo itself is a written command"); + + // A new session at seq 2 = the state right after command 2. + let at2 = load_at(&uri, &uuid, 2); + assert_eq!(math_label(&at2), "Alpha"); + // At seq 3 the pre-undo state (after command 3) is intact. + let at3 = load_at(&uri, &uuid, 3); + assert_eq!(math_label(&at3), "Beta"); + // The head (default load) matches the undone state. + let head_loaded = load_head(&project_uri(&uri, &uuid)); + assert_eq!(math_label(&head_loaded), "Alpha"); + + unsafe { crate::node::oakengine_project_free(project) }; + }); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The label of the first math node of a loaded project (its renames are +/// the undoable operations of [`undo_history_crosses_sessions`]). +fn math_label(arc: &std::sync::Arc>) -> String { + let guard = arc.lock().unwrap(); + let id = guard + .graph + .node_ids() + .into_iter() + .find(|id| { + guard + .graph + .get(*id) + .map(|e| e.behavior.type_id() == MATH) + .unwrap_or(false) + }) + .expect("a math node is present"); + guard.graph.get(id).unwrap().core.label.clone() +} + +// --------------------------------------------------------------------------- +// Snapshot thread +// --------------------------------------------------------------------------- + +/// The background snapshot thread writes periodic snapshots of dirty +/// projects (short interval), the backend prunes to the newest three, and +/// the exit flush drains and leaves a snapshot behind. +#[test] +fn snapshot_thread_and_exit_flush() { + common::force_link(); + let dir = temp_dir("snap"); + let db = dir.join("lib.db"); + with_storage(&db, 1, || { + let project = new_project(); + let uuid = project_uuid(project); + + // Four command batches ~1.1 s apart (the 1 s interval): each batch + // is a rapid pair of writes so the save-time snapshot policy stays + // quiet and the THREAD is the one capturing the new head. + for _ in 0..4 { + let a = add_math_node(project); + assert!(!a.is_null()); + let b = add_math_node(project); + assert!(!b.is_null()); + std::thread::sleep(Duration::from_millis(1100)); + } + // One more tick window so the final batch is snapshotted too. + std::thread::sleep(Duration::from_millis(1500)); + + let seqs = snapshot_seqs(&db, &uuid); + assert!(!seqs.is_empty(), "the thread produced snapshots"); + assert!( + seqs.len() <= 3, + "pruning keeps at most three (got {:?})", + seqs + ); + let (head, _) = journal_rows(&db, &uuid); + assert_eq!(head, 8, "eight commands written through"); + + // Exit flush: drains (save + snapshot) and leaves the snapshot at + // the head seq regardless of thread timing. + assert_eq!(crate::storage::oakengine_storage_flush(), 0); + let seqs = snapshot_seqs(&db, &uuid); + assert!(!seqs.is_empty(), "flush leaves a snapshot behind"); + assert_eq!(seqs[0], 8, "the newest snapshot covers the head seq"); + + unsafe { crate::node::oakengine_project_free(project) }; + }); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A dirty project flushed on close (project_free) gets its head snapshot +/// even with a long (default) interval — the flush, not the thread, is the +/// guaranteed drain. +#[test] +fn close_flushes_snapshot() { + common::force_link(); + let dir = temp_dir("flush"); + let db = dir.join("lib.db"); + with_storage(&db, 600, || { + let project = new_project(); + let uuid = project_uuid(project); + let node = add_math_node(project); + assert!(!node.is_null()); + let node2 = add_math_node(project); + assert!(!node2.is_null()); + + // With the 600 s interval only the FIRST save snapshotted (the + // backend's policy fires when no snapshot exists yet), so the head + // seq is not covered. + assert_eq!(snapshot_seqs(&db, &uuid), vec![1]); + + // Closing the project flushes: write-through + snapshot at the head. + unsafe { crate::node::oakengine_project_free(project) }; + + let seqs = snapshot_seqs(&db, &uuid); + assert_eq!(seqs[0], 2, "close flushed a snapshot at the head seq"); + }); + let _ = std::fs::remove_dir_all(&dir); +} + +// --------------------------------------------------------------------------- +// Multi-project bindings +// --------------------------------------------------------------------------- + +/// Two projects bound to one library keep their rows apart: each project's +/// writes advance only its own journal. +#[test] +fn multi_project_bindings_do_not_cross() { + common::force_link(); + let dir = temp_dir("multi"); + let db = dir.join("lib.db"); + with_storage(&db, 600, || { + // Project A: two commands. + let a = new_project(); + for _ in 0..2 { + let node = add_math_node(a); + assert!(!node.is_null()); + } + let uuid_a = project_uuid(a); + + // Project B: binding it does not disturb A's row; its write goes + // only to B's row (A's saves during B's commands are no-ops). + let b = new_project(); + let node = add_math_node(b); + assert!(!node.is_null()); + let uuid_b = project_uuid(b); + assert_ne!(uuid_a, uuid_b); + + let (head_a, rows_a) = journal_rows(&db, &uuid_a); + assert_eq!(head_a, 2, "A's journal stops at its own second command"); + assert!(rows_a.iter().all(|r| r.seq <= 2)); + let (head_b, rows_b) = journal_rows(&db, &uuid_b); + assert_eq!(head_b, 1, "B has exactly its one command"); + assert!(rows_b.iter().all(|r| r.seq == 1)); + + // Both projects load correctly through fresh sessions. + let uri = db_uri(&db); + let loaded_a = load_head(&project_uri(&uri, &uuid_a)); + let guard = loaded_a.lock().unwrap(); + assert_eq!(math_count(&guard), 2); + drop(guard); + let loaded_b = load_head(&project_uri(&uri, &uuid_b)); + let guard = loaded_b.lock().unwrap(); + assert_eq!(math_count(&guard), 1); + + unsafe { crate::node::oakengine_project_free(a) }; + unsafe { crate::node::oakengine_project_free(b) }; + }); + let _ = std::fs::remove_dir_all(&dir); +} + +// --------------------------------------------------------------------------- +// Graceful degradation +// --------------------------------------------------------------------------- + +/// With `Storage/Backend = "off"` projects bind to nothing: the undo +/// stack works, no library file is created, and `is_bound` reports 0. +#[test] +fn backend_off_keeps_projects_unbound() { + common::force_link(); + let dir = temp_dir("off"); + let db = dir.join("lib.db"); + with_storage_off(|| { + let project = new_project(); + // Not bound: no write-through happens at all. + assert_eq!(unsafe { crate::storage::oakengine_storage_is_bound(project) }, 0); + + let node = add_math_node(project); + assert!(!node.is_null()); + let node2 = add_math_node(project); + assert!(!node2.is_null()); + + // The undo stack still works. + assert_eq!(unsafe { crate::node::oakengine_project_undo(project) }, 0); + assert_eq!(unsafe { crate::node::oakengine_project_redo(project) }, 0); + + // Nothing was ever written to the configured library path. + assert!(!db.exists(), "no library file with the backend off"); + + unsafe { crate::node::oakengine_project_free(project) }; + }); + let _ = std::fs::remove_dir_all(&dir); +} + +/// An unwritable library degrades gracefully: the write-through records a +/// `last_error` instead of failing the command or crashing, and the undo +/// stack keeps working. +#[test] +fn unwritable_library_records_last_error() { + common::force_link(); + let dir = temp_dir("ro"); + let db = dir.join("no/such/dir/lib.db"); // parent dir does not exist + with_storage(&db, 600, || { + let project = new_project(); + // Bound, but the first write-through cannot open the library. + assert_eq!(unsafe { crate::storage::oakengine_storage_is_bound(project) }, 1); + + let node = add_math_node(project); + assert!(!node.is_null()); + + // The command succeeded; the write failure is recorded, not raised. + let mut buf = [0 as std::ffi::c_char; 512]; + let len = unsafe { crate::storage::oakengine_storage_last_error(project, buf.as_mut_ptr(), 512) }; + assert!(len > 0, "the failed write-through is recorded"); + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_string_lossy() + .into_owned(); + assert!(!msg.is_empty(), "error message non-empty"); + + // A second command degrades the same way. + let node2 = add_math_node(project); + assert!(!node2.is_null()); + let len = + unsafe { crate::storage::oakengine_storage_last_error(project, buf.as_mut_ptr(), 512) }; + assert!(len > 0); + + // The undo stack is unaffected. + assert_eq!(unsafe { crate::node::oakengine_project_undo(project) }, 0); + + unsafe { crate::node::oakengine_project_free(project) }; + }); + let _ = std::fs::remove_dir_all(&dir); +} + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +/// The default library path resolves to `/library.db` +/// (absolute), and the backend is strictly config-driven: `Backend = +/// "sqlite"` enables it, `"off"` disables it (and an absent `Storage` +/// group means "no library configured" — projects stay unbound). +#[test] +fn default_library_path_and_backend() { + common::force_link(); + 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()); + + // The default path is a plain absolute `…/library.db`. + let p = crate::storage::default_library_path(); + let path = std::path::Path::new(&p); + assert!(path.is_absolute(), "{p}"); + assert!(p.ends_with("library.db"), "{p}"); + + // Explicit values drive the enabled state. + let store = oakcommon::configstore::ConfigStore::instance(); + store.set(Some("Storage"), "Backend", "sqlite"); + assert!(crate::storage::storage_enabled()); + store.set(Some("Storage"), "Backend", "off"); + assert!(!crate::storage::storage_enabled()); +} diff --git a/crates/oakengine/src/test_support/it_task.rs b/crates/oakengine/src/test_support/it_task.rs index 3d161aaeb..bd63ef104 100644 --- a/crates/oakengine/src/test_support/it_task.rs +++ b/crates/oakengine/src/test_support/it_task.rs @@ -88,9 +88,25 @@ const OAKTASK_E_NOT_FOUND: c_int = -80004; /// does not cascade into `PoisonError` failures in every later test. static SERIAL: Mutex<()> = Mutex::new(()); -/// Take the [`SERIAL`] lock, recovering from any poisoning. -pub(crate) fn serial() -> std::sync::MutexGuard<'static, ()> { - SERIAL.lock().unwrap_or_else(|e| e.into_inner()) +/// Both lock guards held by [`serial`] (the local task lock plus the +/// facade-wide undo-stack lock). +pub(crate) struct SerialGuard { + /// The [`SERIAL`] lock. + _task: std::sync::MutexGuard<'static, ()>, + /// The facade's process-wide undo-stack lock (it_undo's), so the + /// `oakengine_project_new` calls in these tests (which clear the stack) + /// 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. +pub(crate) 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 } } /// The facade's live task-payload counter (the engine-side replacement @@ -904,6 +920,9 @@ fn export_task_creation() { fn export_task_run_real_encoder() { let _g = serial(); common::force_link(); + // The import/add-track/add-clip commands below are undoable; disable + // the write-through backend so they never touch a library. + let _storage = common::storage_off_guard(); let media = std::env::temp_dir().join(format!( "oakengine-it-task-export-src-{}.mp4", diff --git a/crates/oakengine/src/test_support/it_undo.rs b/crates/oakengine/src/test_support/it_undo.rs index c833918f0..c624a92bb 100644 --- a/crates/oakengine/src/test_support/it_undo.rs +++ b/crates/oakengine/src/test_support/it_undo.rs @@ -90,8 +90,9 @@ unsafe fn read_str(buf: *const c_char) -> String { /// `null_name_group_repro`, `group_abort_undoes_children_repro`): cargo /// runs tests on parallel threads and the global stack / single open undo /// group cannot be shared, so each of those tests holds this lock for its -/// whole body. -static GLOBAL_STACK_LOCK: Mutex<()> = Mutex::new(()); +/// whole body. Public so the write-through tests (it_storage.rs), which +/// push commands on the same global stack, serialize on the SAME lock. +pub static GLOBAL_STACK_LOCK: Mutex<()> = Mutex::new(()); static LIFECYCLE_REDO: AtomicI32 = AtomicI32::new(0); static LIFECYCLE_UNDO: AtomicI32 = AtomicI32::new(0); diff --git a/crates/oakengine/src/test_support/mod.rs b/crates/oakengine/src/test_support/mod.rs index ac8d6c68f..da26b2297 100644 --- a/crates/oakengine/src/test_support/mod.rs +++ b/crates/oakengine/src/test_support/mod.rs @@ -42,6 +42,7 @@ mod it_codec; mod it_common; mod it_export; mod it_plugin; +mod it_storage; mod it_task; mod it_undo; mod linkage; diff --git a/crates/oakengine/src/test_support/node.rs b/crates/oakengine/src/test_support/node.rs index d2a8cd453..9e0a52c6f 100644 --- a/crates/oakengine/src/test_support/node.rs +++ b/crates/oakengine/src/test_support/node.rs @@ -108,6 +108,10 @@ unsafe fn find_node(project: *mut crate::handle::OakEngineProject, id: &str) -> fn project_node_keyframe_lifecycle() { common::force_link(); let _ = force_oakundo_command_link(); + // The undo commands below would bind the project to the default user + // library and write through to it; hold the storage lock and disable + // the backend for the whole test. + let _storage = common::storage_off_guard(); // ---- project: create → new → name/filename readback ---------------- let project = oakengine_project_create(); diff --git a/crates/oakengine/src/test_support/undo.rs b/crates/oakengine/src/test_support/undo.rs index 28e5b93c6..4fbfc79b0 100644 --- a/crates/oakengine/src/test_support/undo.rs +++ b/crates/oakengine/src/test_support/undo.rs @@ -153,6 +153,13 @@ fn multi_command_add_child_count_redo() { /// exercised from parallel test threads. #[test] fn undo_stack_lifecycle() { + // Serialized on the SAME lock as the it_undo stack tests and the + // write-through tests (the facade's stack is process-wide): without it + // a concurrent test's pushes break the exact-count assertions below. + let _stack = super::it_undo::GLOBAL_STACK_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + // Reset to a clean "New/Open Project" base row. assert_eq!(unsafe { oakengine_undo_clear() }, 0); assert_eq!(unsafe { oakengine_undo_count() }, 1); diff --git a/crates/oakengine/src/undo.rs b/crates/oakengine/src/undo.rs index 5d7b00738..c8aeff4d6 100644 --- a/crates/oakengine/src/undo.rs +++ b/crates/oakengine/src/undo.rs @@ -116,8 +116,10 @@ pub(crate) unsafe fn push_or_run( }; let rc = unsafe { undostack_push(stack, cmd, label_ptr) }; if rc == 0 { - // Stack took a reference; release ours by freeing the box. + // Stack took a reference; release ours by freeing the box. The + // command's redo already ran (plan M13 D2): persist the project. unsafe { free_box(command_box) }; + crate::storage::note_command(); Ok(()) } else { // Push failed (e.g. empty multi): the module deleted the command; @@ -193,6 +195,9 @@ pub extern "C" fn oakengine_undo_group_end() -> c_int { let mut multi_handle = multi; unsafe { command_free(&mut multi_handle) }; if rc == 0 { + // The group's children were redo'd eagerly at push time; the + // whole group is one command (plan §2: commit at group_end). + crate::storage::note_command(); Ok(()) } else { Err(Error::Module(rc)) @@ -408,10 +413,15 @@ pub extern "C" fn oakengine_undo_command_is_done(row: i64) -> c_int { } /// `oakengine_undo_jump` — undo/redo until the done-command count equals -/// `index`. +/// `index`. On success the bound projects are written through (the jump +/// executed the undo/redo callbacks that mutated them). #[no_mangle] pub extern "C" fn oakengine_undo_jump(index: i64) -> c_int { - guard(|| unsafe { Error::from_module(undostack_jump(*global_stack(), index)) }) + let rc = guard(|| unsafe { Error::from_module(undostack_jump(*global_stack(), index)) }); + if rc == crate::error::OAKENGINE_OK { + crate::storage::note_command(); + } + rc } /// `oakengine_undo_clear` — delete all commands and push the fresh diff --git a/docs/zh/plans/riir/M13-storage-live.md b/docs/zh/plans/riir/M13-storage-live.md index 621e7196b..298dd122b 100644 --- a/docs/zh/plans/riir/M13-storage-live.md +++ b/docs/zh/plans/riir/M13-storage-live.md @@ -97,6 +97,19 @@ facade 的 undo 推送路径挂钩(`oakengine_undo_push` / `undo_group_end` 新建/导入建立 (project ↔ storage session) 绑定;关闭解绑。状态栏 脏标记改为"已写入/写入中"。 +> D2 落地记录(2026-08):`crates/oakengine/src/storage.rs` 实现绑定表 +> (project handle ctx → `{db uri, uuid}`)、写穿(每次 undo 路径成功 +> 后对全部绑定工程调 `DatabaseBackend::save`,diff 式,未变工程 no-op)、 +> 快照线程(`Storage/SnapshotIntervalSec`,默认 600s,latest-wins, +> 退出 `oakengine_storage_flush` 排空)与 `last_error` 降级。 +> **配置默认值**:`Storage/Backend` 的默认值 = `"sqlite"`,`Storage/SqlitePath` +> 的默认值 = `<系统数据目录>/library.db`(`FileFunctions::get_configuration_location` +> 的 macOS Application Support / XDG 位置,尊重 `OAK_CONFIG_DIR`)。 +> **启用语义**:`Storage/Backend` 显式为 `"sqlite"`/`"database"` 才启用写穿; +> 键缺失 = "无库配置"(工程不绑定、写穿不触发)——这是 §2"无库配置优雅 +> 降级"的默认形态,保证 headless 消费者(oak-cli)与测试进程永远不写 +> 用户的真实库。app 侧(D4/D5)在启动时显式设置该配置即可启用。 + ## 4. 项目管理器窗口(app) 达芬奇式启动窗 + 菜单 文件→项目管理器: