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:
Generated
+1
@@ -4886,6 +4886,7 @@ dependencies = [
|
||||
"oakcore-rs",
|
||||
"oaknode",
|
||||
"oakotio",
|
||||
"oakundo",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+39
-482
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,3 +31,4 @@ pub mod nodeutil;
|
||||
pub mod registry;
|
||||
pub mod session;
|
||||
pub mod uri;
|
||||
pub mod writethrough;
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod error;
|
||||
pub mod global;
|
||||
pub mod handle;
|
||||
pub mod undocommand;
|
||||
pub mod undostack;
|
||||
|
||||
Reference in New Issue
Block a user