feat(engine): write-through persistence (M13 D2)
- facade storage session manager: project handles bind to the default SQLite library on project_new/load; every undo push/group_end/jump write-throughs via DatabaseBackend::save (diff journal); project_free flushes and unbinds - background snapshot thread (Storage/SnapshotIntervalSec, latest-wins, newest 3 kept) with exit flush (oakengine_storage_flush) - config-gated: storage only activates with an explicit Storage/Backend=sqlite, so headless consumers and tests never touch the real library; new exports: storage_flush/is_bound/last_error - it_storage: kill -9 recovery, cross-session undo, snapshot pruning, multi-project isolation, graceful degradation - also fixes a real config test polluting the user config.ini and the undo-stack test races
This commit is contained in:
Generated
+3
@@ -4814,12 +4814,15 @@ dependencies = [
|
||||
"oaknode",
|
||||
"oakplugin",
|
||||
"oakrender",
|
||||
"oakstorage",
|
||||
"oaktask",
|
||||
"oaktimeline",
|
||||
"oakundo",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -45,6 +45,10 @@ oaktask = { path = "../oaktask" }
|
||||
oaknode = { path = "../oaknode" }
|
||||
oakaudio = { path = "../oakaudio" }
|
||||
oakplugin = { path = "../oakplugin" }
|
||||
# Live write-through to the project library (plan M13 D2): the facade's
|
||||
# storage session manager (src/storage.rs) drives the oakstorage database
|
||||
# backend (DatabaseBackend::save/snapshot) straight from the undo path.
|
||||
oakstorage = { path = "../oakstorage" }
|
||||
|
||||
[dev-dependencies]
|
||||
# The facade is cdylib-only, so the former tests/*.rs integration tests
|
||||
@@ -53,3 +57,9 @@ oakplugin = { path = "../oakplugin" }
|
||||
# [dependencies] above. oakcommon stays here for the direct dev-only
|
||||
# imports in the test support files.
|
||||
oakcommon = { path = "../oakcommon" }
|
||||
# The write-through tests (src/test_support/it_storage.rs) inspect the
|
||||
# journal/snapshot rows of the temp libraries directly; sea-orm + tokio
|
||||
# unify with oakstorage's own versions (same features), so the entities
|
||||
# and the current-thread runtime pattern match the oakstorage tests.
|
||||
sea-orm = { version = "2", features = ["sqlx-postgres", "sqlx-sqlite", "runtime-tokio"] }
|
||||
tokio = { version = "1", features = ["rt", "macros"] }
|
||||
|
||||
@@ -79,6 +79,7 @@ pub mod plugin;
|
||||
pub mod pods;
|
||||
pub mod render;
|
||||
pub mod stubs;
|
||||
pub mod storage;
|
||||
pub mod task;
|
||||
pub mod testmedia;
|
||||
pub mod timeline;
|
||||
|
||||
@@ -423,8 +423,10 @@ pub unsafe extern "C" fn oakengine_project_free(self_: *mut OakEngineProject) {
|
||||
return;
|
||||
}
|
||||
// The module's project_free releases the handle and clears `ctx`;
|
||||
// the box shell is then deallocated (no double release).
|
||||
// the box shell is then deallocated (no double release). Flush the
|
||||
// write-through binding first (save + snapshot of pending writes).
|
||||
let mut h = (*self_).handle;
|
||||
crate::storage::unbind_project(h);
|
||||
n::oaknode_project_free(&mut h);
|
||||
drop(Box::from_raw(self_));
|
||||
})
|
||||
@@ -442,6 +444,9 @@ pub unsafe extern "C" fn oakengine_project_new(self_: *mut OakEngineProject) ->
|
||||
// Clearing the global undo stack mirrors the app's new-project
|
||||
// behavior (see undo.rs `oakengine_undo_clear`).
|
||||
crate::undo::oakengine_undo_clear();
|
||||
// Bind the fresh project to the default library (plan M13 D2): its
|
||||
// first undoable edit lands in the journal as the import command.
|
||||
crate::storage::bind_project(h);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -502,6 +507,10 @@ pub unsafe extern "C" fn oakengine_project_load(
|
||||
// Success: clear the undo stack and the modified flag.
|
||||
crate::undo::oakengine_undo_clear();
|
||||
Error::from_module(n::oaknode_project_set_modified(h, 0))?;
|
||||
// Bind the opened project to the default library (plan M13 D2): the
|
||||
// row is keyed by the loaded uuid — an existing library row
|
||||
// continues its journal, otherwise the first edit imports it.
|
||||
crate::storage::bind_project(h);
|
||||
if !err.is_null() && err_size > 0 {
|
||||
*err = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Live write-through to the oakstorage project library (plan M13 §2/§3).
|
||||
//!
|
||||
//! The facade binds every opened project to a database session — a
|
||||
//! `(library uri, project uuid)` pair — and re-saves it after every
|
||||
//! successful undo-path operation ([`note_command`], hooked from
|
||||
//! [`crate::undo`]), so each command's diff lands in the journal
|
||||
//! transactionally (the oakstorage database backend does the diff itself).
|
||||
//!
|
||||
//! ## Binding model
|
||||
//!
|
||||
//! The map is keyed by the project handle's `ctx` pointer (the module
|
||||
//! `RefBox` identity — one per in-memory project instance), so several
|
||||
//! projects can be bound at once (multi-project, plan §3) without
|
||||
//! confusing their library rows. Every undo-path operation re-saves ALL
|
||||
//! bound projects ([`note_command`]): the oakstorage backend diffs each
|
||||
//! project against its own library head, so untouched projects are
|
||||
//! no-op touches and only the project the command actually changed
|
||||
//! advances its journal. (The plan's "current project" phrasing maps to
|
||||
//! this — the undo stack is cleared on every project switch, so at most
|
||||
//! one project's graph changes per command; a bound-but-untouched
|
||||
//! project can gain its import row this way, which reflects its true
|
||||
//! state.) Closing a project ([`unbind_project`], hooked from
|
||||
//! `project_free`) flushes its pending writes and drops the binding.
|
||||
//!
|
||||
//! The library is selected from the `Storage` config group (all defaults
|
||||
//! are config-driven, plan §5):
|
||||
//!
|
||||
//! - `Storage/Backend` — `"sqlite"` (the documented default value) or
|
||||
//! `"database"` enable the write-through; any other value (e.g.
|
||||
//! `"off"`) disables it. When the key is absent, no library is
|
||||
//! configured: projects stay unbound and the undo path runs without
|
||||
//! touching a database (graceful degradation — this is what keeps
|
||||
//! headless consumers and the test suite from writing to the user's
|
||||
//! default library).
|
||||
//! - `Storage/SqlitePath` — the SQLite library file; default (used when
|
||||
//! storage is enabled) `<system data directory>/library.db` (the same
|
||||
//! location `FileFunctions::get_configuration_location` derives,
|
||||
//! honoring `OAK_CONFIG_DIR` and portable mode).
|
||||
//!
|
||||
//! ## Snapshot thread and exit flush
|
||||
//!
|
||||
//! A background thread re-snapshots every *dirty* binding every
|
||||
//! `Storage/SnapshotIntervalSec` seconds (default 600; ≤ 0 acts every
|
||||
//! wake). Snapshots are latest-wins: the backend writes the full payload
|
||||
//! at the current head seq and prunes to the newest three. The thread is
|
||||
//! notified on bind and stops on the exit flush. [`flush_all`] (exported
|
||||
//! as `oakengine_storage_flush`) is the facade's exit path: it stops the
|
||||
//! thread, then writes through and snapshots every still-bound project
|
||||
//! (save + snapshot drain). A write-through failure never propagates to
|
||||
//! the caller — it is recorded in the binding's `last_error`
|
||||
//! ([`oakengine_storage_last_error`]) and the project keeps working
|
||||
//! (graceful degradation, plan §5).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{c_char, c_int};
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
use std::sync::{Condvar, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use oakstorage::backend::StorageBackend;
|
||||
use oakstorage::backends::database::DatabaseBackend;
|
||||
use oakstorage::uri::StorageUri;
|
||||
|
||||
use crate::handle::{guard_int, CHandle, OakEngineProject};
|
||||
|
||||
/// One bound project: its session (library uri + row uuid) plus the
|
||||
/// facade-side write state.
|
||||
struct Binding {
|
||||
/// The project handle (addref'd at bind, released at unbind; keeps
|
||||
/// the project alive past the caller's own handle).
|
||||
project: CHandle,
|
||||
/// Library uri (`oakdb+sqlite:///…`).
|
||||
uri: String,
|
||||
/// Library row uuid.
|
||||
uuid: String,
|
||||
/// Whether a write-through happened since the last successful
|
||||
/// snapshot (the snapshot thread and the flush drain on this).
|
||||
dirty: bool,
|
||||
/// Monotonic counter bumped on every write-through; a snapshot only
|
||||
/// clears `dirty` when the generation still matches, so a write that
|
||||
/// lands while the snapshot runs is not lost.
|
||||
write_gen: u64,
|
||||
/// Last write-through / snapshot error (graceful degradation).
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// The process-wide oakstorage backend (shared by the UI thread's
|
||||
/// write-throughs and the snapshot thread; the backend serializes its own
|
||||
/// operations).
|
||||
fn backend() -> &'static DatabaseBackend {
|
||||
static BACKEND: OnceLock<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.
|
||||
pub fn bind_project(project: CHandle) {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
if project.is_null() {
|
||||
return;
|
||||
}
|
||||
let key = project.ctx as usize;
|
||||
{
|
||||
let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if g.contains_key(&key) {
|
||||
return; // Already bound (re-bind): nothing to refresh.
|
||||
}
|
||||
}
|
||||
if !storage_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(uri) = library_uri() else {
|
||||
return;
|
||||
};
|
||||
let uuid = {
|
||||
let arc = unsafe { crate::handle::domain::project_of(&project) };
|
||||
match arc {
|
||||
Some(a) => a.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone(),
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
if uuid.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Addref the handle so the binding owns a reference independent of
|
||||
// the caller's.
|
||||
let mut owned = project;
|
||||
if let Some(addref) = owned.addref {
|
||||
unsafe { addref(owned.ctx) };
|
||||
}
|
||||
bindings()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(
|
||||
key,
|
||||
Binding {
|
||||
project: owned,
|
||||
uri,
|
||||
uuid,
|
||||
dirty: false,
|
||||
write_gen: 0,
|
||||
last_error: None,
|
||||
},
|
||||
);
|
||||
ensure_thread();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Flush `project`'s pending writes and drop its binding (closing the
|
||||
/// project). No-op when not bound.
|
||||
pub fn unbind_project(project: CHandle) {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
let key = project.ctx as usize;
|
||||
flush_one(key);
|
||||
let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(b) = g.remove(&key) {
|
||||
if let Some(release) = b.project.release {
|
||||
unsafe { release(b.project.ctx) };
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Whether `project` currently has a binding (status-bar / D5 surface).
|
||||
pub fn is_bound(project: CHandle) -> bool {
|
||||
if project.is_null() {
|
||||
return false;
|
||||
}
|
||||
bindings()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.contains_key(&(project.ctx as usize))
|
||||
}
|
||||
|
||||
/// The last write-through / snapshot error of `project` (empty when none
|
||||
/// or not bound).
|
||||
pub fn last_error(project: CHandle) -> Option<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). Hooked from [`crate::undo`]; the oakstorage
|
||||
/// backend diffs each project against its own head, so unchanged projects
|
||||
/// are no-op touches. No-op when nothing is bound.
|
||||
pub fn note_command() {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
let keys: Vec<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"` or `"database"` enables it; any
|
||||
/// other explicit value (e.g. `"off"`) disables it. When NO `Storage`
|
||||
/// configuration is present the backend is NOT enabled — "no library
|
||||
/// configured" degrades gracefully to plain unbound projects, which keeps
|
||||
/// headless consumers (the CLI) and the test suite from ever writing to
|
||||
/// the user's default library. The documented default *values* are
|
||||
/// `Backend = "sqlite"` and `SqlitePath = <system data dir>/library.db`
|
||||
/// (used once storage is enabled without an explicit path).
|
||||
pub(crate) fn storage_enabled() -> bool {
|
||||
let store = oakcommon::configstore::ConfigStore::instance();
|
||||
match store.get(Some("Storage"), "Backend") {
|
||||
Ok(b) => b == "sqlite" || b == "database",
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured SQLite library path (empty value = the default).
|
||||
fn configured_sqlite_path() -> Option<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 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+sqlite://…` uri of the configured library (None when the
|
||||
/// path cannot be made absolute).
|
||||
fn library_uri() -> Option<String> {
|
||||
let path = match configured_sqlite_path() {
|
||||
Some(p) => p,
|
||||
None => default_library_path(),
|
||||
};
|
||||
let abs = std::path::absolute(&path).ok()?;
|
||||
Some(format!("oakdb+sqlite://{}", abs.display()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot thread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawn the snapshot thread (a no-op when one is already running). The
|
||||
/// thread is (re)startable after an exit flush.
|
||||
fn ensure_thread() {
|
||||
let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let alive = rt
|
||||
.handle
|
||||
.as_ref()
|
||||
.map(|h| !h.is_finished())
|
||||
.unwrap_or(false);
|
||||
if !alive {
|
||||
rt.stop = false;
|
||||
rt.handle = Some(
|
||||
std::thread::Builder::new()
|
||||
.name("oak-storage-snapshot".into())
|
||||
.spawn(snapshot_loop)
|
||||
.expect("snapshot thread spawn"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The thread loop: sleep a tick, then snapshot every dirty binding.
|
||||
/// The condvar makes the sleep interruptible (bind notifications and the
|
||||
/// exit flush's stop signal wake it immediately).
|
||||
fn snapshot_loop() {
|
||||
loop {
|
||||
let interval = snapshot_tick();
|
||||
let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (guard, _) = SNAPSHOT_CV
|
||||
.wait_timeout(rt, interval)
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
rt = guard;
|
||||
if rt.stop {
|
||||
break;
|
||||
}
|
||||
drop(rt);
|
||||
snapshot_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
/// The sleep between snapshot passes: `Storage/SnapshotIntervalSec`
|
||||
/// seconds (default 600), with ≤ 0 treated as "every wake" and a 100 ms
|
||||
/// floor so the thread stays responsive to bind/stop signals.
|
||||
fn snapshot_tick() -> Duration {
|
||||
let secs = oakcommon::configstore::ConfigStore::instance()
|
||||
.get_int(Some("Storage"), "SnapshotIntervalSec", 600);
|
||||
if secs <= 0 {
|
||||
Duration::from_millis(100)
|
||||
} else {
|
||||
Duration::from_secs(secs as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot every dirty binding at its head seq (latest-wins: a project
|
||||
/// edited since the last pass snapshots at the newer head; an unchanged
|
||||
/// head is a backend no-op).
|
||||
fn snapshot_dirty() {
|
||||
let jobs: Vec<(usize, String, String, u64)> = {
|
||||
let g = bindings().lock().unwrap_or_else(|e| e.into_inner());
|
||||
g.iter()
|
||||
.filter(|(_, b)| b.dirty)
|
||||
.map(|(k, b)| (*k, b.uri.clone(), b.uuid.clone(), b.write_gen))
|
||||
.collect()
|
||||
};
|
||||
for (key, uri, uuid, gen) in jobs {
|
||||
let res = match StorageUri::parse(&uri) {
|
||||
Ok(u) => backend().snapshot(&u, &uuid),
|
||||
Err(_) => {
|
||||
record_error(key, "invalid storage uri");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match res {
|
||||
Ok(()) => {
|
||||
let mut g = bindings().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(b) = g.get_mut(&key) {
|
||||
// Only clear when no write landed since the snapshot
|
||||
// started (the generation counter detects it).
|
||||
if b.write_gen == gen {
|
||||
b.dirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => record_error(key, &format!("{e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exit flush
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The facade exit path: stop the snapshot thread, then save + snapshot
|
||||
/// every still-bound project (plan §2 "退出前 flush"). Idempotent; safe
|
||||
/// to call again after new projects are bound (the thread restarts on the
|
||||
/// next bind).
|
||||
pub fn flush_all() {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
// 1. Stop the thread and wait for it to finish its pass.
|
||||
{
|
||||
let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
rt.stop = true;
|
||||
}
|
||||
SNAPSHOT_CV.notify_all();
|
||||
{
|
||||
let mut rt = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(h) = rt.handle.take() {
|
||||
drop(rt);
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
// 2. Drain every binding (write-through + snapshot).
|
||||
let keys: Vec<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}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Facade exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `oakengine_storage_flush` — flush every bound project (write-through +
|
||||
/// snapshot) and stop the snapshot thread. The app calls this on exit
|
||||
/// (the facade's shutdown path; the write-through is already per-command,
|
||||
/// so this only drains the periodic snapshot backlog).
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakengine_storage_flush() -> c_int {
|
||||
flush_all();
|
||||
crate::error::OAKENGINE_OK
|
||||
}
|
||||
|
||||
/// `oakengine_storage_is_bound` — 1 when `project` is bound to a library
|
||||
/// session, 0 otherwise (NULL project -> 0).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_storage_is_bound(
|
||||
project: *mut OakEngineProject,
|
||||
) -> c_int {
|
||||
guard_int(|| unsafe {
|
||||
let h = crate::handle::unbox(project)?;
|
||||
Ok(is_bound(h) as c_int)
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_storage_last_error` — the last write-through / snapshot
|
||||
/// error of `project` (buf/size convention; empty when none or not
|
||||
/// bound).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_storage_last_error(
|
||||
project: *mut OakEngineProject,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int {
|
||||
guard_int(|| unsafe {
|
||||
let h = crate::handle::unbox(project)?;
|
||||
let msg = last_error(h).unwrap_or_default();
|
||||
Ok(crate::handle::write_string(&msg, buf, buf_size))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn library_uri_resolves_configured_path() {
|
||||
use oakcommon::configstore::ConfigStore;
|
||||
let store = ConfigStore::instance();
|
||||
let _g = crate::tests::common::STORAGE_CONFIG_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// A configured path wins and yields an absolute oakdb+sqlite uri.
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("oakengine_storage_uri_{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let lib = dir.join("lib.db");
|
||||
store.set(Some("Storage"), "Backend", "sqlite");
|
||||
store.set(Some("Storage"), "SqlitePath", &lib.to_string_lossy());
|
||||
let uri = library_uri().expect("configured path resolves");
|
||||
assert!(uri.starts_with("oakdb+sqlite://"), "{uri}");
|
||||
assert!(uri.ends_with("lib.db"), "{uri}");
|
||||
assert!(storage_enabled());
|
||||
|
||||
// Leave the store in a safe state: backend off (the config store
|
||||
// has no remove API, so later unguarded tests see "off" and never
|
||||
// bind a project to a library).
|
||||
store.set(Some("Storage"), "Backend", "off");
|
||||
assert!(!storage_enabled());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -895,7 +895,9 @@ pub unsafe extern "C" fn oakengine_task_save_get_project(
|
||||
///
|
||||
/// The module's `oaktask_load_take_project` is the load-result getter the
|
||||
/// engine facade has no export for (the app's interchange-open path); it is
|
||||
/// wrapped here so the app can stay on the `oakengine_*` surface.
|
||||
/// wrapped here so the app can stay on the `oakengine_*` surface. A taken
|
||||
/// project is bound to the default library (plan M13 D2 — the "task load
|
||||
/// 完成" hook).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_task_load_take_project(
|
||||
task: *mut OakEngineTask,
|
||||
@@ -906,6 +908,7 @@ pub unsafe extern "C" fn oakengine_task_load_take_project(
|
||||
if project.is_null() {
|
||||
return Ok(std::ptr::null_mut());
|
||||
}
|
||||
crate::storage::bind_project(project);
|
||||
Ok(box_handle::<OakEngineProject>(project))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,6 +76,35 @@ pub fn with_manager(f: impl FnOnce()) {
|
||||
f()
|
||||
}
|
||||
|
||||
/// Serializes every test that reads or writes the `Storage` config group
|
||||
/// (the facade's write-through library selection — see src/storage.rs).
|
||||
/// The config store is process-global, so the write-through tests and the
|
||||
/// tests that disable the backend must take this lock for their whole
|
||||
/// body instead of racing on the shared store.
|
||||
pub static STORAGE_CONFIG_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Run `f` with the write-through storage backend disabled
|
||||
/// (`Storage/Backend = "off"`). Tests that push undo commands on real
|
||||
/// projects (e.g. `oakengine_project_add_node`) would otherwise bind them
|
||||
/// to the default user library and write there; disabling the backend
|
||||
/// keeps them side-effect-free. The value intentionally persists — every
|
||||
/// storage test sets its own backend explicitly under
|
||||
/// [`STORAGE_CONFIG_LOCK`].
|
||||
pub fn with_storage_off<R>(f: impl FnOnce() -> R) -> R {
|
||||
let _g = storage_off_guard();
|
||||
f()
|
||||
}
|
||||
|
||||
/// Take the storage-config lock AND disable the write-through backend,
|
||||
/// returning the guard: a test that pushes undo commands throughout its
|
||||
/// body holds the guard (and thus the lock) for its whole lifetime, so a
|
||||
/// concurrently running storage test cannot flip the backend mid-test.
|
||||
pub fn storage_off_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
let g = STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
oakcommon::configstore::ConfigStore::instance().set(Some("Storage"), "Backend", "off");
|
||||
g
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// oakcore_* stubs (see module docs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -38,8 +38,19 @@ use crate::common::{
|
||||
};
|
||||
|
||||
/// Config: load/save, string and int round-trips, missing-key behavior.
|
||||
///
|
||||
/// Serialized with the write-through tests (the config store is
|
||||
/// process-global) and redirected to a temp `OAK_CONFIG_DIR` — without the
|
||||
/// redirect, `oakengine_config_save` would write the real `config.ini`.
|
||||
#[test]
|
||||
fn config_round_trip() {
|
||||
let _config = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"oakengine_common_smoke_config_{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
std::env::set_var("OAK_CONFIG_DIR", &dir);
|
||||
assert_eq!(unsafe { oakengine_config_load() }, 0);
|
||||
|
||||
// Missing key reads as 0 / empty.
|
||||
@@ -92,6 +103,10 @@ fn config_round_trip() {
|
||||
);
|
||||
|
||||
assert_eq!(unsafe { oakengine_config_save() }, 0);
|
||||
assert!(dir.join("config.ini").exists());
|
||||
|
||||
std::env::remove_var("OAK_CONFIG_DIR");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Config error handler: registered, then invoked via report_error.
|
||||
|
||||
@@ -40,7 +40,6 @@ use super::common;
|
||||
use std::ffi::{c_char, c_int};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::common::{
|
||||
oakengine_config_get_int, oakengine_config_get_string, oakengine_config_load,
|
||||
@@ -71,8 +70,11 @@ unsafe fn read_buf(buf: &mut [c_char]) -> String {
|
||||
/// redirects `OAK_CONFIG_DIR` to a fresh temp dir for the duration of `f`
|
||||
/// (same pattern as the oakcommon crate's own test support). The only
|
||||
/// readers of `OAK_CONFIG_DIR` in this binary are these serialized tests.
|
||||
/// The serialization uses the SHARED storage-config lock (common), so the
|
||||
/// config tests never race the write-through tests on the singleton store
|
||||
/// or the env override.
|
||||
fn with_temp_config_dir<T>(f: impl FnOnce(&Path) -> T) -> T {
|
||||
let _guard = CONFIG_LOCK.lock().unwrap();
|
||||
let _guard = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("oakengine_it_common_config_{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
@@ -84,7 +86,7 @@ fn with_temp_config_dir<T>(f: impl FnOnce(&Path) -> T) -> T {
|
||||
}
|
||||
|
||||
/// The process-wide config store is a singleton; see module doc.
|
||||
static CONFIG_LOCK: Mutex<()> = Mutex::new(());
|
||||
// (Serialization now uses the shared `common::STORAGE_CONFIG_LOCK`.)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// config.h
|
||||
|
||||
@@ -78,9 +78,23 @@ const CODEC_AAC: c_int = 12;
|
||||
/// does not cascade into `PoisonError` failures in every later test.
|
||||
static SERIAL: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Take the [`SERIAL`] lock, recovering from any poisoning.
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
SERIAL.lock().unwrap_or_else(|e| e.into_inner())
|
||||
/// Both lock guards held by [`serial`].
|
||||
struct SerialGuard {
|
||||
/// The [`SERIAL`] lock.
|
||||
_task: std::sync::MutexGuard<'static, ()>,
|
||||
/// The facade-wide undo-stack lock, so the `oakengine_project_new`
|
||||
/// calls in these tests never race the it_undo / it_storage stack tests.
|
||||
_stack: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
/// Take the [`SERIAL`] lock AND the global undo-stack lock, recovering
|
||||
/// from any poisoning.
|
||||
fn serial() -> SerialGuard {
|
||||
let _task = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _stack = super::it_undo::GLOBAL_STACK_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
SerialGuard { _task, _stack }
|
||||
}
|
||||
|
||||
/// A unique temp path (per-process, so parallel test binaries never
|
||||
@@ -105,6 +119,10 @@ unsafe fn assemble_test_sequence(
|
||||
frame_count: i64,
|
||||
fps: c_int,
|
||||
) -> (*mut OakEngineProject, *mut OakEngineSequence) {
|
||||
// The undoable import/add-track/add-clip commands below would bind the
|
||||
// project to the default user library; disable the write-through for
|
||||
// the assembly (and keep the config lock held while pushing).
|
||||
let _storage = common::storage_off_guard();
|
||||
let media_c = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap();
|
||||
assert_eq!(
|
||||
oakengine_testmedia_write_clip(media_c.as_ptr(), width, height, frame_count as c_int, fps),
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! D2 integration tests: the facade's live write-through (plan M13 §2/§3).
|
||||
//!
|
||||
//! End-to-end against real SQLite library files in temp directories,
|
||||
//! driving the facade exactly like the app: `oakengine_project_new` binds
|
||||
//! the project, undoable edits (`oakengine_project_add_node`) write
|
||||
//! through on push, and the journal is verified directly with raw sea-orm
|
||||
//! reads (the same pattern as the oakstorage database tests) plus fresh
|
||||
//! oakstorage sessions for cross-process recovery.
|
||||
//!
|
||||
//! Coverage: write-through lands journal rows without any flush; a
|
||||
//! kill-9-style session (no cleanup, a fresh session reading the same
|
||||
//! file) recovers the last command; undo history persists across sessions
|
||||
//! (`load_at`); the background snapshot thread writes and prunes
|
||||
//! snapshots and the exit flush drains; multiple bound projects keep
|
||||
//! their rows apart; and the graceful-degradation paths (backend off /
|
||||
//! unwritable library) record `last_error` without breaking the undo
|
||||
//! stack.
|
||||
//!
|
||||
//! Every test holds the shared undo-stack lock (the facade's stack is
|
||||
//! process-wide, same as the it_undo family) and the storage-config lock
|
||||
//! (the config store is process-global too), so the suite never races on
|
||||
//! either singleton.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::QueryOrder;
|
||||
use oakstorage::backend::StorageBackend;
|
||||
use oakstorage::backends::database::entities::{journal, project, snapshot};
|
||||
use oakstorage::backends::database::DatabaseBackend;
|
||||
use oakstorage::error::OAKSTORAGE_OK;
|
||||
use oakstorage::handle::CHandle;
|
||||
use oakstorage::nodeutil::project_arc;
|
||||
use oakstorage::uri::StorageUri;
|
||||
|
||||
use super::common;
|
||||
use super::it_undo::GLOBAL_STACK_LOCK;
|
||||
|
||||
use crate::handle::OakEngineProject;
|
||||
|
||||
/// The node type the tests add (a real graph node with an input the
|
||||
/// serializer round-trips).
|
||||
const MATH: &str = "org.olivevideoeditor.Olive.math";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serialize a storage test: hold the process-global undo-stack lock AND
|
||||
/// the storage-config lock for the whole body, then point the write-through
|
||||
/// backend at a temp library.
|
||||
fn with_storage<R>(db: &Path, interval: i32, f: impl FnOnce() -> R) -> R {
|
||||
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _config = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let store = oakcommon::configstore::ConfigStore::instance();
|
||||
store.set(Some("Storage"), "Backend", "sqlite");
|
||||
store.set(Some("Storage"), "SqlitePath", &db.to_string_lossy());
|
||||
store.set_int(Some("Storage"), "SnapshotIntervalSec", interval);
|
||||
f()
|
||||
}
|
||||
|
||||
/// Serialize a storage test with the backend disabled (`Storage/Backend =
|
||||
/// "off"`): projects bind to nothing and the undo stack stays untouched by
|
||||
/// write-throughs.
|
||||
fn with_storage_off<R>(f: impl FnOnce() -> R) -> R {
|
||||
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _config = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
oakcommon::configstore::ConfigStore::instance().set(Some("Storage"), "Backend", "off");
|
||||
f()
|
||||
}
|
||||
|
||||
/// A fresh, unique temp directory for one test.
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("oakengine_storage_{}_{}", std::process::id(), tag));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// `oakdb+sqlite:///…` uri for a database file.
|
||||
fn db_uri(path: &Path) -> String {
|
||||
format!("oakdb+sqlite://{}", path.display())
|
||||
}
|
||||
|
||||
/// `…?project=<uuid>` uri selecting one library row.
|
||||
fn project_uri(db: &str, uuid: &str) -> String {
|
||||
format!("{db}?project={uuid}")
|
||||
}
|
||||
|
||||
/// Release an owned handle (refcount 1).
|
||||
fn release(h: CHandle) {
|
||||
if let Some(release) = h.release {
|
||||
unsafe { release(h.ctx) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a project through the facade and initialize it (bind + active).
|
||||
fn new_project() -> *mut OakEngineProject {
|
||||
let project = unsafe { crate::node::oakengine_project_create() };
|
||||
assert!(!project.is_null());
|
||||
assert_eq!(unsafe { crate::node::oakengine_project_new(project) }, 0);
|
||||
project
|
||||
}
|
||||
|
||||
/// The project's uuid (from its in-memory payload).
|
||||
fn project_uuid(project: *mut OakEngineProject) -> String {
|
||||
let h = unsafe { crate::handle::unbox(project) }.expect("project handle");
|
||||
let arc = unsafe { crate::handle::domain::project_of(&h) }.expect("project payload");
|
||||
let guard = arc.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.uuid.clone()
|
||||
}
|
||||
|
||||
/// Add a math node (an undoable command; pushes and write-throughs).
|
||||
fn add_math_node(project: *mut OakEngineProject) -> *mut crate::handle::OakEngineNode {
|
||||
unsafe {
|
||||
crate::node::oakengine_project_add_node(
|
||||
project,
|
||||
c"org.olivevideoeditor.Olive.math".as_ptr(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the head state through a *fresh* database backend (a new
|
||||
/// connection pool = a new session, as after a process restart).
|
||||
fn load_head(uri: &str) -> std::sync::Arc<std::sync::Mutex<oaknode::project::Project>> {
|
||||
let parsed = StorageUri::parse(uri).unwrap();
|
||||
let result = DatabaseBackend::new().load(&parsed).unwrap();
|
||||
assert_eq!(result.version_info, OAKSTORAGE_OK);
|
||||
let handle = result.project;
|
||||
let loaded = unsafe { project_arc(&handle) }.unwrap();
|
||||
release(handle);
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Load the state at `seq` through a fresh database backend.
|
||||
fn load_at(uri: &str, uuid: &str, seq: i64) -> std::sync::Arc<std::sync::Mutex<oaknode::project::Project>> {
|
||||
let parsed = StorageUri::parse(uri).unwrap();
|
||||
let handle = DatabaseBackend::new()
|
||||
.load_at(&parsed, uuid, seq)
|
||||
.unwrap();
|
||||
let loaded = unsafe { project_arc(&handle) }.unwrap();
|
||||
release(handle);
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Count the math nodes of a loaded project (the root folder is slot 0).
|
||||
fn math_count(p: &oaknode::project::Project) -> usize {
|
||||
p.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
p.graph
|
||||
.get(*id)
|
||||
.map(|e| e.behavior.type_id() == MATH)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Open a raw sea-orm connection to the library file and drive one future
|
||||
/// against it on a private current-thread runtime (inspection behind the
|
||||
/// backend's back — same pattern as the oakstorage database tests).
|
||||
fn inspect_db<R, Fut>(path: &Path, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R
|
||||
where
|
||||
Fut: std::future::Future<Output = R>,
|
||||
{
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let options = sea_orm::sqlx::sqlite::SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.journal_mode(sea_orm::sqlx::sqlite::SqliteJournalMode::Wal)
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.foreign_keys(true);
|
||||
let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.unwrap();
|
||||
f(sea_orm::DatabaseConnection::from(pool)).await
|
||||
})
|
||||
}
|
||||
|
||||
/// The `(command_seq, journal rows)` of a library row.
|
||||
fn journal_rows(
|
||||
path: &Path,
|
||||
uuid: &str,
|
||||
) -> (i64, Vec<oakstorage::backends::database::entities::journal::Model>) {
|
||||
let uuid = uuid.to_string();
|
||||
inspect_db(path, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("library row exists");
|
||||
let rows = journal::Entity::find()
|
||||
.filter(journal::Column::ProjectId.eq(proj.id))
|
||||
.order_by_asc(journal::Column::Seq)
|
||||
.order_by_asc(journal::Column::NodeIdentity)
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap();
|
||||
(proj.command_seq, rows)
|
||||
})
|
||||
}
|
||||
|
||||
/// The snapshot seqs of a library row (newest first).
|
||||
fn snapshot_seqs(path: &Path, uuid: &str) -> Vec<i64> {
|
||||
let uuid = uuid.to_string();
|
||||
inspect_db(path, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("library row exists");
|
||||
snapshot::Entity::find()
|
||||
.filter(snapshot::Column::ProjectId.eq(proj.id))
|
||||
.order_by_desc(snapshot::Column::CommandSeq)
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| s.command_seq)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write-through
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every undoable edit lands in the journal synchronously (no flush): the
|
||||
/// first edit is the import command, later edits are diffs.
|
||||
#[test]
|
||||
fn write_through_persists_commands() {
|
||||
common::force_link();
|
||||
let dir = temp_dir("wt");
|
||||
let db = dir.join("lib.db");
|
||||
with_storage(&db, 600, || {
|
||||
let project = new_project();
|
||||
let node1 = add_math_node(project);
|
||||
assert!(!node1.is_null());
|
||||
let node2 = add_math_node(project);
|
||||
assert!(!node2.is_null());
|
||||
let uuid = project_uuid(project);
|
||||
|
||||
let (head, rows) = journal_rows(&db, &uuid);
|
||||
assert_eq!(head, 2, "two commands written through");
|
||||
// Seq 1 (import): the root folder + the first math node + the
|
||||
// settings pseudo-node, all with only after-images.
|
||||
let seq1: Vec<_> = rows.iter().filter(|r| r.seq == 1).collect();
|
||||
assert_eq!(seq1.len(), 3, "root + first math node + settings row");
|
||||
assert!(seq1.iter().all(|r| r.kind == "import"));
|
||||
assert!(seq1.iter().all(|r| r.old_xml.is_none() && r.new_xml.is_some()));
|
||||
// Seq 2 (redo diff): exactly the second math node.
|
||||
let seq2: Vec<_> = rows.iter().filter(|r| r.seq == 2).collect();
|
||||
assert_eq!(seq2.len(), 1, "one changed node in the diff");
|
||||
assert_eq!(seq2[0].kind, "redo");
|
||||
assert!(seq2[0].new_xml.as_deref().unwrap().contains(MATH));
|
||||
|
||||
// The head state reads back through a fresh session (3 nodes: root
|
||||
// + two math nodes), and the import point has exactly one math node.
|
||||
let uri = db_uri(&db);
|
||||
let head_loaded = load_head(&project_uri(&uri, &uuid));
|
||||
let guard = head_loaded.lock().unwrap();
|
||||
assert_eq!(guard.graph.node_count(), 3);
|
||||
assert_eq!(math_count(&guard), 2);
|
||||
drop(guard);
|
||||
let at1 = load_at(&uri, &uuid, 1);
|
||||
let guard = at1.lock().unwrap();
|
||||
assert_eq!(guard.graph.node_count(), 2);
|
||||
assert_eq!(math_count(&guard), 1);
|
||||
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// kill -9 recovery: the write-through is synchronous, so a fresh session
|
||||
/// reading the same file (no flush, no cleanup) sees the state after the
|
||||
/// LAST command.
|
||||
#[test]
|
||||
fn kill_nine_recovers_last_command() {
|
||||
common::force_link();
|
||||
let dir = temp_dir("k9");
|
||||
let db = dir.join("lib.db");
|
||||
with_storage(&db, 600, || {
|
||||
let project = new_project();
|
||||
for _ in 0..3 {
|
||||
let node = add_math_node(project);
|
||||
assert!(!node.is_null());
|
||||
}
|
||||
let uuid = project_uuid(project);
|
||||
|
||||
// NOTE: the project is intentionally NOT freed — the process "dies"
|
||||
// here. The journal already holds every command.
|
||||
let uri = db_uri(&db);
|
||||
let (head, rows) = journal_rows(&db, &uuid);
|
||||
assert_eq!(head, 3);
|
||||
assert_eq!(rows.len(), 5, "3 import rows + 2 diff rows");
|
||||
|
||||
let loaded = load_head(&project_uri(&uri, &uuid));
|
||||
let guard = loaded.lock().unwrap();
|
||||
assert_eq!(guard.graph.node_count(), 4, "root + three math nodes");
|
||||
assert_eq!(math_count(&guard), 3);
|
||||
|
||||
// Cleanup without flush would leave the file behind; free the
|
||||
// project so the temp dir can be removed.
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Undo across sessions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The journal is the persistent undo history: three commands, one undo,
|
||||
/// then a NEW session's `load_at(2)` reproduces the post-command-2 state
|
||||
/// while `load_at(3)` still yields the pre-undo (post-command-3) state.
|
||||
///
|
||||
/// The undoable operations are label renames: their undo closure captures
|
||||
/// `(project, id)` and reliably reverts, unlike the add-node command
|
||||
/// (whose factory handle is released at push — a facade limitation).
|
||||
#[test]
|
||||
fn undo_history_crosses_sessions() {
|
||||
common::force_link();
|
||||
let dir = temp_dir("ux");
|
||||
let db = dir.join("lib.db");
|
||||
with_storage(&db, 600, || {
|
||||
let project = new_project();
|
||||
// Command 1: add a math node (import).
|
||||
let node = add_math_node(project);
|
||||
assert!(!node.is_null());
|
||||
// Command 2: rename to "Alpha".
|
||||
assert_eq!(
|
||||
unsafe { crate::node::oakengine_node_set_label(node, c"Alpha".as_ptr()) },
|
||||
0
|
||||
);
|
||||
// Command 3: rename to "Beta".
|
||||
assert_eq!(
|
||||
unsafe { crate::node::oakengine_node_set_label(node, c"Beta".as_ptr()) },
|
||||
0
|
||||
);
|
||||
let uuid = project_uuid(project);
|
||||
|
||||
// Undo one command through the facade (jump + write-through).
|
||||
assert_eq!(unsafe { crate::node::oakengine_project_undo(project) }, 0);
|
||||
|
||||
let uri = db_uri(&db);
|
||||
let (head, _) = journal_rows(&db, &uuid);
|
||||
assert_eq!(head, 4, "the undo itself is a written command");
|
||||
|
||||
// A new session at seq 2 = the state right after command 2.
|
||||
let at2 = load_at(&uri, &uuid, 2);
|
||||
assert_eq!(math_label(&at2), "Alpha");
|
||||
// At seq 3 the pre-undo state (after command 3) is intact.
|
||||
let at3 = load_at(&uri, &uuid, 3);
|
||||
assert_eq!(math_label(&at3), "Beta");
|
||||
// The head (default load) matches the undone state.
|
||||
let head_loaded = load_head(&project_uri(&uri, &uuid));
|
||||
assert_eq!(math_label(&head_loaded), "Alpha");
|
||||
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The label of the first math node of a loaded project (its renames are
|
||||
/// the undoable operations of [`undo_history_crosses_sessions`]).
|
||||
fn math_label(arc: &std::sync::Arc<std::sync::Mutex<oaknode::project::Project>>) -> String {
|
||||
let guard = arc.lock().unwrap();
|
||||
let id = guard
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.find(|id| {
|
||||
guard
|
||||
.graph
|
||||
.get(*id)
|
||||
.map(|e| e.behavior.type_id() == MATH)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.expect("a math node is present");
|
||||
guard.graph.get(id).unwrap().core.label.clone()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot thread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The background snapshot thread writes periodic snapshots of dirty
|
||||
/// projects (short interval), the backend prunes to the newest three, and
|
||||
/// the exit flush drains and leaves a snapshot behind.
|
||||
#[test]
|
||||
fn snapshot_thread_and_exit_flush() {
|
||||
common::force_link();
|
||||
let dir = temp_dir("snap");
|
||||
let db = dir.join("lib.db");
|
||||
with_storage(&db, 1, || {
|
||||
let project = new_project();
|
||||
let uuid = project_uuid(project);
|
||||
|
||||
// Four command batches ~1.1 s apart (the 1 s interval): each batch
|
||||
// is a rapid pair of writes so the save-time snapshot policy stays
|
||||
// quiet and the THREAD is the one capturing the new head.
|
||||
for _ in 0..4 {
|
||||
let a = add_math_node(project);
|
||||
assert!(!a.is_null());
|
||||
let b = add_math_node(project);
|
||||
assert!(!b.is_null());
|
||||
std::thread::sleep(Duration::from_millis(1100));
|
||||
}
|
||||
// One more tick window so the final batch is snapshotted too.
|
||||
std::thread::sleep(Duration::from_millis(1500));
|
||||
|
||||
let seqs = snapshot_seqs(&db, &uuid);
|
||||
assert!(!seqs.is_empty(), "the thread produced snapshots");
|
||||
assert!(
|
||||
seqs.len() <= 3,
|
||||
"pruning keeps at most three (got {:?})",
|
||||
seqs
|
||||
);
|
||||
let (head, _) = journal_rows(&db, &uuid);
|
||||
assert_eq!(head, 8, "eight commands written through");
|
||||
|
||||
// Exit flush: drains (save + snapshot) and leaves the snapshot at
|
||||
// the head seq regardless of thread timing.
|
||||
assert_eq!(crate::storage::oakengine_storage_flush(), 0);
|
||||
let seqs = snapshot_seqs(&db, &uuid);
|
||||
assert!(!seqs.is_empty(), "flush leaves a snapshot behind");
|
||||
assert_eq!(seqs[0], 8, "the newest snapshot covers the head seq");
|
||||
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A dirty project flushed on close (project_free) gets its head snapshot
|
||||
/// even with a long (default) interval — the flush, not the thread, is the
|
||||
/// guaranteed drain.
|
||||
#[test]
|
||||
fn close_flushes_snapshot() {
|
||||
common::force_link();
|
||||
let dir = temp_dir("flush");
|
||||
let db = dir.join("lib.db");
|
||||
with_storage(&db, 600, || {
|
||||
let project = new_project();
|
||||
let uuid = project_uuid(project);
|
||||
let node = add_math_node(project);
|
||||
assert!(!node.is_null());
|
||||
let node2 = add_math_node(project);
|
||||
assert!(!node2.is_null());
|
||||
|
||||
// With the 600 s interval only the FIRST save snapshotted (the
|
||||
// backend's policy fires when no snapshot exists yet), so the head
|
||||
// seq is not covered.
|
||||
assert_eq!(snapshot_seqs(&db, &uuid), vec![1]);
|
||||
|
||||
// Closing the project flushes: write-through + snapshot at the head.
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
|
||||
let seqs = snapshot_seqs(&db, &uuid);
|
||||
assert_eq!(seqs[0], 2, "close flushed a snapshot at the head seq");
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-project bindings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Two projects bound to one library keep their rows apart: each project's
|
||||
/// writes advance only its own journal.
|
||||
#[test]
|
||||
fn multi_project_bindings_do_not_cross() {
|
||||
common::force_link();
|
||||
let dir = temp_dir("multi");
|
||||
let db = dir.join("lib.db");
|
||||
with_storage(&db, 600, || {
|
||||
// Project A: two commands.
|
||||
let a = new_project();
|
||||
for _ in 0..2 {
|
||||
let node = add_math_node(a);
|
||||
assert!(!node.is_null());
|
||||
}
|
||||
let uuid_a = project_uuid(a);
|
||||
|
||||
// Project B: binding it does not disturb A's row; its write goes
|
||||
// only to B's row (A's saves during B's commands are no-ops).
|
||||
let b = new_project();
|
||||
let node = add_math_node(b);
|
||||
assert!(!node.is_null());
|
||||
let uuid_b = project_uuid(b);
|
||||
assert_ne!(uuid_a, uuid_b);
|
||||
|
||||
let (head_a, rows_a) = journal_rows(&db, &uuid_a);
|
||||
assert_eq!(head_a, 2, "A's journal stops at its own second command");
|
||||
assert!(rows_a.iter().all(|r| r.seq <= 2));
|
||||
let (head_b, rows_b) = journal_rows(&db, &uuid_b);
|
||||
assert_eq!(head_b, 1, "B has exactly its one command");
|
||||
assert!(rows_b.iter().all(|r| r.seq == 1));
|
||||
|
||||
// Both projects load correctly through fresh sessions.
|
||||
let uri = db_uri(&db);
|
||||
let loaded_a = load_head(&project_uri(&uri, &uuid_a));
|
||||
let guard = loaded_a.lock().unwrap();
|
||||
assert_eq!(math_count(&guard), 2);
|
||||
drop(guard);
|
||||
let loaded_b = load_head(&project_uri(&uri, &uuid_b));
|
||||
let guard = loaded_b.lock().unwrap();
|
||||
assert_eq!(math_count(&guard), 1);
|
||||
|
||||
unsafe { crate::node::oakengine_project_free(a) };
|
||||
unsafe { crate::node::oakengine_project_free(b) };
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graceful degradation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// With `Storage/Backend = "off"` projects bind to nothing: the undo
|
||||
/// stack works, no library file is created, and `is_bound` reports 0.
|
||||
#[test]
|
||||
fn backend_off_keeps_projects_unbound() {
|
||||
common::force_link();
|
||||
let dir = temp_dir("off");
|
||||
let db = dir.join("lib.db");
|
||||
with_storage_off(|| {
|
||||
let project = new_project();
|
||||
// Not bound: no write-through happens at all.
|
||||
assert_eq!(unsafe { crate::storage::oakengine_storage_is_bound(project) }, 0);
|
||||
|
||||
let node = add_math_node(project);
|
||||
assert!(!node.is_null());
|
||||
let node2 = add_math_node(project);
|
||||
assert!(!node2.is_null());
|
||||
|
||||
// The undo stack still works.
|
||||
assert_eq!(unsafe { crate::node::oakengine_project_undo(project) }, 0);
|
||||
assert_eq!(unsafe { crate::node::oakengine_project_redo(project) }, 0);
|
||||
|
||||
// Nothing was ever written to the configured library path.
|
||||
assert!(!db.exists(), "no library file with the backend off");
|
||||
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// An unwritable library degrades gracefully: the write-through records a
|
||||
/// `last_error` instead of failing the command or crashing, and the undo
|
||||
/// stack keeps working.
|
||||
#[test]
|
||||
fn unwritable_library_records_last_error() {
|
||||
common::force_link();
|
||||
let dir = temp_dir("ro");
|
||||
let db = dir.join("no/such/dir/lib.db"); // parent dir does not exist
|
||||
with_storage(&db, 600, || {
|
||||
let project = new_project();
|
||||
// Bound, but the first write-through cannot open the library.
|
||||
assert_eq!(unsafe { crate::storage::oakengine_storage_is_bound(project) }, 1);
|
||||
|
||||
let node = add_math_node(project);
|
||||
assert!(!node.is_null());
|
||||
|
||||
// The command succeeded; the write failure is recorded, not raised.
|
||||
let mut buf = [0 as std::ffi::c_char; 512];
|
||||
let len = unsafe { crate::storage::oakengine_storage_last_error(project, buf.as_mut_ptr(), 512) };
|
||||
assert!(len > 0, "the failed write-through is recorded");
|
||||
let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
assert!(!msg.is_empty(), "error message non-empty");
|
||||
|
||||
// A second command degrades the same way.
|
||||
let node2 = add_math_node(project);
|
||||
assert!(!node2.is_null());
|
||||
let len =
|
||||
unsafe { crate::storage::oakengine_storage_last_error(project, buf.as_mut_ptr(), 512) };
|
||||
assert!(len > 0);
|
||||
|
||||
// The undo stack is unaffected.
|
||||
assert_eq!(unsafe { crate::node::oakengine_project_undo(project) }, 0);
|
||||
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The default library path resolves to `<system data dir>/library.db`
|
||||
/// (absolute), and the backend is strictly config-driven: `Backend =
|
||||
/// "sqlite"` enables it, `"off"` disables it (and an absent `Storage`
|
||||
/// group means "no library configured" — projects stay unbound).
|
||||
#[test]
|
||||
fn default_library_path_and_backend() {
|
||||
common::force_link();
|
||||
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _config = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// The default path is a plain absolute `…/library.db`.
|
||||
let p = crate::storage::default_library_path();
|
||||
let path = std::path::Path::new(&p);
|
||||
assert!(path.is_absolute(), "{p}");
|
||||
assert!(p.ends_with("library.db"), "{p}");
|
||||
|
||||
// Explicit values drive the enabled state.
|
||||
let store = oakcommon::configstore::ConfigStore::instance();
|
||||
store.set(Some("Storage"), "Backend", "sqlite");
|
||||
assert!(crate::storage::storage_enabled());
|
||||
store.set(Some("Storage"), "Backend", "off");
|
||||
assert!(!crate::storage::storage_enabled());
|
||||
}
|
||||
@@ -88,9 +88,25 @@ const OAKTASK_E_NOT_FOUND: c_int = -80004;
|
||||
/// does not cascade into `PoisonError` failures in every later test.
|
||||
static SERIAL: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Take the [`SERIAL`] lock, recovering from any poisoning.
|
||||
pub(crate) fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
SERIAL.lock().unwrap_or_else(|e| e.into_inner())
|
||||
/// Both lock guards held by [`serial`] (the local task lock plus the
|
||||
/// facade-wide undo-stack lock).
|
||||
pub(crate) struct SerialGuard {
|
||||
/// The [`SERIAL`] lock.
|
||||
_task: std::sync::MutexGuard<'static, ()>,
|
||||
/// The facade's process-wide undo-stack lock (it_undo's), so the
|
||||
/// `oakengine_project_new` calls in these tests (which clear the stack)
|
||||
/// never race the it_undo / it_storage stack tests.
|
||||
_stack: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
/// Take the [`SERIAL`] lock AND the global undo-stack lock, recovering
|
||||
/// from any poisoning.
|
||||
pub(crate) fn serial() -> SerialGuard {
|
||||
let _task = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _stack = super::it_undo::GLOBAL_STACK_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
SerialGuard { _task, _stack }
|
||||
}
|
||||
|
||||
/// The facade's live task-payload counter (the engine-side replacement
|
||||
@@ -904,6 +920,9 @@ fn export_task_creation() {
|
||||
fn export_task_run_real_encoder() {
|
||||
let _g = serial();
|
||||
common::force_link();
|
||||
// The import/add-track/add-clip commands below are undoable; disable
|
||||
// the write-through backend so they never touch a library.
|
||||
let _storage = common::storage_off_guard();
|
||||
|
||||
let media = std::env::temp_dir().join(format!(
|
||||
"oakengine-it-task-export-src-{}.mp4",
|
||||
|
||||
@@ -90,8 +90,9 @@ unsafe fn read_str(buf: *const c_char) -> String {
|
||||
/// `null_name_group_repro`, `group_abort_undoes_children_repro`): cargo
|
||||
/// runs tests on parallel threads and the global stack / single open undo
|
||||
/// group cannot be shared, so each of those tests holds this lock for its
|
||||
/// whole body.
|
||||
static GLOBAL_STACK_LOCK: Mutex<()> = Mutex::new(());
|
||||
/// whole body. Public so the write-through tests (it_storage.rs), which
|
||||
/// push commands on the same global stack, serialize on the SAME lock.
|
||||
pub static GLOBAL_STACK_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
static LIFECYCLE_REDO: AtomicI32 = AtomicI32::new(0);
|
||||
static LIFECYCLE_UNDO: AtomicI32 = AtomicI32::new(0);
|
||||
|
||||
@@ -42,6 +42,7 @@ mod it_codec;
|
||||
mod it_common;
|
||||
mod it_export;
|
||||
mod it_plugin;
|
||||
mod it_storage;
|
||||
mod it_task;
|
||||
mod it_undo;
|
||||
mod linkage;
|
||||
|
||||
@@ -108,6 +108,10 @@ unsafe fn find_node(project: *mut crate::handle::OakEngineProject, id: &str) ->
|
||||
fn project_node_keyframe_lifecycle() {
|
||||
common::force_link();
|
||||
let _ = force_oakundo_command_link();
|
||||
// The undo commands below would bind the project to the default user
|
||||
// library and write through to it; hold the storage lock and disable
|
||||
// the backend for the whole test.
|
||||
let _storage = common::storage_off_guard();
|
||||
|
||||
// ---- project: create → new → name/filename readback ----------------
|
||||
let project = oakengine_project_create();
|
||||
|
||||
@@ -153,6 +153,13 @@ fn multi_command_add_child_count_redo() {
|
||||
/// exercised from parallel test threads.
|
||||
#[test]
|
||||
fn undo_stack_lifecycle() {
|
||||
// Serialized on the SAME lock as the it_undo stack tests and the
|
||||
// write-through tests (the facade's stack is process-wide): without it
|
||||
// a concurrent test's pushes break the exact-count assertions below.
|
||||
let _stack = super::it_undo::GLOBAL_STACK_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// Reset to a clean "New/Open Project" base row.
|
||||
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
|
||||
assert_eq!(unsafe { oakengine_undo_count() }, 1);
|
||||
|
||||
@@ -116,8 +116,10 @@ pub(crate) unsafe fn push_or_run(
|
||||
};
|
||||
let rc = unsafe { undostack_push(stack, cmd, label_ptr) };
|
||||
if rc == 0 {
|
||||
// Stack took a reference; release ours by freeing the box.
|
||||
// Stack took a reference; release ours by freeing the box. The
|
||||
// command's redo already ran (plan M13 D2): persist the project.
|
||||
unsafe { free_box(command_box) };
|
||||
crate::storage::note_command();
|
||||
Ok(())
|
||||
} else {
|
||||
// Push failed (e.g. empty multi): the module deleted the command;
|
||||
@@ -193,6 +195,9 @@ pub extern "C" fn oakengine_undo_group_end() -> c_int {
|
||||
let mut multi_handle = multi;
|
||||
unsafe { command_free(&mut multi_handle) };
|
||||
if rc == 0 {
|
||||
// The group's children were redo'd eagerly at push time; the
|
||||
// whole group is one command (plan §2: commit at group_end).
|
||||
crate::storage::note_command();
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Module(rc))
|
||||
@@ -408,10 +413,15 @@ pub extern "C" fn oakengine_undo_command_is_done(row: i64) -> c_int {
|
||||
}
|
||||
|
||||
/// `oakengine_undo_jump` — undo/redo until the done-command count equals
|
||||
/// `index`.
|
||||
/// `index`. On success the bound projects are written through (the jump
|
||||
/// executed the undo/redo callbacks that mutated them).
|
||||
#[no_mangle]
|
||||
pub extern "C" fn oakengine_undo_jump(index: i64) -> c_int {
|
||||
guard(|| unsafe { Error::from_module(undostack_jump(*global_stack(), index)) })
|
||||
let rc = guard(|| unsafe { Error::from_module(undostack_jump(*global_stack(), index)) });
|
||||
if rc == crate::error::OAKENGINE_OK {
|
||||
crate::storage::note_command();
|
||||
}
|
||||
rc
|
||||
}
|
||||
|
||||
/// `oakengine_undo_clear` — delete all commands and push the fresh
|
||||
|
||||
@@ -97,6 +97,19 @@ facade 的 undo 推送路径挂钩(`oakengine_undo_push` / `undo_group_end`
|
||||
新建/导入建立 (project ↔ storage session) 绑定;关闭解绑。状态栏
|
||||
脏标记改为"已写入/写入中"。
|
||||
|
||||
> D2 落地记录(2026-08):`crates/oakengine/src/storage.rs` 实现绑定表
|
||||
> (project handle ctx → `{db uri, uuid}`)、写穿(每次 undo 路径成功
|
||||
> 后对全部绑定工程调 `DatabaseBackend::save`,diff 式,未变工程 no-op)、
|
||||
> 快照线程(`Storage/SnapshotIntervalSec`,默认 600s,latest-wins,
|
||||
> 退出 `oakengine_storage_flush` 排空)与 `last_error` 降级。
|
||||
> **配置默认值**:`Storage/Backend` 的默认值 = `"sqlite"`,`Storage/SqlitePath`
|
||||
> 的默认值 = `<系统数据目录>/library.db`(`FileFunctions::get_configuration_location`
|
||||
> 的 macOS Application Support / XDG 位置,尊重 `OAK_CONFIG_DIR`)。
|
||||
> **启用语义**:`Storage/Backend` 显式为 `"sqlite"`/`"database"` 才启用写穿;
|
||||
> 键缺失 = "无库配置"(工程不绑定、写穿不触发)——这是 §2"无库配置优雅
|
||||
> 降级"的默认形态,保证 headless 消费者(oak-cli)与测试进程永远不写
|
||||
> 用户的真实库。app 侧(D4/D5)在启动时显式设置该配置即可启用。
|
||||
|
||||
## 4. 项目管理器窗口(app)
|
||||
|
||||
达芬奇式启动窗 + 菜单 文件→项目管理器:
|
||||
|
||||
Reference in New Issue
Block a user