feat(storage,app): PostgreSQL backend (D3) + project manager window (D4)
D3: oakdb+pg:// fully wired (shared sea-orm entities, BIGSERIAL DDL, connect-probe instead of pool retry on dead servers); Storage/Backend=pg + Storage/PgUrl config; 13 OAK_TEST_PG_URL-gated PG tests (verified against a Docker postgres:16), always-on clean-error tests otherwise. D4: DaVinci-style project manager — list with derived stats, create/ rename/duplicate/delete (confirm)/import/export (native dialogs, ove/otio/fcpxml), shown at startup and from the file menu; facade oakengine_library_* exports (list/create/delete/rename/duplicate/ import/export + project_load_library that binds write-through); save/save-as menu becomes 'export project file', open splits into from-library/from-file; status bar shows library write state; storage activates on app start and flushes on exit; spawn_modal reentrancy fixed (window-callback path) with a doc note. Also: the P1 audio test's environment probe was lost in the ffi purge; restored on cpal (the output device is cpal now).
This commit is contained in:
@@ -74,6 +74,7 @@ pub mod handle;
|
||||
pub mod ipc;
|
||||
#[cfg(not(test))]
|
||||
pub mod linkage;
|
||||
pub mod library;
|
||||
pub mod node;
|
||||
pub mod plugin;
|
||||
pub mod pods;
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
// 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 project-library manager C ABI (plan M13 §4): list / create / open /
|
||||
//! rename / duplicate / delete / import / export over the oakstorage
|
||||
//! database backend the write-through binds to ([`crate::storage`]).
|
||||
//!
|
||||
//! These exports are additive (D4): the app talks to the engine dylib only
|
||||
//! through the frozen `oakengine_*` surface, so the manager's data source
|
||||
//! crosses the boundary here instead of linking oakstorage directly (which
|
||||
//! would give the app a second copy of the handle/serializer types).
|
||||
//!
|
||||
//! All operations address the configured default library (the same
|
||||
//! `Storage/Backend` + `Storage/SqlitePath` configuration the write-through
|
||||
//! uses); with storage disabled every call fails with `OAKENGINE_E_STATE`
|
||||
//! except [`oakengine_library_list`], which reports an empty library
|
||||
//! (`"[]"`) so the manager window can still open.
|
||||
|
||||
use std::ffi::{c_char, c_int};
|
||||
|
||||
use oakstorage::backend::StorageBackend;
|
||||
use oakstorage::uri::StorageUri;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::handle::{guard, guard_int, read_cstr, write_string, OakEngineProject};
|
||||
use crate::stubs::node as n;
|
||||
|
||||
/// One library row as the project manager shows it: the project metadata
|
||||
/// plus the stats derived from the head state (plan §4).
|
||||
#[derive(serde::Serialize)]
|
||||
struct LibraryRow {
|
||||
/// Library row uuid (the open/duplicate/export selector).
|
||||
uuid: String,
|
||||
/// Display name.
|
||||
name: String,
|
||||
/// Row creation time (unix seconds, UTC).
|
||||
created_at: i64,
|
||||
/// Last-write time (unix seconds, UTC; the manager sort key).
|
||||
modified_at: i64,
|
||||
/// Longest sequence duration, milliseconds.
|
||||
duration_ms: i64,
|
||||
/// Total tracks across all sequences.
|
||||
track_count: i32,
|
||||
/// Total clip blocks.
|
||||
clip_count: i32,
|
||||
/// Total footage nodes.
|
||||
footage_count: i32,
|
||||
}
|
||||
|
||||
/// Map an oakstorage error onto the facade error space (the context string
|
||||
/// is log-only per the error contract).
|
||||
fn map_err(e: oakstorage::error::Error) -> Error {
|
||||
use oakstorage::error::Error as E;
|
||||
match e {
|
||||
E::Invalid => Error::Invalid,
|
||||
E::State => Error::State,
|
||||
E::NotFound => Error::NotFound,
|
||||
E::NoMem => Error::NoMem,
|
||||
other => Error::Failed(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured default library as a parsed URI; [`Error::State`] when
|
||||
/// the write-through backend is disabled or the path does not resolve.
|
||||
fn library() -> Result<StorageUri> {
|
||||
if !crate::storage::storage_enabled() {
|
||||
return Err(Error::State);
|
||||
}
|
||||
let uri = crate::storage::library_uri().ok_or(Error::State)?;
|
||||
StorageUri::parse(&uri).map_err(map_err)
|
||||
}
|
||||
|
||||
/// The library URI selecting one row (`…?project=<uuid>`).
|
||||
fn project_uri(uuid: &str) -> Result<StorageUri> {
|
||||
let uri = library()?;
|
||||
StorageUri::parse(&format!("{}?project={uuid}", uri.to_uri_string())).map_err(map_err)
|
||||
}
|
||||
|
||||
/// Load one library row as an owned project handle (refcount 1).
|
||||
fn load_handle(uuid: &str) -> Result<crate::handle::CHandle> {
|
||||
let uri = project_uri(uuid)?;
|
||||
let result = crate::storage::backend().load(&uri).map_err(map_err)?;
|
||||
if result.project.is_null() {
|
||||
return Err(Error::Failed(format!(
|
||||
"library load of {uuid} returned no project (info code {})",
|
||||
result.version_info
|
||||
)));
|
||||
}
|
||||
Ok(result.project)
|
||||
}
|
||||
|
||||
/// Release an owned handle (refcount 1).
|
||||
fn release(h: crate::handle::CHandle) {
|
||||
if let Some(release) = h.release {
|
||||
unsafe { release(h.ctx) };
|
||||
}
|
||||
}
|
||||
|
||||
/// `oakengine_library_list` — the library rows as a JSON array (buf/size
|
||||
/// convention), most recently modified first. Each row carries the manager
|
||||
/// stats derived from the head state; a row whose stats fail to replay
|
||||
/// degrades to zeros instead of failing the whole list. With storage
|
||||
/// disabled the result is the empty array (`"[]"`), not an error.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_list(buf: *mut c_char, buf_size: c_int) -> c_int {
|
||||
guard_int(|| unsafe {
|
||||
if !crate::storage::storage_enabled() {
|
||||
return Ok(write_string("[]", buf, buf_size));
|
||||
}
|
||||
let uri = library()?;
|
||||
let infos = crate::storage::backend()
|
||||
.list_projects(&uri)
|
||||
.map_err(map_err)?;
|
||||
let mut rows = Vec::with_capacity(infos.len());
|
||||
for info in infos {
|
||||
let stats = crate::storage::backend()
|
||||
.project_stats(&uri, &info.uuid)
|
||||
.unwrap_or_default();
|
||||
rows.push(LibraryRow {
|
||||
uuid: info.uuid,
|
||||
name: info.name,
|
||||
created_at: info.created_at.and_utc().timestamp(),
|
||||
modified_at: info.modified_at.and_utc().timestamp(),
|
||||
duration_ms: stats.duration_ms,
|
||||
track_count: stats.track_count,
|
||||
clip_count: stats.clip_count,
|
||||
footage_count: stats.footage_count,
|
||||
});
|
||||
}
|
||||
let json = serde_json::to_string(&rows)
|
||||
.map_err(|e| Error::Failed(format!("library list encode: {e}")))?;
|
||||
Ok(write_string(&json, buf, buf_size))
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_create` — create a blank project named `name` as a
|
||||
/// new library row and report its uuid (buf/size convention on
|
||||
/// `out_uuid`; the return value is the uuid length, negative on error).
|
||||
/// The row lands immediately (one `kind='import'` command), so the
|
||||
/// manager list shows it before the first edit.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_create(
|
||||
name: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int {
|
||||
guard_int(|| unsafe {
|
||||
if name.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let name = read_cstr(name);
|
||||
if name.trim().is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let uri = library()?;
|
||||
|
||||
let mut h = n::oaknode_project_init();
|
||||
if h.is_null() {
|
||||
return Err(Error::NoMem);
|
||||
}
|
||||
let outcome = (|| -> Result<String> {
|
||||
Error::from_module(n::oaknode_project_initialize(h))?;
|
||||
let uuid = {
|
||||
let arc = crate::handle::domain::project_of(&h).ok_or(Error::Invalid)?;
|
||||
let mut guard = arc.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.settings.insert("projectname".to_string(), name);
|
||||
guard.uuid.clone()
|
||||
};
|
||||
crate::storage::backend().save(h, &uri, 0).map_err(map_err)?;
|
||||
Ok(uuid)
|
||||
})();
|
||||
n::oaknode_project_free(&mut h);
|
||||
let uuid = outcome?;
|
||||
Ok(write_string(&uuid, out_uuid, out_size))
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_delete` — delete the library row `uuid` (cascades
|
||||
/// settings / snapshots / journal; `OAKENGINE_E_NOT_FOUND` when absent).
|
||||
/// The manager confirms with the user before calling.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_delete(uuid: *const c_char) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if uuid.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let uuid = read_cstr(uuid);
|
||||
if uuid.is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
crate::storage::backend()
|
||||
.delete_project(&library()?, &uuid)
|
||||
.map_err(map_err)
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_rename` — rename the library row `uuid` (the
|
||||
/// manager's list name; the in-project `projectname` setting is
|
||||
/// untouched).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_rename(
|
||||
uuid: *const c_char,
|
||||
name: *const c_char,
|
||||
) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if uuid.is_null() || name.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let (uuid, name) = (read_cstr(uuid), read_cstr(name));
|
||||
if uuid.is_empty() || name.trim().is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
crate::storage::backend()
|
||||
.rename_project(&library()?, &uuid, name.trim())
|
||||
.map_err(map_err)
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_duplicate` — copy the library row `uuid` (settings,
|
||||
/// snapshots and the full journal history included) under a fresh uuid,
|
||||
/// reporting the new row's uuid (buf/size convention on `out_uuid`; the
|
||||
/// return value is the uuid length, negative on error).
|
||||
/// `name` is the copy's display name; NULL/empty defaults to
|
||||
/// `<name> (copy)`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_duplicate(
|
||||
uuid: *const c_char,
|
||||
name: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int {
|
||||
guard_int(|| unsafe {
|
||||
if uuid.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let uuid = read_cstr(uuid);
|
||||
if uuid.is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let name = read_cstr(name);
|
||||
let name = match name.trim() {
|
||||
"" => None,
|
||||
trimmed => Some(trimmed),
|
||||
};
|
||||
let info = crate::storage::backend()
|
||||
.duplicate_project(&library()?, &uuid, name)
|
||||
.map_err(map_err)?;
|
||||
Ok(write_string(&info.uuid, out_uuid, out_size))
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_import` — import a `.ove` / `.otio` / `.fcpxml`
|
||||
/// project file as a new library row (the file backend parses it, a fresh
|
||||
/// uuid is assigned, and the first save journals the whole project as one
|
||||
/// `kind='import'` command). Reports the new row's uuid (buf/size
|
||||
/// convention on `out_uuid`; the return value is the uuid length,
|
||||
/// negative on error).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_import(
|
||||
path: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int {
|
||||
guard_int(|| unsafe {
|
||||
if path.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let path = read_cstr(path);
|
||||
if path.is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let file_uri = StorageUri::parse(&path).map_err(map_err)?;
|
||||
let uuid = crate::storage::backend()
|
||||
.import_from_file(&library()?, &file_uri)
|
||||
.map_err(map_err)?;
|
||||
Ok(write_string(&uuid, out_uuid, out_size))
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_export` — export the library row `uuid` to the file
|
||||
/// `path`; the format is dispatched by extension through the oakstorage
|
||||
/// registry (`.ove` / `.ovexml` → ove-xml, `.otio` / `.fcpxml` → the
|
||||
/// interchange backend). Nothing is written back to the library.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_export(
|
||||
uuid: *const c_char,
|
||||
path: *const c_char,
|
||||
) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if uuid.is_null() || path.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let (uuid, path) = (read_cstr(uuid), read_cstr(path));
|
||||
if uuid.is_empty() || path.is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let file_uri = StorageUri::parse(&path).map_err(map_err)?;
|
||||
if file_uri.scheme != "file" {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let handle = load_handle(&uuid)?;
|
||||
let backend = oakstorage::registry::Registry::global()
|
||||
.resolve(&file_uri)
|
||||
.map_err(map_err)?;
|
||||
let result = backend.save(handle, &file_uri, 0).map_err(map_err);
|
||||
release(handle);
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_project_load_library` — load the library row `uuid` into a
|
||||
/// fresh project shell (same contract as `oakengine_project_load`: the
|
||||
/// shell must carry no content). On success the undo stack is cleared, the
|
||||
/// modified flag is reset, and the project is bound to the library session
|
||||
/// (the write-through continues the row's journal from its head seq).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_project_load_library(
|
||||
self_: *mut OakEngineProject,
|
||||
uuid: *const c_char,
|
||||
err: *mut c_char,
|
||||
err_size: c_int,
|
||||
) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if self_.is_null() || uuid.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let h = crate::handle::unbox(self_)?;
|
||||
if !n::oaknode_project_root(h).is_null() {
|
||||
return Err(Error::State);
|
||||
}
|
||||
let uuid = read_cstr(uuid);
|
||||
if uuid.is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let loaded = match load_handle(&uuid) {
|
||||
Ok(handle) => handle,
|
||||
Err(e) => {
|
||||
write_string(&e.to_string(), err, err_size);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Swap the loaded content into the caller's shell box, releasing
|
||||
// the empty shell handle the box was created with.
|
||||
let mut old = (*self_).handle;
|
||||
(*self_).handle = loaded;
|
||||
n::oaknode_project_free(&mut old);
|
||||
crate::undo::oakengine_undo_clear();
|
||||
Error::from_module(n::oaknode_project_set_modified(loaded, 0))?;
|
||||
crate::storage::bind_project(loaded);
|
||||
if !err.is_null() && err_size > 0 {
|
||||
*err = 0;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -41,9 +41,9 @@
|
||||
//! 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
|
||||
//! - `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
|
||||
@@ -52,6 +52,12 @@
|
||||
//! 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
|
||||
//!
|
||||
@@ -101,9 +107,9 @@ struct Binding {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// 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)
|
||||
}
|
||||
@@ -291,18 +297,18 @@ fn record_error(key: usize, message: &str) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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`
|
||||
/// `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(crate) fn storage_enabled() -> bool {
|
||||
let store = oakcommon::configstore::ConfigStore::instance();
|
||||
match store.get(Some("Storage"), "Backend") {
|
||||
Ok(b) => b == "sqlite" || b == "database",
|
||||
Ok(b) => b == "sqlite" || b == "database" || b == "pg",
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
@@ -316,6 +322,29 @@ fn configured_sqlite_path() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -327,9 +356,14 @@ pub(crate) fn default_library_path() -> String {
|
||||
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> {
|
||||
/// 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`].
|
||||
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(),
|
||||
@@ -580,4 +614,47 @@ mod tests {
|
||||
assert!(!storage_enabled());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_uri_resolves_pg_config() {
|
||||
use oakcommon::configstore::ConfigStore;
|
||||
let store = ConfigStore::instance();
|
||||
let _g = crate::tests::common::STORAGE_CONFIG_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// Backend = "pg" yields an oakdb+pg uri from Storage/PgUrl; a
|
||||
// postgres:// scheme on the config value is stripped.
|
||||
store.set(Some("Storage"), "Backend", "pg");
|
||||
store.set(
|
||||
Some("Storage"),
|
||||
"PgUrl",
|
||||
"postgres://user:pass@host:5432/oak",
|
||||
);
|
||||
assert!(storage_enabled());
|
||||
assert_eq!(
|
||||
library_uri().as_deref(),
|
||||
Some("oakdb+pg://user:pass@host:5432/oak")
|
||||
);
|
||||
|
||||
// postgresql:// is accepted too.
|
||||
store.set(
|
||||
Some("Storage"),
|
||||
"PgUrl",
|
||||
"postgresql://u@h/db?sslmode=disable",
|
||||
);
|
||||
assert_eq!(
|
||||
library_uri().as_deref(),
|
||||
Some("oakdb+pg://u@h/db?sslmode=disable")
|
||||
);
|
||||
|
||||
// Backend = "pg" with no PgUrl = no library (graceful
|
||||
// degradation, same as an absent sqlite path).
|
||||
store.set(Some("Storage"), "PgUrl", "");
|
||||
assert_eq!(library_uri(), None);
|
||||
|
||||
// Leave the store in a safe state.
|
||||
store.set(Some("Storage"), "Backend", "off");
|
||||
assert!(!storage_enabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// 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/>.
|
||||
|
||||
//! D4 integration tests: the project-library manager C ABI
|
||||
//! (`src/library.rs`, plan M13 §4).
|
||||
//!
|
||||
//! End-to-end against real SQLite library files in temp directories,
|
||||
//! driving the facade exactly like the app's project manager: create lands
|
||||
//! a row immediately, list reports it with the derived stats, open
|
||||
//! (`oakengine_project_load_library`) binds the loaded project to the
|
||||
//! library session (the next undoable edit write-throughs onto the row's
|
||||
//! journal), rename / duplicate / delete / import / export round-trip, and
|
||||
//! the disabled-backend configuration degrades to an empty list + error
|
||||
//! codes.
|
||||
//!
|
||||
//! Every test holds the shared undo-stack lock (the facade's stack is
|
||||
//! process-wide, same as the it_undo / it_storage families) and the
|
||||
//! storage-config lock, so the suite never races on either singleton.
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::common;
|
||||
use super::it_undo::GLOBAL_STACK_LOCK;
|
||||
|
||||
use crate::error::{OAKENGINE_OK, OAKENGINE_E_INVALID, OAKENGINE_E_NOT_FOUND, OAKENGINE_E_STATE};
|
||||
use crate::handle::OakEngineProject;
|
||||
|
||||
/// The math node type id (a factory type the tests add as an undoable
|
||||
/// edit; footage is not factory-creatable, so the write-through is
|
||||
/// verified on the journal rows directly).
|
||||
const MATH: &str = "org.olivevideoeditor.Olive.math";
|
||||
|
||||
/// The journal row count of a library row (direct sea-orm read, the same
|
||||
/// pattern as the it_storage tests).
|
||||
fn journal_rows(db: &Path, uuid: &str) -> usize {
|
||||
use sea_orm::entity::prelude::*;
|
||||
use oakstorage::backends::database::entities::{journal, project};
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
let conn = sea_orm::Database::connect(format!("sqlite://{}?mode=ro", db.display()))
|
||||
.await
|
||||
.unwrap();
|
||||
let model = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(uuid))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("the row exists");
|
||||
journal::Entity::find()
|
||||
.filter(journal::Column::ProjectId.eq(model.id))
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.len()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serialize a library test: hold the process-global undo-stack lock AND
|
||||
/// the storage-config lock for the whole body, then point the library at a
|
||||
/// temp SQLite file.
|
||||
fn with_library<R>(db: &Path, 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());
|
||||
f()
|
||||
}
|
||||
|
||||
/// A fresh, unique temp directory for one test.
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("oakengine_library_{}_{}", std::process::id(), tag));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Two-stage string read over a facade `(buf, size)` getter.
|
||||
fn read_string(f: impl Fn(*mut std::ffi::c_char, i32) -> i32) -> String {
|
||||
let needed = f(std::ptr::null_mut(), 0);
|
||||
if needed <= 0 {
|
||||
return String::new();
|
||||
}
|
||||
let mut buf = vec![0 as std::ffi::c_char; needed as usize + 1];
|
||||
f(buf.as_mut_ptr(), needed + 1);
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// The library list JSON.
|
||||
fn list_json() -> String {
|
||||
read_string(|buf, size| unsafe { crate::library::oakengine_library_list(buf, size) })
|
||||
}
|
||||
|
||||
/// Create a library row; returns its uuid. The create export has a side
|
||||
/// effect, so it is called ONCE with a stack buffer (never two-stage).
|
||||
fn create(name: &str) -> String {
|
||||
let name = CString::new(name).unwrap();
|
||||
let mut buf = [0 as std::ffi::c_char; 256];
|
||||
let rc = unsafe { crate::library::oakengine_library_create(name.as_ptr(), buf.as_mut_ptr(), 256) };
|
||||
assert!(rc > 0, "create {name:?} rc={rc}");
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// Duplicate a library row (single call with a stack buffer, see
|
||||
/// [`create`]).
|
||||
fn duplicate(uuid: &str) -> String {
|
||||
let uuid = CString::new(uuid).unwrap();
|
||||
let mut buf = [0 as std::ffi::c_char; 256];
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_duplicate(
|
||||
uuid.as_ptr(),
|
||||
std::ptr::null(),
|
||||
buf.as_mut_ptr(),
|
||||
256,
|
||||
)
|
||||
};
|
||||
assert!(rc > 0, "duplicate rc={rc}");
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// The uuids in the library list JSON (order preserved).
|
||||
fn list_uuids(json: &str) -> Vec<String> {
|
||||
serde_json::from_str::<serde_json::Value>(json)
|
||||
.expect("list is JSON")
|
||||
.as_array()
|
||||
.expect("list is an array")
|
||||
.iter()
|
||||
.map(|row| {
|
||||
row.get("uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("row uuid")
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One row of the library list JSON by uuid.
|
||||
fn list_row<'a>(json: &'a str, uuid: &str) -> Option<serde_json::Value> {
|
||||
serde_json::from_str::<serde_json::Value>(json)
|
||||
.expect("list is JSON")
|
||||
.as_array()
|
||||
.expect("list is an array")
|
||||
.iter()
|
||||
.find(|row| row.get("uuid").and_then(|v| v.as_str()) == Some(uuid))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Open a library row into a fresh facade project shell.
|
||||
fn open(uuid: &str) -> *mut OakEngineProject {
|
||||
let project = unsafe { crate::node::oakengine_project_create() };
|
||||
assert!(!project.is_null());
|
||||
let uuid_c = CString::new(uuid).unwrap();
|
||||
let mut err = [0 as std::ffi::c_char; 4096];
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_project_load_library(
|
||||
project,
|
||||
uuid_c.as_ptr(),
|
||||
err.as_mut_ptr(),
|
||||
err.len() as i32,
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_OK, "open {uuid}");
|
||||
project
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create lands a row immediately; list reports it with the metadata and
|
||||
/// the (zero) stats.
|
||||
#[test]
|
||||
fn create_then_list_shows_the_row() {
|
||||
let dir = temp_dir("create");
|
||||
with_library(&dir.join("lib.db"), || {
|
||||
let uuid = create("Demo Reel");
|
||||
assert!(!uuid.is_empty(), "create reports the new uuid");
|
||||
|
||||
let json = list_json();
|
||||
let row = list_row(&json, &uuid).expect("the created row is listed");
|
||||
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Demo Reel"));
|
||||
assert!(row.get("modified_at").and_then(|v| v.as_i64()).unwrap() > 0);
|
||||
assert_eq!(row.get("track_count").and_then(|v| v.as_i64()), Some(0));
|
||||
assert_eq!(row.get("footage_count").and_then(|v| v.as_i64()), Some(0));
|
||||
|
||||
// A second create adds a second row.
|
||||
let other = create("Second");
|
||||
assert_ne!(uuid, other);
|
||||
assert_eq!(list_uuids(&list_json()).len(), 2);
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Opening a library row binds the project to the library session: the
|
||||
/// next undoable edit write-throughs onto the row's journal.
|
||||
#[test]
|
||||
fn open_binds_and_write_through_advances_the_row() {
|
||||
let dir = temp_dir("open");
|
||||
let db = dir.join("lib.db");
|
||||
with_library(&db, || {
|
||||
let uuid = create("Editable");
|
||||
let before = journal_rows(&db, &uuid);
|
||||
let project = open(&uuid);
|
||||
assert_eq!(
|
||||
unsafe { crate::storage::oakengine_storage_is_bound(project) },
|
||||
1,
|
||||
"the library-opened project is bound"
|
||||
);
|
||||
|
||||
// An undoable edit write-throughs.
|
||||
let node = unsafe {
|
||||
crate::node::oakengine_project_add_node(project, CString::new(MATH).unwrap().as_ptr())
|
||||
};
|
||||
assert!(!node.is_null());
|
||||
unsafe { crate::node::oakengine_node_free(node) };
|
||||
|
||||
let after = journal_rows(&db, &uuid);
|
||||
assert!(
|
||||
after > before,
|
||||
"the edit journaled new rows ({before} -> {after})"
|
||||
);
|
||||
|
||||
// The row name comes from the projectname setting (the facade's
|
||||
// project name is filename-derived, so a library project displays
|
||||
// "(untitled)"; the app overrides it with the row name).
|
||||
let row = list_row(&list_json(), &uuid).expect("row after the edit");
|
||||
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Editable"));
|
||||
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Rename and duplicate keep the list coherent; delete removes the row and
|
||||
/// opening it afterwards fails.
|
||||
#[test]
|
||||
fn rename_duplicate_delete() {
|
||||
let dir = temp_dir("rdd");
|
||||
with_library(&dir.join("lib.db"), || {
|
||||
let uuid = create("Original");
|
||||
|
||||
// Rename.
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_rename(
|
||||
CString::new(uuid.clone()).unwrap().as_ptr(),
|
||||
CString::new("Renamed").unwrap().as_ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_OK);
|
||||
let row = list_row(&list_json(), &uuid).expect("renamed row");
|
||||
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Renamed"));
|
||||
|
||||
// Duplicate (default "<name> (copy)" name).
|
||||
let copy = duplicate(&uuid);
|
||||
assert_ne!(copy, uuid);
|
||||
let row = list_row(&list_json(), ©).expect("the copy is listed");
|
||||
assert_eq!(
|
||||
row.get("name").and_then(|v| v.as_str()),
|
||||
Some("Renamed (copy)")
|
||||
);
|
||||
|
||||
// The copy opens (its journal history came along).
|
||||
let project = open(©);
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
|
||||
// Delete the copy; opening it afterwards fails.
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_delete(CString::new(copy.clone()).unwrap().as_ptr())
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_OK);
|
||||
assert!(!list_uuids(&list_json()).contains(©));
|
||||
let shell = unsafe { crate::node::oakengine_project_create() };
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_project_load_library(
|
||||
shell,
|
||||
CString::new(copy).unwrap().as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_E_NOT_FOUND, "a deleted row does not open");
|
||||
unsafe { crate::node::oakengine_project_free(shell) };
|
||||
|
||||
// Unknown uuids are E_NOT_FOUND; empty arguments E_INVALID.
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_delete(CString::new("{no-such}").unwrap().as_ptr())
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_E_NOT_FOUND);
|
||||
let rc = unsafe { crate::library::oakengine_library_delete(c"".as_ptr()) };
|
||||
assert_eq!(rc, OAKENGINE_E_INVALID);
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Export writes the row's head state to a file (dispatched by extension),
|
||||
/// and import brings a file back as a new library row that opens.
|
||||
#[test]
|
||||
fn export_then_import_round_trip() {
|
||||
let dir = temp_dir("xport");
|
||||
with_library(&dir.join("lib.db"), || {
|
||||
// A row with one node, so the exported file has content.
|
||||
let uuid = create("Exchange");
|
||||
let project = open(&uuid);
|
||||
let node = unsafe {
|
||||
crate::node::oakengine_project_add_node(project, CString::new(MATH).unwrap().as_ptr())
|
||||
};
|
||||
assert!(!node.is_null());
|
||||
unsafe { crate::node::oakengine_node_free(node) };
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
|
||||
// Export as .ove and as .otio.
|
||||
let ove = dir.join("out.ove");
|
||||
let otio = dir.join("out.otio");
|
||||
for path in [&ove, &otio] {
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_export(
|
||||
CString::new(uuid.clone()).unwrap().as_ptr(),
|
||||
CString::new(path.to_string_lossy().into_owned()).unwrap().as_ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_OK, "export {}", path.display());
|
||||
assert!(path.exists(), "{} exists", path.display());
|
||||
}
|
||||
let xml = std::fs::read_to_string(&ove).unwrap();
|
||||
assert!(xml.contains("<project"), "ove payload:\n{xml}");
|
||||
|
||||
// Import the .ove back as a new row and open it (single call with a
|
||||
// stack buffer — the import export has a side effect).
|
||||
let path_c = CString::new(ove.to_string_lossy().into_owned()).unwrap();
|
||||
let mut buf = [0 as std::ffi::c_char; 256];
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_import(path_c.as_ptr(), buf.as_mut_ptr(), 256)
|
||||
};
|
||||
assert!(rc > 0, "import rc={rc}");
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
let imported =
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned();
|
||||
assert!(!imported.is_empty(), "import reports the new uuid");
|
||||
assert_ne!(imported, uuid, "import assigns a fresh uuid");
|
||||
let project = open(&imported);
|
||||
// The projectname setting round-trips into the imported row's name.
|
||||
let row = list_row(&list_json(), &imported).expect("the imported row is listed");
|
||||
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Exchange"));
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
|
||||
// Importing a missing file fails.
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_import(
|
||||
CString::new(dir.join("nope.ove").to_string_lossy().into_owned())
|
||||
.unwrap()
|
||||
.as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
assert!(rc < 0, "a missing file does not import");
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// With the backend disabled the list is empty (not an error) and every
|
||||
/// mutating call fails with E_STATE.
|
||||
#[test]
|
||||
fn disabled_backend_degrades_gracefully() {
|
||||
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _off = common::storage_off_guard();
|
||||
|
||||
assert_eq!(list_json(), "[]", "no library configured reads as empty");
|
||||
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_create(
|
||||
c"Nope".as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_E_STATE);
|
||||
let rc = unsafe { crate::library::oakengine_library_delete(c"{x}".as_ptr()) };
|
||||
assert_eq!(rc, OAKENGINE_E_STATE);
|
||||
let rc = unsafe { crate::library::oakengine_library_export(c"{x}".as_ptr(), c"/tmp/x.ove".as_ptr()) };
|
||||
assert_eq!(rc, OAKENGINE_E_STATE);
|
||||
|
||||
let shell = unsafe { crate::node::oakengine_project_create() };
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_project_load_library(
|
||||
shell,
|
||||
c"{x}".as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_E_STATE);
|
||||
unsafe { crate::node::oakengine_project_free(shell) };
|
||||
}
|
||||
@@ -41,6 +41,7 @@ mod it_audio;
|
||||
mod it_codec;
|
||||
mod it_common;
|
||||
mod it_export;
|
||||
mod it_library;
|
||||
mod it_plugin;
|
||||
mod it_storage;
|
||||
mod it_task;
|
||||
|
||||
Reference in New Issue
Block a user