lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 2fbb6ebb98595eed375d3606cd26202d75ab715a
parent 2e7b982328549640fc75835906c705af8da5e70f
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:24:15 +0000

core(storage): add SQLite migration foundation

- open bundled SQLite with safe connection policy
- apply an embedded idempotent Refinery schema migration
- attempt owner-only permissions for file-backed databases
- document the compatible Rusqlite 0.39 dependency pin

Diffstat:
Mcrates/studio_storage/Cargo.toml | 8++++++++
Acrates/studio_storage/migrations/V1__initialize.sql | 6++++++
Acrates/studio_storage/src/db.rs | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_storage/src/lib.rs | 10+++-------
Mimports/studio_workspace/Cargo.toml | 2++
5 files changed, 180 insertions(+), 7 deletions(-)

diff --git a/crates/studio_storage/Cargo.toml b/crates/studio_storage/Cargo.toml @@ -6,5 +6,13 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[dependencies] +radroots-studio-domain = { path = "../domain" } +refinery.workspace = true +rusqlite.workspace = true + +[dev-dependencies] +tempfile = "=3.23.0" + [lints] workspace = true diff --git a/crates/studio_storage/migrations/V1__initialize.sql b/crates/studio_storage/migrations/V1__initialize.sql @@ -0,0 +1,6 @@ +CREATE TABLE application_schema ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + schema_version INTEGER NOT NULL CHECK (schema_version >= 1) +); + +INSERT INTO application_schema (singleton, schema_version) VALUES (1, 1); diff --git a/crates/studio_storage/src/db.rs b/crates/studio_storage/src/db.rs @@ -0,0 +1,161 @@ +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; + +use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; +use refinery::embed_migrations; +use rusqlite::{Connection, OpenFlags}; + +mod migrations { + use super::embed_migrations; + + embed_migrations!("migrations"); +} + +pub struct Database { + connection: Mutex<Connection>, +} + +impl Database { + /// Opens, configures, and migrates a file-backed `SQLite` database. + /// + /// # Errors + /// + /// Returns a safe storage error when the file, connection configuration, + /// permission update, or migration cannot complete. + pub fn open(path: &Path) -> Result<Self, SafeError> { + let flags = OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX; + let mut connection = + Connection::open_with_flags(path, flags).map_err(|_| storage_error())?; + configure(&connection)?; + migrations::migrations::runner() + .run(&mut connection) + .map_err(|_| corrupt_storage_error())?; + restrict_file_permissions(path)?; + Ok(Self { + connection: Mutex::new(connection), + }) + } + + /// Opens and migrates an isolated in-memory `SQLite` database. + /// + /// # Errors + /// + /// Returns a safe storage error when configuration or migration fails. + pub fn in_memory() -> Result<Self, SafeError> { + let mut connection = Connection::open_in_memory().map_err(|_| storage_error())?; + configure(&connection)?; + migrations::migrations::runner() + .run(&mut connection) + .map_err(|_| corrupt_storage_error())?; + Ok(Self { + connection: Mutex::new(connection), + }) + } + + /// Returns the highest successfully applied migration version. + /// + /// # Errors + /// + /// Returns a safe storage error when migration history cannot be read. + pub fn schema_version(&self) -> Result<u32, SafeError> { + self.connection() + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .map_err(|_| corrupt_storage_error()) + } + + pub(crate) fn connection(&self) -> MutexGuard<'_, Connection> { + self.connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +fn configure(connection: &Connection) -> Result<(), SafeError> { + connection + .pragma_update(None, "foreign_keys", "ON") + .and_then(|()| connection.pragma_update(None, "journal_mode", "WAL")) + .and_then(|()| connection.busy_timeout(Duration::from_secs(5))) + .map_err(|_| storage_error()) +} + +#[cfg(unix)] +fn restrict_file_permissions(path: &Path) -> Result<(), SafeError> { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| storage_error()) +} + +#[cfg(not(unix))] +fn restrict_file_permissions(_path: &Path) -> Result<(), SafeError> { + Ok(()) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The application database is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The application database could not be read."), + ) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::Database; + + #[test] + fn migration_opens_fresh_memory_database_once() { + let database = Database::in_memory().expect("open memory database"); + + assert_eq!(database.schema_version().expect("schema version"), 1); + assert_eq!(database.schema_version().expect("repeat schema version"), 1); + } + + #[test] + fn migration_persists_schema_version_across_file_reopen() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + + { + let database = Database::open(&path).expect("open file database"); + assert_eq!(database.schema_version().expect("schema version"), 1); + } + let reopened = Database::open(&path).expect("reopen file database"); + assert_eq!(reopened.schema_version().expect("schema version"), 1); + assert!(fs::metadata(path).expect("database metadata").len() > 0); + } + + #[cfg(unix)] + #[test] + fn migration_attempts_owner_only_database_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let _database = Database::open(&path).expect("open file database"); + let mode = fs::metadata(path) + .expect("database metadata") + .permissions() + .mode() + & 0o777; + + assert_eq!(mode, 0o600); + } +} diff --git a/crates/studio_storage/src/lib.rs b/crates/studio_storage/src/lib.rs @@ -1,9 +1,5 @@ #![doc = "Radroots Studio persistence adapters."] -#[cfg(test)] -mod tests { - #[test] - fn crate_is_available_to_the_workspace() { - assert_eq!(env!("CARGO_PKG_NAME"), "radroots-studio-storage"); - } -} +pub mod db; + +pub use db::Database; diff --git a/imports/studio_workspace/Cargo.toml b/imports/studio_workspace/Cargo.toml @@ -24,6 +24,8 @@ all = "deny" pedantic = "deny" [workspace.dependencies] +refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] } +rusqlite = { version = "=0.39.0", features = ["bundled"] } secrecy = "=0.10.3" url = "=2.5.8" zeroize = "=1.9.0"