From 8a2e45225fc3d37efccb8d9a00080c57ed84b4d5 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 16 Aug 2026 20:37:45 +0800 Subject: [PATCH] refactor: sink facade glue into modules (M14 R1) - oakundo::global: the process-global undo stack, grouping and a command observer API; facade undo.rs becomes a thin forwarder - oakstorage::writethrough: the binding table, snapshot thread, flush and config resolution; it subscribes to oakundo's observer itself - the remaining non-forwarding facade logic (TaskMeta, effect chains, timeline composites, RendererBox, exporter path) is documented as facade-owned with reasons - facade exports unchanged; full suite stays green (the one render_manager_not_initialized failure is pre-existing on the base commit) --- Cargo.lock | 1 + crates/oakengine/src/storage.rs | 521 ++---------------------- crates/oakengine/src/undo.rs | 239 +++-------- crates/oakstorage/Cargo.toml | 5 + crates/oakstorage/src/lib.rs | 1 + crates/oakstorage/src/writethrough.rs | 563 ++++++++++++++++++++++++++ crates/oakundo/src/error.rs | 14 + crates/oakundo/src/global.rs | 425 +++++++++++++++++++ crates/oakundo/src/lib.rs | 1 + 9 files changed, 1096 insertions(+), 674 deletions(-) create mode 100644 crates/oakstorage/src/writethrough.rs create mode 100644 crates/oakundo/src/global.rs diff --git a/Cargo.lock b/Cargo.lock index 227b704e6..f7a56abd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4886,6 +4886,7 @@ dependencies = [ "oakcore-rs", "oaknode", "oakotio", + "oakundo", "sea-orm", "serde", "serde_json", diff --git a/crates/oakengine/src/storage.rs b/crates/oakengine/src/storage.rs index 720d35dcc..9bfa0598e 100644 --- a/crates/oakengine/src/storage.rs +++ b/crates/oakengine/src/storage.rs @@ -14,527 +14,84 @@ // 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). +//! Live write-through to the oakstorage project library — a thin +//! forward to the module's session manager (plan M13 §2/§3; M14 R1: +//! the binding map, snapshot thread and exit flush moved into +//! [`oakstorage::writethrough`]). //! -//! 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). +//! The write-through itself is subscribed directly to the oakundo +//! process-wide stack's command-success observers (see +//! [`oakstorage::writethrough`]); this module only keeps the frozen +//! `oakengine_storage_*` C ABI exports and the box/buf-size glue, and +//! re-exports the manager entry points the rest of the facade (the +//! library manager, the project family) calls. //! -//! ## 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), -//! `"database"` or `"pg"` 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). -//! - `Storage/PgUrl` — the PostgreSQL connection string (plan D3), used -//! when `Storage/Backend` is `"pg"`: `user:pass@host:5432/dbname` -//! (libpq URL form; an optional `postgres://`/`postgresql://` scheme is -//! accepted and stripped). The resolved library URI is -//! `oakdb+pg://`. When `Backend = "pg"` but `PgUrl` is absent -//! or empty, no library is configured (same graceful degradation). -//! -//! ## 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). +//! See [`oakstorage::writethrough`] for the binding model, the config +//! keys (`Storage/Backend`, `Storage/SqlitePath`, `Storage/PgUrl`, +//! `Storage/SnapshotIntervalSec`) and the graceful-degradation rules. -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, the snapshot thread and the library manager exports in -/// [`crate::library`]; the backend serializes its own operations). -pub(crate) 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. +/// Bind `project` to the configured default library (no-op when the +/// backend is disabled, the project has no uuid, or it is already bound). 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(); - })); + oakstorage::writethrough::bind_project(project); } /// Flush `project`'s pending writes and drop its binding (closing the -/// project). No-op when not bound. +/// project). 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) }; - } - } - })); + oakstorage::writethrough::unbind_project(project); } /// 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)) + oakstorage::writethrough::is_bound(project) } /// 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()) + oakstorage::writethrough::last_error(project) } -// --------------------------------------------------------------------------- -// 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. +/// Persist every bound project after a successful undo-path operation. +/// No-op when nothing is bound (also called by the module's command +/// observer; kept here for the facade's own call sites). 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); - } - })); + oakstorage::writethrough::note_command(); } -/// 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"`, `"database"` or `"pg"` 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). +/// Whether the write-through backend is enabled (config-driven). pub(crate) fn storage_enabled() -> bool { - let store = oakcommon::configstore::ConfigStore::instance(); - match store.get(Some("Storage"), "Backend") { - Ok(b) => b == "sqlite" || b == "database" || b == "pg", - Err(_) => false, - } + oakstorage::writethrough::storage_enabled() } -/// 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 configured PostgreSQL connection string (`Storage/PgUrl`; empty -/// value = not configured). -fn configured_pg_url() -> Option { - let store = oakcommon::configstore::ConfigStore::instance(); - match store.get(Some("Storage"), "PgUrl") { - Ok(u) if !u.trim().is_empty() => Some(u.trim().to_string()), - _ => None, - } -} - -/// The `oakdb+pg://…` uri of the configured PostgreSQL library (None -/// when `Storage/PgUrl` is absent). A `postgres://`/`postgresql://` -/// scheme on the config value is stripped — the oakdb uri body is the -/// bare connection string (`user:pass@host:5432/dbname`). -fn pg_library_uri() -> Option { - let url = configured_pg_url()?; - let body = url - .strip_prefix("postgres://") - .or_else(|| url.strip_prefix("postgresql://")) - .unwrap_or(&url); - Some(format!("oakdb+pg://{body}")) -} - -/// 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+…` uri of the configured library (None when the path -/// cannot be made absolute, or the PG url is missing). Shared with the -/// library manager exports in [`crate::library`]. +/// The `oakdb+…` uri of the configured library (None when it cannot be +/// resolved). Shared with the library manager exports. pub(crate) fn library_uri() -> Option { - let store = oakcommon::configstore::ConfigStore::instance(); - if store.get(Some("Storage"), "Backend").ok().as_deref() == Some("pg") { - return pg_library_uri(); - } - 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())) + oakstorage::writethrough::library_uri() } -// --------------------------------------------------------------------------- -// 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 default library file path (config-driven data directory). +pub(crate) fn default_library_path() -> String { + oakstorage::writethrough::default_library_path() } -/// 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 process-wide oakstorage backend (shared with the library manager +/// exports in [`crate::library`]). +pub(crate) fn backend() -> &'static DatabaseBackend { + oakstorage::writethrough::backend() } -/// 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). +/// The exit path: stop the snapshot thread and drain every still-bound +/// project (save + snapshot). 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}")), - } + oakstorage::writethrough::flush_all(); } // --------------------------------------------------------------------------- diff --git a/crates/oakengine/src/undo.rs b/crates/oakengine/src/undo.rs index c8aeff4d6..8a7f0bf3b 100644 --- a/crates/oakengine/src/undo.rs +++ b/crates/oakengine/src/undo.rs @@ -17,12 +17,10 @@ //! `engine/include/oakengine/undo.h` — the process-wide undo stack, //! undo groups and command lifecycle over the oakundo module. //! -//! The facade owns the process-wide undo stack (module 00 analogue of -//! `EngineCore::undo_stack()`): it is created lazily on first use and -//! lives for the process (mirroring the C++ EngineCore shell, which is -//! also leaked intentionally). The open undo group is facade state too: -//! while a group is open, every command a wrapped family hands to -//! [`push_or_run`] is added to the group instead of the stack. +//! The process-wide stack, the open undo group and the "command +//! recorded" notification now live in [`oakundo::global`] (M14 R1: sunk +//! from this facade); every export here is a thin forward that only adds +//! the engine's box/unbox, buf/size and error-code conventions. //! //! Command creators declared in undo.h but backed by other modules //! (`oakengine_node_*_command`, `oakengine_track_*_command`, @@ -30,47 +28,33 @@ //! the corresponding family modules, mirroring the C++ capi layout. use std::ffi::{c_char, c_int, c_void}; -use std::sync::{Mutex, OnceLock}; use oakundo::undocommand::{ command_free, command_init, command_init_multi, command_multi_add_child, - command_multi_child, command_multi_child_count, command_redo_now, command_undo_now, -}; -use oakundo::undostack::{ - undostack_can_redo, undostack_can_undo, undostack_clear, undostack_command_is_done, - undostack_command_text, undostack_count, undostack_index, undostack_init, undostack_jump, - undostack_push, undostack_push_pre_executed, + command_multi_child_count, command_redo_now, command_undo_now, }; + use crate::error::{Error, Result}; -use crate::handle::{box_handle, free_box, guard, guard_void, unbox, CHandle, OakEngineClipboard}; +use crate::handle::{box_handle, free_box, guard, guard_void, unbox, OakEngineClipboard}; -/// The process-wide undo stack handle (oakundo `OakUndoStack`), created -/// lazily and kept for the process lifetime. -fn global_stack() -> &'static CHandle { - static STACK: OnceLock = OnceLock::new(); - STACK.get_or_init(|| unsafe { undostack_init() }) +/// Map an oakundo error onto the facade error space for the GROUP +/// functions: `State` (no group open / already open) and the allocation +/// failure map to the facade's own codes; every other oakundo code passes +/// through as a module code (the numeric value is preserved). +fn map_group_err(e: oakundo::error::Error) -> Error { + match e { + oakundo::error::Error::State => Error::State, + oakundo::error::Error::NoMem => Error::NoMem, + oakundo::error::Error::Failed(s) => Error::Failed(s), + other => Error::Module(other.code()), + } } -/// Stable opaque token for `oakengine_undo_handle`: the module stack's -/// `ctx` pointer (never dereferenced by the facade; lives for the -/// process). -fn stack_token() -> *mut c_void { - global_stack().ctx -} - -/// The currently open undo group (a multi command handle) plus its name. -struct OpenGroup { - /// Multi command handle; owned by this state until end/abort. - multi: CHandle, - /// Group label. - #[allow(dead_code)] - name: String, -} - -static GROUP: Mutex> = Mutex::new(None); - -fn group_lock() -> std::sync::MutexGuard<'static, Option> { - GROUP.lock().unwrap_or_else(|e| e.into_inner()) +/// Map an oakundo error onto the facade error space for the STACK +/// queries: every code passes through untranslated as a module code (the +/// facade contract says module codes cross the boundary verbatim). +fn map_stack_err(e: oakundo::error::Error) -> Error { + Error::Module(e.code()) } /// Push `command` onto the stack, add it to the open group, or run it @@ -85,56 +69,23 @@ pub(crate) unsafe fn push_or_run( ) -> Result<()> { let cmd = unsafe { unbox(command_box)? }; let label = unsafe { crate::handle::read_cstr(name) }; - let g = group_lock(); - if let Some(group) = g.as_ref() { - // The module's `oakundo_command_multi_add_child` consumes the - // child's command value (command_take), so the eager redo must - // happen on the still-owned handle FIRST — the group takes the - // already-done command (C++ semantics: add_child + redo_now, net - // effect identical for the group's reverse-order undo). - let rc = unsafe { command_redo_now(cmd) }; - if rc != 0 { - return Err(Error::Module(rc)); - } - let rc = unsafe { command_multi_add_child(group.multi, cmd) }; - drop(g); - unsafe { free_box(command_box) }; - return if rc == 0 { - Ok(()) - } else { - Err(Error::Module(rc)) - }; - } - let stack = *global_stack(); - // The module treats a NULL name like an empty label, but an empty Rust - // String's `as_ptr()` is a DANGLING non-NULL pointer (0x1): the module's - // `read_name` would strlen it and SIGSEGV. Pass a real NULL instead. - let label_ptr = if label.is_empty() { - std::ptr::null() - } else { - label.as_ptr() as *const c_char - }; - let rc = unsafe { undostack_push(stack, cmd, label_ptr) }; + let rc = unsafe { oakundo::global::push_or_run(cmd, &label) }; + // The stack/multi took (or rejected) the command value; release the box + // shell either way (the command is destroyed with the stack/multi, or + // with this shell when the push failed and nobody took it). + unsafe { free_box(command_box) }; if rc == 0 { - // 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; - // release the box shell without touching the (already consumed) - // handle. - unsafe { free_box(command_box) }; Err(Error::Module(rc)) } } /// `oakengine_undo_handle` — borrowed token of the global undo stack -/// (NULL never: the facade creates the stack lazily). +/// (NULL never: the module creates the stack lazily). #[no_mangle] pub extern "C" fn oakengine_undo_handle() -> *mut c_void { - crate::handle::guard_ptr(|| Ok(stack_token())) + crate::handle::guard_ptr(|| Ok(oakundo::global::stack_token())) } /// `oakengine_undo_push` — push `command` onto the stack and execute its @@ -152,102 +103,21 @@ pub unsafe extern "C" fn oakengine_undo_push(command: *mut c_void, name: *const /// `oakengine_undo_group_begin` — start collecting commands into a group. #[no_mangle] pub extern "C" fn oakengine_undo_group_begin(name: *const c_char) -> c_int { - guard(|| { - let mut g = group_lock(); - if g.is_some() { - return Err(Error::State); - } - let multi = unsafe { command_init_multi() }; - if multi.is_null() { - return Err(Error::Failed("undo group allocation failed".into())); - } - *g = Some(OpenGroup { - multi, - name: unsafe { crate::handle::read_cstr(name) }, - }); - Ok(()) - }) + guard(|| oakundo::global::group_begin(name).map_err(map_group_err)) } /// `oakengine_undo_group_end` — close the group and push it as one entry. /// An empty group is discarded (no undo entry). #[no_mangle] pub extern "C" fn oakengine_undo_group_end() -> c_int { - guard(|| { - let mut g = group_lock(); - let open = g.take().ok_or(Error::State)?; - let multi = open.multi; - let name = open.name; - drop(g); - // Same NULL-for-empty convention as `push_or_run`: the module's - // `read_name` treats NULL like an empty label, while an empty String's - // dangling `as_ptr()` (0x1) would be strlen'd -> SIGSEGV. - let name_ptr = if name.is_empty() { - std::ptr::null() - } else { - name.as_ptr() as *const c_char - }; - // push_pre_executed discards an empty multi command. Either way - // the stack took (or destroyed) the command; release our own - // reference to the multi handle. - let stack = *global_stack(); - let rc = unsafe { undostack_push_pre_executed(stack, multi, name_ptr) }; - 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)) - } - }) + guard(|| oakundo::global::group_end().map_err(map_group_err)) } /// `oakengine_undo_group_abort` — undo all executed children and discard /// the group. #[no_mangle] pub extern "C" fn oakengine_undo_group_abort() -> c_int { - guard(|| { - let mut g = group_lock(); - let open = g.take().ok_or(Error::State)?; - drop(g); - // The multi command itself is never marked done (each child was - // redo'd eagerly at push time), so `undo_now` on it is a no-op. - // Undo the executed children individually instead, in reverse - // insertion order (mirroring the multi's reverse-order undo), each - // through its own borrowed handle. - let mut count: c_int = 0; - let rc = unsafe { command_multi_child_count(open.multi, &mut count) }; - if rc != 0 { - let mut multi = open.multi; - unsafe { command_free(&mut multi) }; - return Err(Error::Module(rc)); - } - for i in (0..count).rev() { - let mut child = CHandle::null(); - let rc = unsafe { command_multi_child(open.multi, i, &mut child) }; - if rc != 0 { - let mut multi = open.multi; - unsafe { command_free(&mut multi) }; - return Err(Error::Module(rc)); - } - let rc = unsafe { command_undo_now(child) }; - // The child handle is borrowed (owns:false): release only its - // shell — the child value lives on in the multi until the multi - // itself is freed below. - unsafe { command_free(&mut child) }; - if rc != 0 { - let mut multi = open.multi; - unsafe { command_free(&mut multi) }; - return Err(Error::Module(rc)); - } - } - let mut multi = open.multi; - unsafe { command_free(&mut multi) }; - Ok(()) - }) + guard(|| oakundo::global::group_abort().map_err(map_group_err)) } /// `oakengine_undo_command_redo_now` — execute the redo of `command` @@ -358,21 +228,13 @@ pub unsafe extern "C" fn oakengine_undo_command_free(command: *mut c_void) { /// `oakengine_undo_count` — total number of history rows. #[no_mangle] pub extern "C" fn oakengine_undo_count() -> i64 { - crate::handle::guard_i64(|| unsafe { - let mut count: i64 = 0; - Error::from_module(undostack_count(*global_stack(), &mut count))?; - Ok(count) - }) + crate::handle::guard_i64(|| oakundo::global::count().map_err(map_stack_err)) } /// `oakengine_undo_index` — current position in the history. #[no_mangle] pub extern "C" fn oakengine_undo_index() -> i64 { - crate::handle::guard_i64(|| unsafe { - let mut index: i64 = 0; - Error::from_module(undostack_index(*global_stack(), &mut index))?; - Ok(index) - }) + crate::handle::guard_i64(|| oakundo::global::index().map_err(map_stack_err)) } /// `oakengine_undo_command_text` — label of the row at `row` @@ -387,8 +249,8 @@ pub unsafe extern "C" fn oakengine_undo_command_text( // size when `buf` is NULL/too small and copies otherwise, so the // module return value is returned verbatim (guarded against panic), // converted to the engine's length-excluding-NUL convention. - crate::handle::guard_int(|| unsafe { - let rc = undostack_command_text(*global_stack(), row, buf, buf_size); + crate::handle::guard_int(|| { + let rc = oakundo::global::command_text(row, buf, buf_size); if rc < 0 { Err(Error::Module(rc)) } else { @@ -401,34 +263,27 @@ pub unsafe extern "C" fn oakengine_undo_command_text( /// undone, OAKENGINE_E_NOT_FOUND for an invalid row. #[no_mangle] pub extern "C" fn oakengine_undo_command_is_done(row: i64) -> c_int { - crate::handle::guard_int(|| unsafe { + crate::handle::guard_int(|| { let mut value: c_int = 0; - Error::from_module(undostack_command_is_done( - *global_stack(), - row, - &mut value, - ))?; + oakundo::global::command_is_done(row, &mut value).map_err(map_stack_err)?; Ok(value) }) } /// `oakengine_undo_jump` — undo/redo until the done-command count equals /// `index`. On success the bound projects are written through (the jump -/// executed the undo/redo callbacks that mutated them). +/// executed the undo/redo callbacks that mutated them) — the module's +/// command observers fire the write-through subscribers. #[no_mangle] pub extern "C" fn oakengine_undo_jump(index: i64) -> c_int { - let rc = guard(|| unsafe { Error::from_module(undostack_jump(*global_stack(), index)) }); - if rc == crate::error::OAKENGINE_OK { - crate::storage::note_command(); - } - rc + guard(|| oakundo::global::jump(index).map_err(map_stack_err)) } /// `oakengine_undo_clear` — delete all commands and push the fresh /// "New/Open Project" empty command. #[no_mangle] pub extern "C" fn oakengine_undo_clear() -> c_int { - guard(|| unsafe { Error::from_module(undostack_clear(*global_stack())) }) + guard(|| oakundo::global::clear().map_err(map_stack_err)) } /// `oakengine_undo_update_actions` — no-op: the QAction members were @@ -442,9 +297,9 @@ pub extern "C" fn oakengine_undo_update_actions() -> c_int { /// `oakengine_undo_can_undo` — 1/0. #[no_mangle] pub extern "C" fn oakengine_undo_can_undo() -> c_int { - crate::handle::guard_int(|| unsafe { + crate::handle::guard_int(|| { let mut value: c_int = 0; - Error::from_module(undostack_can_undo(*global_stack(), &mut value))?; + oakundo::global::can_undo(&mut value).map_err(map_stack_err)?; Ok(value) }) } @@ -452,9 +307,9 @@ pub extern "C" fn oakengine_undo_can_undo() -> c_int { /// `oakengine_undo_can_redo` — 1/0. #[no_mangle] pub extern "C" fn oakengine_undo_can_redo() -> c_int { - crate::handle::guard_int(|| unsafe { + crate::handle::guard_int(|| { let mut value: c_int = 0; - Error::from_module(undostack_can_redo(*global_stack(), &mut value))?; + oakundo::global::can_redo(&mut value).map_err(map_stack_err)?; Ok(value) }) } diff --git a/crates/oakstorage/Cargo.toml b/crates/oakstorage/Cargo.toml index 1156b4cba..0e1c96d7d 100644 --- a/crates/oakstorage/Cargo.toml +++ b/crates/oakstorage/Cargo.toml @@ -31,6 +31,11 @@ oakotio = { path = "../oakotio" } # backends/database.rs). sqlx-postgres + sqlx-sqlite cover PG/SQLite. sea-orm = { version = "2", features = ["sqlx-postgres", "sqlx-sqlite", "runtime-tokio"] } tokio = { version = "1", features = ["rt", "macros"] } +# The write-through session manager (src/writethrough.rs) subscribes to +# the oakundo process-wide undo stack's command-success observers, so a +# recorded command immediately journals every bound project (M14 R1: the +# former facade-side binding/snapshot/flush logic, now module-owned). +oakundo = { path = "../oakundo" } [dev-dependencies] # oakcodec is also reachable through oaknode; the dev-dependency re-entry diff --git a/crates/oakstorage/src/lib.rs b/crates/oakstorage/src/lib.rs index f4d147442..c9d80aec7 100644 --- a/crates/oakstorage/src/lib.rs +++ b/crates/oakstorage/src/lib.rs @@ -31,3 +31,4 @@ pub mod nodeutil; pub mod registry; pub mod session; pub mod uri; +pub mod writethrough; diff --git a/crates/oakstorage/src/writethrough.rs b/crates/oakstorage/src/writethrough.rs new file mode 100644 index 000000000..f12c438c7 --- /dev/null +++ b/crates/oakstorage/src/writethrough.rs @@ -0,0 +1,563 @@ +// 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 project library (plan M13 §2/§3; M14 R1: +//! sunk from the engine facade's `storage.rs`). +//! +//! Every opened project is bound to a database session — a +//! `(library uri, project uuid)` pair — and re-saved after every +//! successful undo-path operation. The module subscribes to the oakundo +//! process-wide stack's **command-success observers** +//! ([`oakundo::global::add_observer`]), so each command's diff lands in +//! the journal transactionally (the oakstorage database backend does the +//! diff itself) with no facade round-trip. +//! +//! ## 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 the +//! facade's `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), +//! `"database"` or `"pg"` 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). +//! - `Storage/PgUrl` — the PostgreSQL connection string (plan D3), used +//! when `Storage/Backend` is `"pg"`: `user:pass@host:5432/dbname` +//! (libpq URL form; an optional `postgres://`/`postgresql://` scheme is +//! accepted and stripped). The resolved library URI is +//! `oakdb+pg://`. When `Backend = "pg"` but `PgUrl` is absent +//! or empty, no library is configured (same graceful degradation). +//! +//! ## 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 +//! by the facade as `oakengine_storage_flush`) is the 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` ([`last_error`]) and the project keeps working (graceful +//! degradation, plan §5). + +use std::collections::HashMap; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::{Condvar, Mutex, OnceLock}; +use std::time::Duration; + +use oaknode::project::Project; +use oakundo::global; + +use crate::backend::StorageBackend; +use crate::backends::database::DatabaseBackend; +use crate::handle::CHandle; +use crate::uri::StorageUri; + +/// The boxed project payload behind an oaknode project handle (the +/// engine's `crate::handle::domain::ProjectArc` equivalent). +type ProjectArc = std::sync::Arc>; + +/// One bound project: its session (library uri + row uuid) plus the +/// 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 write-throughs, the +/// snapshot thread and the library-manager exports of the facade; the +/// backend serializes its own operations). +pub 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(); + +// --------------------------------------------------------------------------- +// Command-success subscription +// --------------------------------------------------------------------------- + +/// Subscribe this module's [`note_command`] to the oakundo process-wide +/// stack's command-success observers (idempotent; the callback is a +/// `fn()`, so a single registration covers every future command). +fn ensure_command_observer() { + static ONCE: OnceLock<()> = OnceLock::new(); + ONCE.get_or_init(|| { + global::add_observer(note_command); + }); +} + +// --------------------------------------------------------------------------- +// 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 { oaknode::handle::get::(&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 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_command_observer(); + 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). Registered as the oakundo global-stack +/// command-success observer ([`ensure_command_observer`]); 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"`, `"database"` or `"pg"` 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 fn storage_enabled() -> bool { + let store = oakcommon::configstore::ConfigStore::instance(); + match store.get(Some("Storage"), "Backend") { + Ok(b) => b == "sqlite" || b == "database" || b == "pg", + 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 configured PostgreSQL connection string (`Storage/PgUrl`; empty +/// value = not configured). +fn configured_pg_url() -> Option { + let store = oakcommon::configstore::ConfigStore::instance(); + match store.get(Some("Storage"), "PgUrl") { + Ok(u) if !u.trim().is_empty() => Some(u.trim().to_string()), + _ => None, + } +} + +/// The `oakdb+pg://…` uri of the configured PostgreSQL library (None +/// when `Storage/PgUrl` is absent). A `postgres://`/`postgresql://` +/// scheme on the config value is stripped — the oakdb uri body is the +/// bare connection string (`user:pass@host:5432/dbname`). +fn pg_library_uri() -> Option { + let url = configured_pg_url()?; + let body = url + .strip_prefix("postgres://") + .or_else(|| url.strip_prefix("postgresql://")) + .unwrap_or(&url); + Some(format!("oakdb+pg://{body}")) +} + +/// 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 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+…` uri of the configured library (None when the path +/// cannot be made absolute, or the PG url is missing). Shared with the +/// facade's library-manager exports. +pub fn library_uri() -> Option { + let store = oakcommon::configstore::ConfigStore::instance(); + if store.get(Some("Storage"), "Backend").ok().as_deref() == Some("pg") { + return pg_library_uri(); + } + 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 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}")), + } +} diff --git a/crates/oakundo/src/error.rs b/crates/oakundo/src/error.rs index 2896ae773..59eb92c80 100644 --- a/crates/oakundo/src/error.rs +++ b/crates/oakundo/src/error.rs @@ -66,6 +66,20 @@ impl Error { Error::NoMem => OAKUNDO_E_NOMEM, } } + + /// Recover the variant for a raw module return code (the numeric value + /// round-trips through [`Error::code`]). Used by the handle-level + /// composers whose sub-calls report plain `c_int` codes. + pub fn from_code(code: i32) -> Error { + match code { + OAKUNDO_E_INVALID => Error::Invalid, + OAKUNDO_E_STATE => Error::State, + OAKUNDO_E_FAILED => Error::Failed("operation failed".into()), + OAKUNDO_E_NOT_FOUND => Error::NotFound, + OAKUNDO_E_NOMEM => Error::NoMem, + _ => Error::Failed(format!("module error code {code}")), + } + } } #[cfg(test)] diff --git a/crates/oakundo/src/global.rs b/crates/oakundo/src/global.rs new file mode 100644 index 000000000..38ed9231f --- /dev/null +++ b/crates/oakundo/src/global.rs @@ -0,0 +1,425 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The process-wide undo stack, undo groups and the command-success +//! observer hook (M14 R1: sunk from the engine facade's `undo.rs`). +//! +//! The facade used to own the process-wide stack (the module-00 analogue +//! of `EngineCore::undo_stack()`), the open undo group and the +//! write-through notification as facade state; all of it is process state, +//! so this module holds it and the facade forwards. The observer registry +//! lets downstream modules (the oakstorage write-through session manager) +//! subscribe to "a command was recorded" notifications without a facade +//! round-trip. + +use std::ffi::{c_char, c_int, c_void}; +use std::sync::{Mutex, OnceLock}; + +use crate::error::{Error, Result}; +use crate::handle::CHandle; +use crate::undocommand::{ + command_free, command_init_multi, command_multi_add_child, command_multi_child, + command_multi_child_count, command_redo_now, command_undo_now, +}; +use crate::undostack::{ + undostack_can_redo, undostack_can_undo, undostack_clear, undostack_command_is_done, + undostack_command_text, undostack_count, undostack_index, undostack_init, undostack_jump, + undostack_push, undostack_push_pre_executed, +}; + +/// The process-wide undo stack handle (`OakUndoStack`), created lazily +/// on first use and kept for the process lifetime. +fn global_stack() -> &'static CHandle { + static STACK: OnceLock = OnceLock::new(); + STACK.get_or_init(|| undostack_init()) +} + +/// Stable opaque token for the engine's `oakengine_undo_handle` export: +/// the stack handle's `ctx` pointer (never dereferenced by callers; lives +/// for the process). +pub fn stack_token() -> *mut c_void { + global_stack().ctx +} + +/// Borrowed copy of the process-wide stack handle (for the module-level +/// queries below). +fn stack() -> CHandle { + *global_stack() +} + +/// Read a NUL-terminated C string; `NULL` yields an empty string. +fn read_name(name: *const c_char) -> String { + if name.is_null() { + String::new() + } else { + // SAFETY: the caller guarantees a valid NUL-terminated string. + unsafe { std::ffi::CStr::from_ptr(name) } + .to_string_lossy() + .into_owned() + } +} + +// --------------------------------------------------------------------------- +// Command-success observers +// --------------------------------------------------------------------------- + +/// A callback invoked after a command is successfully recorded on the +/// process-wide stack (a stack push, a group end or a jump). +pub type CommandObserver = fn(); + +static OBSERVERS: OnceLock>> = OnceLock::new(); + +fn observers() -> &'static Mutex> { + OBSERVERS.get_or_init(|| Mutex::new(Vec::new())) +} + +/// Register a command-success observer. The callback runs after the stack +/// mutation is complete, outside the stack/group locks; multiple observers +/// are supported and run in registration order. There is no un-registration +/// API — observers are process-lifetime, mirroring the global stack itself. +pub fn add_observer(f: CommandObserver) { + observers().lock().unwrap_or_else(|e| e.into_inner()).push(f); +} + +/// Invoke every registered observer (called on the stack-path push, the +/// group end and the jump success). +fn notify_observers() { + let callbacks: Vec = observers() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + for cb in callbacks { + cb(); + } +} + +// --------------------------------------------------------------------------- +// Undo group +// --------------------------------------------------------------------------- + +/// The currently open undo group (a multi command handle) plus its name. +struct OpenGroup { + /// Multi command handle; owned by this state until end/abort. + multi: CHandle, + /// Group label. + #[allow(dead_code)] + name: String, +} + +static GROUP: Mutex> = Mutex::new(None); + +fn group_lock() -> std::sync::MutexGuard<'static, Option> { + GROUP.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Start collecting commands into a group. [`Error::State`] when a group +/// is already open. +pub fn group_begin(name: *const c_char) -> Result<()> { + let mut g = group_lock(); + if g.is_some() { + return Err(Error::State); + } + let multi = command_init_multi(); + if multi.is_null() { + return Err(Error::NoMem); + } + *g = Some(OpenGroup { + multi, + name: read_name(name), + }); + Ok(()) +} + +/// Close the group and push it as one entry. An empty group is discarded +/// (no undo entry). On success the command observers fire. [`Error::State`] +/// when no group is open. +pub fn group_end() -> Result<()> { + let mut g = group_lock(); + let open = g.take().ok_or(Error::State)?; + let multi = open.multi; + let name = open.name; + drop(g); + // Same NULL-for-empty convention as [`push_or_run`]: the module's + // `read_name` treats NULL like an empty label, while an empty String's + // dangling `as_ptr()` (0x1) would be strlen'd -> SIGSEGV. + let name_ptr = if name.is_empty() { + std::ptr::null() + } else { + name.as_ptr() as *const c_char + }; + // push_pre_executed discards an empty multi command. Either way the + // stack took (or destroyed) the command; release our own reference to + // the multi handle. + let rc = undostack_push_pre_executed(stack(), multi, name_ptr); + let mut multi_handle = multi; + 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 (commit at group_end). + notify_observers(); + Ok(()) + } else { + Err(Error::from_code(rc)) + } +} + +/// Undo all executed children and discard the group. [`Error::State`] when +/// no group is open. No observers fire (nothing was recorded). +pub fn group_abort() -> Result<()> { + let mut g = group_lock(); + let open = g.take().ok_or(Error::State)?; + drop(g); + // The multi command itself is never marked done (each child was + // redo'd eagerly at push time), so `undo_now` on it is a no-op. Undo + // the executed children individually instead, in reverse insertion + // order (mirroring the multi's reverse-order undo), each through its + // own borrowed handle. + let mut count: c_int = 0; + let rc = command_multi_child_count(open.multi, &mut count); + if rc != 0 { + let mut multi = open.multi; + command_free(&mut multi); + return Err(Error::from_code(rc)); + } + for i in (0..count).rev() { + let mut child = CHandle::null(); + let rc = command_multi_child(open.multi, i, &mut child); + if rc != 0 { + let mut multi = open.multi; + command_free(&mut multi); + return Err(Error::from_code(rc)); + } + let rc = command_undo_now(child); + // The child handle is borrowed (owns:false): release only its shell + // — the child value lives on in the multi until the multi itself is + // freed below. + command_free(&mut child); + if rc != 0 { + let mut multi = open.multi; + command_free(&mut multi); + return Err(Error::from_code(rc)); + } + } + let mut multi = open.multi; + command_free(&mut multi); + Ok(()) +} + +/// Push `command` onto the stack and execute its redo (or add it to the +/// open group). `command` is consumed: the stack/multi takes the command +/// value, leaving the caller's handle as a non-owning shell that still +/// needs its own release. Returns 0 on success, a module error code +/// otherwise. Command observers fire only when the command was recorded on +/// the STACK — a group child joins the group, and the group itself +/// notifies at [`group_end`]. +pub fn push_or_run(command: CHandle, name: &str) -> c_int { + let g = group_lock(); + if let Some(group) = g.as_ref() { + // The module's `command_multi_add_child` consumes the child's + // command value (command_take), so the eager redo must happen on the + // still-owned handle FIRST — the group takes the already-done + // command (C++ semantics: add_child + redo_now, net effect identical + // for the group's reverse-order undo). + let rc = command_redo_now(command); + if rc != 0 { + return rc; + } + let rc = command_multi_add_child(group.multi, command); + drop(g); + return rc; + } + let stack = stack(); + // The module treats a NULL name like an empty label, but an empty Rust + // String's `as_ptr()` is a DANGLING non-NULL pointer (0x1): the module's + // `read_name` would strlen it and SIGSEGV. Pass a real NULL instead. + let label_ptr = if name.is_empty() { + std::ptr::null() + } else { + name.as_ptr() as *const c_char + }; + let rc = undostack_push(stack, command, label_ptr); + if rc == 0 { + // The stack took a reference; the command's redo already ran (plan + // M13 D2): persist the write-through subscribers. + notify_observers(); + } + rc +} + +// --------------------------------------------------------------------------- +// Stack queries and mutations +// --------------------------------------------------------------------------- + +/// Total number of history rows. +pub fn count() -> Result { + let mut c: i64 = 0; + let rc = undostack_count(stack(), &mut c); + if rc == 0 { + Ok(c) + } else { + Err(Error::from_code(rc)) + } +} + +/// Current position in the history (done-command count). +pub fn index() -> Result { + let mut i: i64 = 0; + let rc = undostack_index(stack(), &mut i); + if rc == 0 { + Ok(i) + } else { + Err(Error::from_code(rc)) + } +} + +/// Whether an undo is possible (1/0 via `out_value`; a module error code +/// otherwise). +pub fn can_undo(out_value: *mut c_int) -> Result<()> { + let rc = undostack_can_undo(stack(), out_value); + if rc == 0 { + Ok(()) + } else { + Err(Error::from_code(rc)) + } +} + +/// Whether a redo is possible (1/0 via `out_value`; a module error code +/// otherwise). +pub fn can_redo(out_value: *mut c_int) -> Result<()> { + let rc = undostack_can_redo(stack(), out_value); + if rc == 0 { + Ok(()) + } else { + Err(Error::from_code(rc)) + } +} + +/// Undo/redo until the done-command count equals `index`. On success the +/// bound projects are written through (the jump executed the undo/redo +/// callbacks that mutated them) via the command observers. +pub fn jump(index: i64) -> Result<()> { + let rc = undostack_jump(stack(), index); + if rc == 0 { + notify_observers(); + Ok(()) + } else { + Err(Error::from_code(rc)) + } +} + +/// Delete all commands and push the fresh "New/Open Project" empty command. +pub fn clear() -> Result<()> { + let rc = undostack_clear(stack()); + if rc == 0 { + Ok(()) + } else { + Err(Error::from_code(rc)) + } +} + +/// Two-stage label getter for the row at `row` (see +/// [`crate::undostack::undostack_command_text`]): returns the required +/// size including the NUL, or a module error code. +pub fn command_text(row: i64, buf: *mut c_char, buf_size: c_int) -> c_int { + undostack_command_text(stack(), row, buf, buf_size) +} + +/// Whether the row at `row` is done (1/0 via `out_value`; a module error +/// code otherwise — `-20004` for an out-of-range row). +pub fn command_is_done(row: i64, out_value: *mut c_int) -> Result<()> { + let rc = undostack_command_is_done(stack(), row, out_value); + if rc == 0 { + Ok(()) + } else { + Err(Error::from_code(rc)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A no-op observer that counts its invocations. + static COUNT: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); + fn counter() { + COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + + /// The stack/group/observer state is process-wide: every test here runs + /// serially under this lock, mirroring the facade's GLOBAL_STACK_LOCK + /// pattern. + static LOCK: Mutex<()> = Mutex::new(()); + + fn vtable_command() -> CHandle { + use crate::undocommand::{command_init, OakUndoCommandVtable}; + command_init( + &OakUndoCommandVtable { + redo: None, + undo: None, + free_fn: None, + }, + std::ptr::null_mut(), + ) + } + + #[test] + fn stack_and_group_lifecycle() { + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + COUNT.store(0, std::sync::atomic::Ordering::SeqCst); + add_observer(counter); + + assert!(clear().is_ok()); + assert_eq!(count().unwrap(), 1); + assert_eq!(index().unwrap(), 1); + let mut v: c_int = 1; + assert!(can_undo(&mut v).is_ok()); + assert_eq!(v, 0); + + // Push fires the observer once. + let cmd = vtable_command(); + assert_eq!(push_or_run(cmd, "alpha"), 0); + assert_eq!(count().unwrap(), 2); + assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 1); + + // Group begin/end fires the observer once at end. + assert!(group_begin(c"grouped".as_ptr()).is_ok()); + assert!(group_begin(c"again".as_ptr()).is_err()); // State + let c1 = vtable_command(); + let c2 = vtable_command(); + assert_eq!(push_or_run(c1, "c1"), 0); + assert_eq!(push_or_run(c2, "c2"), 0); + // Children joined the group: no observer fire yet. + assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(count().unwrap(), 2); + assert!(group_end().is_ok()); + assert_eq!(count().unwrap(), 3); + assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 2); + + // Abort fires nothing. + assert!(group_begin(c"abort".as_ptr()).is_ok()); + let c3 = vtable_command(); + assert_eq!(push_or_run(c3, "c3"), 0); + assert!(group_abort().is_ok()); + assert_eq!(count().unwrap(), 3); + assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 2); + + // End/abort with no group open: State. + assert!(group_end().is_err()); + assert!(group_abort().is_err()); + + assert!(clear().is_ok()); + } +} diff --git a/crates/oakundo/src/lib.rs b/crates/oakundo/src/lib.rs index 9d5837bf0..83bc7a5e3 100644 --- a/crates/oakundo/src/lib.rs +++ b/crates/oakundo/src/lib.rs @@ -31,6 +31,7 @@ #![warn(missing_docs)] pub mod error; +pub mod global; pub mod handle; pub mod undocommand; pub mod undostack;