diff --git a/Cargo.lock b/Cargo.lock
index 53701141e..227b704e6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4744,6 +4744,7 @@ dependencies = [
"gpui_widgets",
"image",
"oakengine",
+ "serde_json",
"smallvec",
]
diff --git a/Cargo.toml b/Cargo.toml
index 54c1d60ab..f94c2a1b3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -82,6 +82,9 @@ smallvec = "1"
# Editable-text widget (used by the file / export dialogs' path fields, the
# same gpui-elements crate gpui_widgets builds on).
gpui_elements = { path = "gpui/crates/gpui_elements" }
+# The library list crosses the facade C ABI as JSON
+# (`oakengine_library_list`, M13 D4); Value-only parsing, no derive.
+serde_json = "1"
[build-dependencies]
# The real engine is NOT linked as an rlib: the app binds only the frozen
diff --git a/crates/oakaudio/src/manager.rs b/crates/oakaudio/src/manager.rs
index 8c7088fa5..68d9d1ee1 100644
--- a/crates/oakaudio/src/manager.rs
+++ b/crates/oakaudio/src/manager.rs
@@ -454,10 +454,43 @@ mod tests {
fn output_callback_consumes_pushed_samples() {
// Skip when the audio system cannot actually run a stream: open a
// silent stream and require at least one callback within 2 s. A
- // device existing is not enough — headless sessions report
- // is_active=true while delivering zero callbacks.
+ // device existing is not enough — headless sessions report the
+ // stream running while delivering zero callbacks.
use std::sync::atomic::AtomicI64 as A;
+ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
static PROBE: A = A::new(0);
+ PROBE.store(0, Ordering::Relaxed);
+ let can_play = (|| {
+ let host = cpal::default_host();
+ let device = host.default_output_device()?;
+ let config = device.default_output_config().ok()?;
+ let cb = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
+ PROBE.fetch_add(data.len() as i64, Ordering::Relaxed);
+ for s in data.iter_mut() {
+ *s = 0.0;
+ }
+ };
+ let stream = device
+ .build_output_stream(
+ config.config(),
+ cb,
+ |_| {},
+ None,
+ )
+ .ok()?;
+ stream.play().ok()?;
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
+ while std::time::Instant::now() < deadline && PROBE.load(Ordering::Relaxed) == 0 {
+ std::thread::sleep(std::time::Duration::from_millis(50));
+ }
+ let got = PROBE.load(Ordering::Relaxed) > 0;
+ drop(stream);
+ Some(got)
+ })();
+ if can_play != Some(true) {
+ eprintln!("audio session cannot deliver callbacks; skipping");
+ return;
+ }
DESTROYED.store(false, Ordering::SeqCst);
let manager = MANAGER.get_or_init(|| Mutex::new(ManagerInner::default()));
diff --git a/crates/oakengine/src/lib.rs b/crates/oakengine/src/lib.rs
index 5fdedd46c..0278d7109 100644
--- a/crates/oakengine/src/lib.rs
+++ b/crates/oakengine/src/lib.rs
@@ -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;
diff --git a/crates/oakengine/src/library.rs b/crates/oakengine/src/library.rs
new file mode 100644
index 000000000..e134ef4e1
--- /dev/null
+++ b/crates/oakengine/src/library.rs
@@ -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 .
+
+//! 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 {
+ 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=`).
+fn project_uri(uuid: &str) -> Result {
+ 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 {
+ 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 {
+ 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
+/// ` (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(())
+ })
+}
diff --git a/crates/oakengine/src/storage.rs b/crates/oakengine/src/storage.rs
index be5c8b45a..720d35dcc 100644
--- a/crates/oakengine/src/storage.rs
+++ b/crates/oakengine/src/storage.rs
@@ -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) `/library.db` (the same
//! location `FileFunctions::get_configuration_location` derives,
//! honoring `OAK_CONFIG_DIR` and portable mode).
+//! - `Storage/PgUrl` — the PostgreSQL connection string (plan D3), used
+//! when `Storage/Backend` is `"pg"`: `user:pass@host:5432/dbname`
+//! (libpq URL form; an optional `postgres://`/`postgresql://` scheme is
+//! accepted and stripped). The resolved library URI is
+//! `oakdb+pg://`. When `Backend = "pg"` but `PgUrl` is absent
+//! or empty, no library is configured (same graceful degradation).
//!
//! ## Snapshot thread and exit flush
//!
@@ -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 = 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 = /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 = /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 {
}
}
+/// The configured PostgreSQL connection string (`Storage/PgUrl`; empty
+/// value = not configured).
+fn configured_pg_url() -> Option {
+ let store = oakcommon::configstore::ConfigStore::instance();
+ match store.get(Some("Storage"), "PgUrl") {
+ Ok(u) if !u.trim().is_empty() => Some(u.trim().to_string()),
+ _ => None,
+ }
+}
+
+/// The `oakdb+pg://…` uri of the configured PostgreSQL library (None
+/// when `Storage/PgUrl` is absent). A `postgres://`/`postgresql://`
+/// scheme on the config value is stripped — the oakdb uri body is the
+/// bare connection string (`user:pass@host:5432/dbname`).
+fn pg_library_uri() -> Option {
+ let url = configured_pg_url()?;
+ let body = url
+ .strip_prefix("postgres://")
+ .or_else(|| url.strip_prefix("postgresql://"))
+ .unwrap_or(&url);
+ Some(format!("oakdb+pg://{body}"))
+}
+
/// The default library file: `/library.db`, where
/// the data directory is the standard per-user location
/// (`FileFunctions::get_configuration_location`: macOS Application
@@ -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 {
+/// 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 {
+ 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());
+ }
}
diff --git a/crates/oakengine/src/test_support/it_library.rs b/crates/oakengine/src/test_support/it_library.rs
new file mode 100644
index 000000000..b5985ae1c
--- /dev/null
+++ b/crates/oakengine/src/test_support/it_library.rs
@@ -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 .
+
+//! 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(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 {
+ serde_json::from_str::(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::from_str::(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 " (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(" 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) };
+}
diff --git a/crates/oakengine/src/test_support/mod.rs b/crates/oakengine/src/test_support/mod.rs
index da26b2297..da42b101e 100644
--- a/crates/oakengine/src/test_support/mod.rs
+++ b/crates/oakengine/src/test_support/mod.rs
@@ -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;
diff --git a/crates/oakstorage/src/backends/database/entities/mod.rs b/crates/oakstorage/src/backends/database/entities/mod.rs
index 7dee44324..562b5e8b2 100644
--- a/crates/oakstorage/src/backends/database/entities/mod.rs
+++ b/crates/oakstorage/src/backends/database/entities/mod.rs
@@ -18,10 +18,10 @@
//!
//! One module per entity: the `DeriveEntityModel` macro generates
//! `Entity`/`Column`/`Model`/`ActiveModel` in the defining module, so
-//! each table gets its own scope. The same definitions serve SQLite
-//! today and PostgreSQL (D3) — the column types (`i64`, `String`,
-//! `Option`, `NaiveDateTime`) map to BIGINT/TEXT/TIMESTAMP on
-//! both.
+//! each table gets its own scope. The same definitions serve SQLite and
+//! PostgreSQL — the column types (`i64`, `String`, `Option`,
+//! `NaiveDateTime`) map to BIGINT/TEXT/TIMESTAMP on both, so the shared
+//! save/replay logic never branches on the dialect.
pub mod journal;
pub mod project;
diff --git a/crates/oakstorage/src/backends/database/migration.rs b/crates/oakstorage/src/backends/database/migration.rs
index 7d27bd09b..3e3bcf1f8 100644
--- a/crates/oakstorage/src/backends/database/migration.rs
+++ b/crates/oakstorage/src/backends/database/migration.rs
@@ -60,22 +60,53 @@ const SQLITE_DDL: &[&str] = &[
)",
];
-/// Apply the schema (idempotent; safe to run on every connection open).
-///
-/// PostgreSQL (D3) gets its own `BIGSERIAL` DDL — same shape, currently
-/// unreachable because the backend rejects `oakdb+pg://` before any
-/// connection is opened.
+/// PostgreSQL DDL for the four tables — the same shape with the PG
+/// dialect differences (plan D3: BIGSERIAL for the surrogate key,
+/// BIGINT for the FKs; `TEXT` everywhere, matching the SQLite TEXT
+/// decision — no BYTEA). `CREATE TABLE IF NOT EXISTS` makes it
+/// idempotent, exactly like the SQLite side.
+const POSTGRES_DDL: &[&str] = &[
+ "CREATE TABLE IF NOT EXISTS projects (
+ id BIGSERIAL PRIMARY KEY,
+ uuid TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ schema_ver INTEGER NOT NULL,
+ created_at TIMESTAMP NOT NULL,
+ modified_at TIMESTAMP NOT NULL,
+ command_seq BIGINT NOT NULL DEFAULT 0
+ )",
+ "CREATE TABLE IF NOT EXISTS settings (
+ project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ PRIMARY KEY (project_id, key)
+ )",
+ "CREATE TABLE IF NOT EXISTS snapshots (
+ project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+ command_seq BIGINT NOT NULL,
+ payload TEXT NOT NULL,
+ written_at TIMESTAMP NOT NULL,
+ PRIMARY KEY (project_id, command_seq)
+ )",
+ "CREATE TABLE IF NOT EXISTS journal (
+ project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+ seq BIGINT NOT NULL,
+ node_identity BIGINT NOT NULL,
+ kind TEXT NOT NULL,
+ old_xml TEXT,
+ new_xml TEXT,
+ at TIMESTAMP NOT NULL,
+ PRIMARY KEY (project_id, seq, node_identity)
+ )",
+];
+
+/// Apply the schema for the connection's dialect (idempotent; safe to
+/// run on every connection open).
pub async fn migrate(db: &impl ConnectionTrait) -> crate::error::Result<()> {
let backend = db.get_database_backend();
let statements: &[&str] = match backend {
DatabaseBackend::Sqlite => SQLITE_DDL,
- // D3: `BIGSERIAL PRIMARY KEY` for projects.id, `BIGINT` for the
- // FKs — the rest of the column set is identical.
- DatabaseBackend::Postgres => {
- return Err(crate::error::Error::Failed(
- "oakdb+pg is a D3 milestone; the PostgreSQL schema is not wired yet".to_string(),
- ));
- }
+ DatabaseBackend::Postgres => POSTGRES_DDL,
other => {
return Err(crate::error::Error::Failed(format!(
"unsupported database backend {other:?}"
diff --git a/crates/oakstorage/src/backends/database/mod.rs b/crates/oakstorage/src/backends/database/mod.rs
index ca023dc62..f02ec3a45 100644
--- a/crates/oakstorage/src/backends/database/mod.rs
+++ b/crates/oakstorage/src/backends/database/mod.rs
@@ -14,16 +14,20 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-//! The database backend (plan M13 §1/§2), SQLite first.
+//! The database backend (plan M13 §1/§2): SQLite and PostgreSQL share
+//! one code path — the same sea-orm entities, diff/save/replay logic and
+//! migrations, with the dialect differences (BIGSERIAL vs AUTOINCREMENT,
+//! `IF NOT EXISTS` DDL) converged in the connection and migration layer.
//!
//! URI forms:
//! - `oakdb+sqlite:///absolute/path.db[?project=]` — local file
//! database; the optional `project` query selects the library row to
//! load (default: the most recently modified project).
-//! - `oakdb+pg://user:pass@host:5432/db[?project=]` — PostgreSQL
-//! (D3). The target is parsed into [`DbTarget::Pg`] today, but every
-//! operation rejects it with [`crate::error::Error::NoBackend`] until
-//! the D3 client lands.
+//! - `oakdb+pg://user:pass@host:5432/db[?project=]` — PostgreSQL;
+//! the body is the libpq connection string without a scheme. A `?…`
+//! part is the `?project=` selector only when it carries a `project=`
+//! key — a bare query (e.g. `?sslmode=disable`) belongs to the
+//! connection string and is passed through untouched.
//!
//! The async sea-orm API is driven by a private current-thread tokio
//! runtime so the public surface stays synchronous (plan §6: "后端内嵌
@@ -104,9 +108,11 @@ pub(crate) enum DbTarget {
/// `?project=` uuid; `None` = most recently modified row.
project: Option,
},
- /// PostgreSQL (parsed for D3; rejected until then).
+ /// PostgreSQL server.
Pg {
- /// Connection string (`user:pass@host:port/db`).
+ /// Connection string body (`user:pass@host:port/db`, without a
+ /// scheme; may carry its own `?param=…` query when it does not
+ /// contain a `project=` key).
conn: String,
/// `?project=` uuid; `None` = most recently modified row.
project: Option,
@@ -114,20 +120,6 @@ pub(crate) enum DbTarget {
}
impl DbTarget {
- /// True for the PostgreSQL variant (all ops return E_NO_BACKEND
- /// until D3).
- fn is_pg(&self) -> bool {
- matches!(self, DbTarget::Pg { .. })
- }
-
- /// The SQLite path (`None` for PG).
- fn sqlite_path(&self) -> Option<&str> {
- match self {
- DbTarget::Sqlite { path, .. } => Some(path),
- DbTarget::Pg { .. } => None,
- }
- }
-
/// The `?project=` selector.
fn project(&self) -> Option<&str> {
match self {
@@ -138,12 +130,23 @@ impl DbTarget {
}
}
+/// The connection-cache key of a parsed target (the database location —
+/// an absolute SQLite path or a PostgreSQL connection string body).
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+enum DbKey {
+ /// SQLite file database at an absolute path.
+ Sqlite(String),
+ /// PostgreSQL server (connection string body, see [`DbTarget::Pg`]).
+ Pg(String),
+}
+
/// Parse a storage URI into a [`DbTarget`].
///
/// `oakdb+sqlite://` requires an absolute path (E_INVALID otherwise);
/// the body may carry `?project=` (no percent-decoding — uuid
-/// values contain only hex/braces/hyphens). Unknown `oakdb` variants
-/// are E_NO_BACKEND.
+/// values contain only hex/braces/hyphens). `oakdb+pg://` requires a
+/// non-empty, parseable connection string (E_INVALID otherwise). Unknown
+/// `oakdb` variants are E_NO_BACKEND.
pub(crate) fn parse_target(uri: &StorageUri) -> Result {
match uri.scheme.as_str() {
"oakdb+sqlite" => {
@@ -157,16 +160,37 @@ pub(crate) fn parse_target(uri: &StorageUri) -> Result {
})
}
"oakdb+pg" => {
- let (conn, query) = split_query(&uri.body);
- Ok(DbTarget::Pg {
- conn: conn.to_string(),
- project: query_param(query, "project"),
- })
+ let (base, query) = split_query(&uri.body);
+ // A `?…` part is the `?project=` selector only when it carries
+ // a `project=` key; otherwise (e.g. `?sslmode=disable`) it
+ // belongs to the connection string and the whole body passes
+ // through.
+ let project = match query {
+ Some(q) if query_param(Some(q), "project").is_some() => {
+ query_param(Some(q), "project")
+ }
+ _ => None,
+ };
+ let conn = match &project {
+ Some(_) => base.to_string(),
+ None => uri.body.clone(),
+ };
+ if conn.is_empty() || !valid_pg_conn(&conn) {
+ return Err(Error::Invalid);
+ }
+ Ok(DbTarget::Pg { conn, project })
}
_ => Err(Error::NoBackend),
}
}
+/// Whether `conn` parses as a PostgreSQL connection string (the libpq
+/// URL form, `user:pass@host:port/db[?params]`, with a scheme prepended).
+fn valid_pg_conn(conn: &str) -> bool {
+ use std::str::FromStr;
+ sea_orm::sqlx::postgres::PgConnectOptions::from_str(&format!("postgres://{conn}")).is_ok()
+}
+
/// Split `body` at the first `?` (the query part, if any).
fn split_query(body: &str) -> (&str, Option<&str>) {
match body.split_once('?') {
@@ -310,8 +334,8 @@ pub struct DatabaseBackend {
runtime: OnceLock,
/// Serializes all database operations of this backend.
op_lock: Mutex<()>,
- /// Open connections by database path.
- connections: Mutex>,
+ /// Open connections by database location (path or PG conn string).
+ connections: Mutex>,
}
impl DatabaseBackend {
@@ -332,10 +356,11 @@ impl DatabaseBackend {
.expect("tokio current_thread runtime")
}
- /// Run `f` against the database at `key` (an absolute SQLite path).
- /// The op lock is held for the whole call; the connection is opened
- /// and migrated on first use, then cached.
- fn run(&self, key: &str, f: F) -> Result
+ /// Run `f` against the database at `key` (an absolute SQLite path or a
+ /// PostgreSQL connection string). The op lock is held for the whole
+ /// call; the connection is opened and migrated on first use, then
+ /// cached.
+ fn run(&self, key: DbKey, f: F) -> Result
where
F: FnOnce(DatabaseConnection) -> Fut,
Fut: Future