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)
This commit is contained in:
2026-08-16 20:37:45 +08:00
parent f59c7c8476
commit 8a2e45225f
9 changed files with 1096 additions and 674 deletions
Generated
+1
View File
@@ -4886,6 +4886,7 @@ dependencies = [
"oakcore-rs",
"oaknode",
"oakotio",
"oakundo",
"sea-orm",
"serde",
"serde_json",
+39 -482
View File
@@ -14,527 +14,84 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! 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) `<system data directory>/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://<PgUrl>`. 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<String>,
}
/// 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<DatabaseBackend> = OnceLock::new();
BACKEND.get_or_init(DatabaseBackend::new)
}
/// project identity (handle `ctx` pointer) -> binding.
fn bindings() -> &'static Mutex<HashMap<usize, Binding>> {
static BINDINGS: OnceLock<Mutex<HashMap<usize, Binding>>> = 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<std::thread::JoinHandle<()>>,
/// Exit request (set by [`flush_all`]).
stop: bool,
}
static SNAPSHOT: Mutex<SnapshotRuntime> = 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<String> {
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<usize> = {
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 = <system data dir>/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<String> {
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<String> {
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<String> {
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: `<system data directory>/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<String> {
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<usize> = {
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();
}
// ---------------------------------------------------------------------------
+47 -192
View File
@@ -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<CHandle> = 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<Option<OpenGroup>> = Mutex::new(None);
fn group_lock() -> std::sync::MutexGuard<'static, Option<OpenGroup>> {
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)
})
}
+5
View File
@@ -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
+1
View File
@@ -31,3 +31,4 @@ pub mod nodeutil;
pub mod registry;
pub mod session;
pub mod uri;
pub mod writethrough;
+563
View File
@@ -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 <http://www.gnu.org/licenses/>.
//! 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) `<system data directory>/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://<PgUrl>`. 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<std::sync::Mutex<Project>>;
/// 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<String>,
}
/// 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<DatabaseBackend> = OnceLock::new();
BACKEND.get_or_init(DatabaseBackend::new)
}
/// project identity (handle `ctx` pointer) -> binding.
fn bindings() -> &'static Mutex<HashMap<usize, Binding>> {
static BINDINGS: OnceLock<Mutex<HashMap<usize, Binding>>> = 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<std::thread::JoinHandle<()>>,
/// Exit request (set by [`flush_all`]).
stop: bool,
}
static SNAPSHOT: Mutex<SnapshotRuntime> = 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::<ProjectArc>(&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<String> {
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<usize> = {
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 = <system data dir>/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<String> {
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<String> {
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<String> {
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: `<system data directory>/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<String> {
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<usize> = {
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}")),
}
}
+14
View File
@@ -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)]
+425
View File
@@ -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 <http://www.gnu.org/licenses/>.
//! 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<CHandle> = 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<Mutex<Vec<CommandObserver>>> = OnceLock::new();
fn observers() -> &'static Mutex<Vec<CommandObserver>> {
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<CommandObserver> = 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<Option<OpenGroup>> = Mutex::new(None);
fn group_lock() -> std::sync::MutexGuard<'static, Option<OpenGroup>> {
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<i64> {
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<i64> {
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());
}
}
+1
View File
@@ -31,6 +31,7 @@
#![warn(missing_docs)]
pub mod error;
pub mod global;
pub mod handle;
pub mod undocommand;
pub mod undostack;