max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
9 files changed,
+1285 insertions,
-465 deletions
| @@ -16,10 +16,10 @@ | |||
| 16 | 16 | //! Exemptions, by construction: | |
| 17 | 17 | //! - `DELETE FROM samples` (a write, not a read). | |
| 18 | 18 | //! - The `CREATE VIEW live_samples` definition itself. | |
| 19 | - | //! - `#[cfg(test)]` modules (they legitimately assert raw table state). This | |
| 20 | - | //! assumes the repo convention of test modules at file end, and covers both | |
| 21 | - | //! forms that convention takes: an inline module, and a `tests.rs` sibling | |
| 22 | - | //! declared by `#[cfg(test)] mod tests;`. | |
| 19 | + | //! - `#[cfg(test)]` modules (they legitimately assert raw table state), in all | |
| 20 | + | //! three forms the convention takes: an inline module at file end, a | |
| 21 | + | //! `tests.rs` sibling, and a `tests/` directory, both declared by | |
| 22 | + | //! `#[cfg(test)] mod tests;`. | |
| 23 | 23 | ||
| 24 | 24 | use std::fs; | |
| 25 | 25 | use std::path::{Path, PathBuf}; | |
| @@ -57,10 +57,14 @@ | |||
| 57 | 57 | if path.extension().and_then(|e| e.to_str()) != Some("rs") { | |
| 58 | 58 | continue; | |
| 59 | 59 | } | |
| 60 | - | // A module's tests may sit inline or in a `tests.rs` beside it. Both | |
| 61 | - | // read raw rows legitimately, so the sibling is skipped here for the | |
| 62 | - | // same reason the inline module is cut off below. | |
| 63 | - | if path.file_name().and_then(|n| n.to_str()) == Some("tests.rs") { | |
| 60 | + | // A module's tests may sit inline, in a `tests.rs` beside it, or in a | |
| 61 | + | // `tests/` directory of their own. All three read raw rows | |
| 62 | + | // legitimately, so the file and the directory are skipped here for | |
| 63 | + | // the same reason the inline module is cut off below. | |
| 64 | + | if path | |
| 65 | + | .components() | |
| 66 | + | .any(|c| c.as_os_str() == "tests" || c.as_os_str() == "tests.rs") | |
| 67 | + | { | |
| 64 | 68 | continue; | |
| 65 | 69 | } | |
| 66 | 70 | let text = fs::read_to_string(&path).unwrap(); |
| @@ -1,55 +1,12 @@ | |||
| 1 | - | //! SQLite database wrapper with versioned migrations for samples, VFS, tags, and analysis tables. | |
| 1 | + | //! The ordered migration log: every schema version this build can produce. | |
| 2 | + | //! | |
| 3 | + | //! Extracted from the former `db.rs`; the parent re-exports [`SCHEMA_VERSION`]. | |
| 2 | 4 | ||
| 3 | - | use std::path::Path; | |
| 4 | - | ||
| 5 | - | use rusqlite::{Connection, functions::FunctionFlags}; | |
| 5 | + | use rusqlite::Connection; | |
| 6 | + | use rusqlite::functions::FunctionFlags; | |
| 6 | 7 | use sha2::{Digest, Sha256}; | |
| 7 | - | use synckit_config::{ConfigError, ConfigStore}; | |
| 8 | - | use thiserror::Error; | |
| 9 | - | use tracing::instrument; | |
| 10 | 8 | ||
| 11 | - | use crate::config_key::{CONFIG, ConfigKey}; | |
| 12 | - | ||
| 13 | - | #[derive(Error, Debug)] | |
| 14 | - | pub enum DbError { | |
| 15 | - | #[error("SQLite error: {0}")] | |
| 16 | - | Sqlite(#[from] rusqlite::Error), | |
| 17 | - | ||
| 18 | - | /// The vault's schema is ahead of what this build knows how to read, so | |
| 19 | - | /// opening it would mean querying a shape this code has never seen. See | |
| 20 | - | /// [`Database::migrate`] for why that is refused rather than tolerated. | |
| 21 | - | #[error( | |
| 22 | - | "this vault was written by a newer version of audiofiles \ | |
| 23 | - | (vault schema {found}, this build understands {supported}). \ | |
| 24 | - | Update audiofiles to open it." | |
| 25 | - | )] | |
| 26 | - | VaultTooNew { | |
| 27 | - | /// `PRAGMA user_version` read off the vault. | |
| 28 | - | found: i32, | |
| 29 | - | /// The highest version this build can produce, [`SCHEMA_VERSION`]. | |
| 30 | - | supported: i32, | |
| 31 | - | }, | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | /// The config store wraps rusqlite; its one failure mode is the database, so it | |
| 35 | - | /// folds into [`DbError::Sqlite`] rather than carrying a second SQL error type. | |
| 36 | - | impl From<ConfigError> for DbError { | |
| 37 | - | fn from(error: ConfigError) -> Self { | |
| 38 | - | match error { | |
| 39 | - | ConfigError::Db(error) => DbError::Sqlite(error), | |
| 40 | - | } | |
| 41 | - | } | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | /// Core database wrapper. All access is synchronous, no async runtime needed, | |
| 45 | - | /// safe to use from a CLAP plugin host thread. | |
| 46 | - | pub struct Database { | |
| 47 | - | conn: Connection, | |
| 48 | - | /// The shared config store over `user_config`. Attached, not opened: the | |
| 49 | - | /// table and its sync triggers are stood up by this crate's migrations, so | |
| 50 | - | /// the store drives the existing table rather than creating its own. | |
| 51 | - | config: ConfigStore, | |
| 52 | - | } | |
| 9 | + | use super::DbError; | |
| 53 | 10 | ||
| 54 | 11 | const MIGRATION_001: &str = r" | |
| 55 | 12 | -- Sample storage and metadata | |
| @@ -1632,7 +1589,7 @@ | |||
| 1632 | 1589 | END; | |
| 1633 | 1590 | "; | |
| 1634 | 1591 | ||
| 1635 | - | const MIGRATION_034: &str = r" | |
| 1592 | + | pub(super) const MIGRATION_034: &str = r" | |
| 1636 | 1593 | -- Normalise musical_key to the '<note> major' / '<note> minor' spelling. | |
| 1637 | 1594 | -- | |
| 1638 | 1595 | -- detect_bpm_key stored stratum_dsp's compact DJ-style name ('Am', 'C#m', 'C') | |
| @@ -1673,7 +1630,7 @@ | |||
| 1673 | 1630 | UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'; | |
| 1674 | 1631 | "; | |
| 1675 | 1632 | ||
| 1676 | - | const MIGRATION_035: &str = r" | |
| 1633 | + | pub(super) const MIGRATION_035: &str = r" | |
| 1677 | 1634 | -- Rewrite key.* tags written from the pre-M034 key spelling. | |
| 1678 | 1635 | -- | |
| 1679 | 1636 | -- M034 fixed audio_analysis.musical_key, but tags are user-accepted copies of | |
| @@ -1874,7 +1831,7 @@ | |||
| 1874 | 1831 | /// raw sample SHA-256s) on the wire. The salt is a per-user random nonce | |
| 1875 | 1832 | /// stored in `sync_state` and never synced; without it, even a global rainbow | |
| 1876 | 1833 | /// table over common tag strings would deanonymise users. | |
| 1877 | - | fn register_hash_row_id(conn: &Connection) -> Result<(), DbError> { | |
| 1834 | + | pub(super) fn register_hash_row_id(conn: &Connection) -> Result<(), DbError> { | |
| 1878 | 1835 | conn.create_scalar_function( | |
| 1879 | 1836 | "hash_row_id", | |
| 1880 | 1837 | 2, | |
| @@ -1906,7 +1863,7 @@ | |||
| 1906 | 1863 | /// can be derived from it: the guard against opening a newer vault and the | |
| 1907 | 1864 | /// migration runner have to agree on one number, and deriving it is how they | |
| 1908 | 1865 | /// cannot drift. | |
| 1909 | - | const MIGRATIONS: &[&str] = &[ | |
| 1866 | + | pub(super) const MIGRATIONS: &[&str] = &[ | |
| 1910 | 1867 | MIGRATION_001, | |
| 1911 | 1868 | MIGRATION_002, | |
| 1912 | 1869 | MIGRATION_003, | |
| @@ -1953,1379 +1910,3 @@ | |||
| 1953 | 1910 | /// A vault reporting more than this was written by a newer audiofiles and is | |
| 1954 | 1911 | /// refused; see [`DbError::VaultTooNew`]. | |
| 1955 | 1912 | pub const SCHEMA_VERSION: i32 = MIGRATIONS.len() as i32; | |
| 1956 | - | ||
| 1957 | - | /// Compile-time proof that a write transaction is open on the connection. | |
| 1958 | - | /// | |
| 1959 | - | /// Constructed only by [`Database::transaction`], and required by the row-write | |
| 1960 | - | /// functions that must run inside a batched transaction rather than as | |
| 1961 | - | /// standalone autocommits in a loop (e.g. [`crate::analysis::save_analysis`], | |
| 1962 | - | /// [`crate::rules::apply_tag_sourced`]). Holding a `&Tx` is the only way to call | |
| 1963 | - | /// those functions, so "bulk loop of per-row autocommits", the chronic | |
| 1964 | - | /// per-row-commit pattern, does not compile: there is no `Tx` to pass except | |
| 1965 | - | /// inside a `transaction` closure that already batches the whole loop. | |
| 1966 | - | /// | |
| 1967 | - | /// The token is a zero-sized marker; the SQL still runs on `db.conn()`, which | |
| 1968 | - | /// participates in the ambient `BEGIN IMMEDIATE` opened by `transaction`. | |
| 1969 | - | pub struct Tx(()); | |
| 1970 | - | ||
| 1971 | - | impl Database { | |
| 1972 | - | /// Open (or create) the database at the given path and run migrations. | |
| 1973 | - | #[instrument(skip_all)] | |
| 1974 | - | pub fn open(path: impl AsRef<Path>) -> Result<Self, DbError> { | |
| 1975 | - | let conn = Connection::open(path)?; | |
| 1976 | - | conn.execute_batch( | |
| 1977 | - | // WAL + synchronous=NORMAL is the standard durable-but-fast pairing: | |
| 1978 | - | // commits no longer fsync individually (only at checkpoint), which is | |
| 1979 | - | // what made the import path ~2 fsyncs/file. NORMAL under WAL can lose | |
| 1980 | - | // only the last few committed transactions on power loss, never | |
| 1981 | - | // corruption, acceptable for a local sample library. The cache / | |
| 1982 | - | // mmap / temp_store pragmas cut page churn on large scans and the | |
| 1983 | - | // import write batch. | |
| 1984 | - | "PRAGMA journal_mode=WAL;\ | |
| 1985 | - | PRAGMA synchronous=NORMAL;\ | |
| 1986 | - | PRAGMA foreign_keys=ON;\ | |
| 1987 | - | PRAGMA busy_timeout=5000;\ | |
| 1988 | - | PRAGMA cache_size=-16000;\ | |
| 1989 | - | PRAGMA mmap_size=268435456;\ | |
| 1990 | - | PRAGMA temp_store=MEMORY;\ | |
| 1991 | - | PRAGMA wal_checkpoint(TRUNCATE);", | |
| 1992 | - | )?; | |
| 1993 | - | register_hash_row_id(&conn)?; | |
| 1994 | - | let mut db = Self { | |
| 1995 | - | conn, | |
| 1996 | - | config: ConfigStore::attached(&CONFIG), | |
| 1997 | - | }; | |
| 1998 | - | db.migrate()?; | |
| 1999 | - | db.seed_config_key_policy()?; | |
| 2000 | - | Ok(db) | |
| 2001 | - | } | |
| 2002 | - | ||
| 2003 | - | /// Flush the WAL back into the main database file and remove the -shm file. | |
| 2004 | - | /// | |
| 2005 | - | /// Call after large write batches (e.g. import completion) to keep the | |
| 2006 | - | /// WAL index fresh and avoid stale memory-mapped state on macOS. | |
| 2007 | - | pub fn wal_checkpoint(&self) -> Result<(), DbError> { | |
| 2008 | - | self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?; | |
| 2009 | - | Ok(()) | |
| 2010 | - | } | |
| 2011 | - | ||
| 2012 | - | /// Open an in-memory database (for tests). | |
| 2013 | - | #[instrument(skip_all)] | |
| 2014 | - | pub fn open_in_memory() -> Result<Self, DbError> { | |
| 2015 | - | let conn = Connection::open_in_memory()?; | |
| 2016 | - | conn.execute_batch("PRAGMA foreign_keys=ON;")?; | |
| 2017 | - | register_hash_row_id(&conn)?; | |
| 2018 | - | let mut db = Self { | |
| 2019 | - | conn, | |
| 2020 | - | config: ConfigStore::attached(&CONFIG), | |
| 2021 | - | }; | |
| 2022 | - | db.migrate()?; | |
| 2023 | - | db.seed_config_key_policy()?; | |
| 2024 | - | Ok(db) | |
| 2025 | - | } | |
| 2026 | - | ||
| 2027 | - | /// Seed `config_key_policy` from the [`CONFIG`] spec, the single source of | |
| 2028 | - | /// truth for which `user_config` keys may sync. Run at every open so the SQL | |
| 2029 | - | /// export triggers always reflect the current registry: adding a key in Rust | |
| 2030 | - | /// is enough, no migration needed. Idempotent (clear + reinsert the closed | |
| 2031 | - | /// set); the rows are the spec's [`policy_rows`](synckit_config::ConfigSpec::policy_rows), | |
| 2032 | - | /// so an undeclared key has no row and the export filter cannot admit it. | |
| 2033 | - | fn seed_config_key_policy(&self) -> Result<(), DbError> { | |
| 2034 | - | self.conn.execute("DELETE FROM config_key_policy", [])?; | |
| 2035 | - | let mut stmt = self | |
| 2036 | - | .conn | |
| 2037 | - | .prepare("INSERT INTO config_key_policy (key, replicated) VALUES (?1, ?2)")?; | |
| 2038 | - | for row in CONFIG.policy_rows() { | |
| 2039 | - | stmt.execute(rusqlite::params![row.key, i64::from(row.replicated)])?; | |
| 2040 | - | } | |
| 2041 | - | Ok(()) | |
| 2042 | - | } | |
| 2043 | - | ||
| 2044 | - | /// Read a `user_config` value through the shared config store, `None` when | |
| 2045 | - | /// unset. The store is attached to the `user_config` table this crate's | |
| 2046 | - | /// migrations own. | |
| 2047 | - | pub fn get_config(&self, key: ConfigKey) -> Result<Option<String>, DbError> { | |
| 2048 | - | Ok(self.config.get(&self.conn, key.as_str())?) | |
| 2049 | - | } | |
| 2050 | - | ||
| 2051 | - | /// Write a `user_config` value through the shared config store. | |
| 2052 | - | /// | |
| 2053 | - | /// An upsert on the key: writing a key that already exists fires the table's | |
| 2054 | - | /// UPDATE trigger once, not a DELETE followed by an INSERT the way the old | |
| 2055 | - | /// `INSERT OR REPLACE` did, so a synced key enqueues one changelog row per | |
| 2056 | - | /// edit rather than a spurious delete-then-insert pair. | |
| 2057 | - | pub fn set_config(&self, key: ConfigKey, value: &str) -> Result<(), DbError> { | |
| 2058 | - | self.config.set(&self.conn, key.as_str(), value)?; | |
| 2059 | - | Ok(()) | |
| 2060 | - | } | |
| 2061 | - | ||
| 2062 | - | /// Remove a `user_config` key through the shared config store. Absent | |
| 2063 | - | /// already is not an error. | |
| 2064 | - | pub fn delete_config(&self, key: ConfigKey) -> Result<(), DbError> { | |
| 2065 | - | self.config.unset(&self.conn, key.as_str())?; | |
| 2066 | - | Ok(()) | |
| 2067 | - | } | |
| 2068 | - | ||
| 2069 | - | /// Apply pending migrations using PRAGMA user_version as the version tracker. | |
| 2070 | - | /// | |
| 2071 | - | /// Each migration step runs inside a transaction so the schema change and | |
| 2072 | - | /// version bump are atomic, a crash between the two can no longer leave the | |
| 2073 | - | /// database in an inconsistent state. | |
| 2074 | - | /// | |
| 2075 | - | /// The runner is bounded on both sides. Forward is the ordinary case. | |
| 2076 | - | /// Backward is not possible and must not be attempted silently: a vault | |
| 2077 | - | /// carrying a version this build has never heard of was written by a newer | |
| 2078 | - | /// audiofiles, and every query past this point assumes a schema this code | |
| 2079 | - | /// has seen. Applying nothing and returning `Ok` reads as success and then | |
| 2080 | - | /// queries a shape it does not understand, survivable for an added column | |
| 2081 | - | /// and silent data loss for a dropped one, a rename, or a NOT NULL the | |
| 2082 | - | /// older code never populates. | |
| 2083 | - | /// | |
| 2084 | - | /// Nothing exotic is needed to reach it: a rollback to an older release | |
| 2085 | - | /// after a bad update, a restored backup, or one vault opened from two | |
| 2086 | - | /// machines running different versions, which is the shape the | |
| 2087 | - | /// vault-per-detachable-drive workflow is made of. | |
| 2088 | - | #[instrument(skip_all)] | |
| 2089 | - | fn migrate(&mut self) -> Result<(), DbError> { | |
| 2090 | - | let version: i32 = self | |
| 2091 | - | .conn | |
| 2092 | - | .query_row("PRAGMA user_version", [], |row| row.get(0))?; | |
| 2093 | - | ||
| 2094 | - | if version > SCHEMA_VERSION { | |
| 2095 | - | return Err(DbError::VaultTooNew { | |
| 2096 | - | found: version, | |
| 2097 | - | supported: SCHEMA_VERSION, | |
| 2098 | - | }); | |
| 2099 | - | } | |
| 2100 | - | ||
| 2101 | - | for (i, sql) in MIGRATIONS.iter().enumerate() { | |
| 2102 | - | let target = (i + 1) as i32; | |
| 2103 | - | if version < target { | |
| 2104 | - | let batch = format!("BEGIN;\n{sql}\nPRAGMA user_version = {target};\nCOMMIT;"); | |
| 2105 | - | match self.conn.execute_batch(&batch) { | |
| 2106 | - | Ok(()) => {} | |
| 2107 | - | Err(e) if e.to_string().contains("duplicate column") => { | |
| 2108 | - | // Recovery path: a prior partial migration committed | |
| 2109 | - | // some ALTERs before crashing. Re-run the migration in | |
| 2110 | - | // pieces, tolerating "duplicate column" on ALTERs and | |
| 2111 | - | // "already exists" on CREATEs (both mean: the prior | |
| 2112 | - | // partial run got there already; the desired final | |
| 2113 | - | // state is still reachable). Any OTHER error here is | |
| 2114 | - | // a real failure, we roll back and surface it, | |
| 2115 | - | // because silently bumping user_version on a partially | |
| 2116 | - | // applied schema is the worst possible outcome. | |
| 2117 | - | let _ = self.conn.execute_batch("ROLLBACK"); | |
| 2118 | - | self.conn.execute_batch("BEGIN")?; | |
| 2119 | - | ||
| 2120 | - | // ALTER TABLEs first, individually, tolerating duplicates. | |
| 2121 | - | for line in sql.lines() { | |
| 2122 | - | let trimmed = line.trim(); | |
| 2123 | - | if trimmed.to_uppercase().starts_with("ALTER TABLE") | |
| 2124 | - | && trimmed.to_uppercase().contains("ADD COLUMN") | |
| 2125 | - | && let Err(alter_err) = self.conn.execute_batch(trimmed) | |
| 2126 | - | && !alter_err.to_string().contains("duplicate column") | |
| 2127 | - | { | |
| 2128 | - | let _ = self.conn.execute_batch("ROLLBACK"); | |
| 2129 | - | return Err(DbError::Sqlite(alter_err)); | |
| 2130 | - | } | |
| 2131 | - | } | |
| 2132 | - | ||
| 2133 | - | // Non-ALTER statements (CREATE TABLE / INDEX / | |
| 2134 | - | // TRIGGER, DROP IF EXISTS, INSERT OR IGNORE, plain | |
| 2135 | - | // INSERT / UPDATE / DELETE). After M018, every | |
| 2136 | - | // migration from M003 onward is replay-safe by | |
| 2137 | - | // construction (verified by the | |
| 2138 | - | // migration_replay_from_version_two_against_full_schema | |
| 2139 | - | // regression test), so this batch should succeed | |
| 2140 | - | // cleanly even against a populated schema. "already | |
| 2141 | - | // exists" stays tolerable as a belt-and-braces guard | |
| 2142 | - | // for pre-idempotent migration bodies. Anything else | |
| 2143 | - | // is a real failure, fail fast, don't bump. | |
| 2144 | - | let non_alter: String = sql | |
| 2145 | - | .lines() | |
| 2146 | - | .filter(|l| { | |
| 2147 | - | let t = l.trim().to_uppercase(); | |
| 2148 | - | !(t.starts_with("ALTER TABLE") && t.contains("ADD COLUMN")) | |
| 2149 | - | }) | |
| 2150 | - | .collect::<Vec<_>>() | |
| 2151 | - | .join("\n"); | |
| 2152 | - | if !non_alter.trim().is_empty() | |
| 2153 | - | && let Err(e) = self.conn.execute_batch(&non_alter) | |
| 2154 | - | && !e.to_string().contains("already exists") | |
| 2155 | - | { | |
| 2156 | - | let _ = self.conn.execute_batch("ROLLBACK"); | |
| 2157 | - | return Err(DbError::Sqlite(e)); | |
| 2158 | - | } | |
| 2159 | - | ||
| 2160 | - | self.conn | |
| 2161 | - | .execute_batch(&format!("PRAGMA user_version = {target};\nCOMMIT;"))?; | |
| 2162 | - | } | |
| 2163 | - | Err(e) => return Err(DbError::Sqlite(e)), | |
| 2164 | - | } | |
| 2165 | - | } | |
| 2166 | - | } | |
| 2167 | - | ||
| 2168 | - | Ok(()) | |
| 2169 | - | } | |
| 2170 | - | ||
| 2171 | - | /// Run a closure inside a SQLite transaction. | |
| 2172 | - | /// | |
| 2173 | - | /// Uses `BEGIN IMMEDIATE` to acquire a write lock upfront, preventing | |
| 2174 | - | /// deadlocks when the closure issues writes. The closure receives a [`Tx`] | |
| 2175 | - | /// token proving a transaction is open; pass it to row-write functions that | |
| 2176 | - | /// require batching (the token cannot be constructed any other way, so those | |
| 2177 | - | /// functions cannot be called in an un-batched per-row loop). The closure | |
| 2178 | - | /// accesses the same `Database` through the shared `Mutex<Database>`, which is | |
| 2179 | - | /// safe because the caller already holds the lock. | |
| 2180 | - | #[instrument(skip_all)] | |
| 2181 | - | pub fn transaction<T, F>(&self, f: F) -> Result<T, DbError> | |
| 2182 | - | where | |
| 2183 | - | F: FnOnce(&Tx) -> Result<T, DbError>, | |
| 2184 | - | { | |
| 2185 | - | self.conn.execute_batch("BEGIN IMMEDIATE")?; | |
| 2186 | - | match f(&Tx(())) { | |
| 2187 | - | Ok(val) => { | |
| 2188 | - | self.conn.execute_batch("COMMIT")?; | |
| 2189 | - | Ok(val) | |
| 2190 | - | } | |
| 2191 | - | Err(e) => { | |
| 2192 | - | if let Err(rb_err) = self.conn.execute_batch("ROLLBACK") { | |
| 2193 | - | tracing::warn!("ROLLBACK failed after transaction error: {rb_err}"); | |
| 2194 | - | } | |
| 2195 | - | Err(e) | |
| 2196 | - | } | |
| 2197 | - | } | |
| 2198 | - | } | |
| 2199 | - | ||
| 2200 | - | /// [`transaction`](Self::transaction) for closures that produce a | |
| 2201 | - | /// [`CoreError`](crate::error::CoreError), i.e. the row-write batchers in | |
| 2202 | - | /// `analysis`, `rules`, and `harvest`, which call functions returning | |
| 2203 | - | /// `CoreError`. Same `BEGIN IMMEDIATE` / commit / rollback semantics and the | |
| 2204 | - | /// same [`Tx`] proof token; only the closure's error type differs. | |
| 2205 | - | #[instrument(skip_all)] | |
| 2206 | - | pub fn transaction_core<T, F>(&self, f: F) -> Result<T, crate::error::CoreError> | |
| 2207 | - | where | |
| 2208 | - | F: FnOnce(&Tx) -> Result<T, crate::error::CoreError>, | |
| 2209 | - | { | |
| 2210 | - | self.conn.execute_batch("BEGIN IMMEDIATE")?; | |
| 2211 | - | match f(&Tx(())) { | |
| 2212 | - | Ok(val) => { | |
| 2213 | - | self.conn.execute_batch("COMMIT")?; | |
| 2214 | - | Ok(val) | |
| 2215 | - | } | |
| 2216 | - | Err(e) => { | |
| 2217 | - | if let Err(rb_err) = self.conn.execute_batch("ROLLBACK") { | |
| 2218 | - | tracing::warn!("ROLLBACK failed after transaction error: {rb_err}"); | |
| 2219 | - | } | |
| 2220 | - | Err(e) | |
| 2221 | - | } | |
| 2222 | - | } | |
| 2223 | - | } | |
| 2224 | - | ||
| 2225 | - | /// Borrow the underlying connection for queries. | |
| 2226 | - | pub fn conn(&self) -> &Connection { | |
| 2227 | - | &self.conn | |
| 2228 | - | } | |
| 2229 | - | ||
| 2230 | - | /// Aggregate storage stats: (sample_count, total_file_bytes). | |
| 2231 | - | /// | |
| 2232 | - | /// Excludes tombstoned rows (`deleted_at IS NOT NULL`) so the figure matches | |
| 2233 | - | /// the library the user actually sees, the M019 read-path filter applies here | |
| 2234 | - | /// like every other sample read site. | |
| 2235 | - | pub fn storage_stats(&self) -> Result<(u64, u64), DbError> { | |
| 2236 | - | let (count, total): (u64, u64) = self.conn.query_row( | |
| 2237 | - | "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM live_samples", | |
| 2238 | - | [], | |
| 2239 | - | // SQLite integers are i64. COUNT/SUM should be non-negative, but a | |
| 2240 | - | // single corrupt negative file_size must surface as an error, not | |
| 2241 | - | // wrap silently to ~1.8e19 (workspace denies unwrap for this class). | |
| 2242 | - | |row| { | |
| 2243 | - | let count = row.get::<_, i64>(0)?; | |
| 2244 | - | let total = row.get::<_, i64>(1)?; | |
| 2245 | - | Ok(( | |
| 2246 | - | u64::try_from(count) | |
| 2247 | - | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, count))?, | |
| 2248 | - | u64::try_from(total) | |
| 2249 | - | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, total))?, | |
| 2250 | - | )) | |
| 2251 | - | }, | |
| 2252 | - | )?; | |
| 2253 | - | Ok((count, total)) | |
| 2254 | - | } | |
| 2255 | - | ||
| 2256 | - | /// Per-VFS storage stats: count and total bytes of *unique* samples | |
| 2257 | - | /// referenced by `vfs_id`. A sample referenced from multiple nodes in the | |
| 2258 | - | /// same VFS counts once. Used by the sync panel's per-VFS toggle rows so | |
| 2259 | - | /// the user can see how much would upload before enabling blob sync. | |
| 2260 | - | pub fn vfs_storage_stats(&self, vfs_id: i64) -> Result<(u64, u64), DbError> { | |
| 2261 | - | // Soft-delete keeps vfs placements, so a tombstoned sample would still be | |
| 2262 | - | // counted/summed here and inflate the "would upload" estimate; read through | |
| 2263 | - | // live_samples to exclude it. | |
| 2264 | - | let (count, total): (u64, u64) = self.conn.query_row( | |
| 2265 | - | "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM live_samples \ | |
| 2266 | - | WHERE hash IN (\ | |
| 2267 | - | SELECT DISTINCT sample_hash FROM vfs_nodes \ | |
| 2268 | - | WHERE vfs_id = ? AND sample_hash IS NOT NULL\ | |
| 2269 | - | )", | |
| 2270 | - | [vfs_id], | |
| 2271 | - | // Non-negative in practice; a corrupt negative surfaces as an error | |
| 2272 | - | // rather than wrapping silently to a nonsense u64. | |
| 2273 | - | |row| { | |
| 2274 | - | let count = row.get::<_, i64>(0)?; | |
| 2275 | - | let total = row.get::<_, i64>(1)?; | |
| 2276 | - | Ok(( | |
| 2277 | - | u64::try_from(count) | |
| 2278 | - | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, count))?, | |
| 2279 | - | u64::try_from(total) | |
| 2280 | - | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, total))?, | |
| 2281 | - | )) | |
| 2282 | - | }, | |
| 2283 | - | )?; | |
| 2284 | - | Ok((count, total)) | |
| 2285 | - | } | |
| 2286 | - | ||
| 2287 | - | /// Count and total bytes of the samples blob sync would actually upload: | |
| 2288 | - | /// the *union* of every VFS with `sync_files` set. | |
| 2289 | - | /// | |
| 2290 | - | /// A union rather than a sum over [`vfs_storage_stats`](Self::vfs_storage_stats), | |
| 2291 | - | /// because a sample placed in two synced VFSes uploads once. Blobs are | |
| 2292 | - | /// content-addressed and the server dedups on `(app, user, hash)`, so | |
| 2293 | - | /// adding the per-VFS figures would overstate the need and buy the user a | |
| 2294 | - | /// cap they do not require. Reads through `live_samples` for the same | |
| 2295 | - | /// reason the per-VFS query does: a tombstoned sample is not going to | |
| 2296 | - | /// upload. | |
| 2297 | - | /// | |
| 2298 | - | /// Zero synced VFSes gives `(0, 0)`, which is the honest answer — nothing | |
| 2299 | - | /// is set to sync, so nothing would upload. | |
| 2300 | - | pub fn synced_storage_stats(&self) -> Result<(u64, u64), DbError> { | |
| 2301 | - | let (count, total): (u64, u64) = self.conn.query_row( | |
| 2302 | - | "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM live_samples \ | |
| 2303 | - | WHERE hash IN (\ | |
| 2304 | - | SELECT DISTINCT sample_hash FROM vfs_nodes \ | |
| 2305 | - | WHERE sample_hash IS NOT NULL \ | |
| 2306 | - | AND vfs_id IN (SELECT id FROM vfs WHERE sync_files != 0)\ | |
| 2307 | - | )", | |
| 2308 | - | [], | |
| 2309 | - | |row| { | |
| 2310 | - | let count = row.get::<_, i64>(0)?; | |
| 2311 | - | let total = row.get::<_, i64>(1)?; | |
| 2312 | - | Ok(( | |
| 2313 | - | u64::try_from(count) | |
| 2314 | - | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, count))?, | |
| 2315 | - | u64::try_from(total) | |
| 2316 | - | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, total))?, | |
| 2317 | - | )) | |
| 2318 | - | }, | |
| 2319 | - | )?; | |
| 2320 | - | Ok((count, total)) | |
| 2321 | - | } | |
| 2322 | - | } | |
| 2323 | - | ||
| 2324 | - | #[cfg(test)] | |
| 2325 | - | mod tests { | |
| 2326 | - | use super::*; | |
| 2327 | - | ||
| 2328 | - | #[test] | |
| 2329 | - | fn migration_034_normalises_legacy_key_spellings() { | |
| 2330 | - | let db = Database::open_in_memory().unwrap(); | |
| 2331 | - | // Rows written before the detector normalised its output. Inserted | |
| 2332 | - | // post-migration and re-run explicitly, since an in-memory DB starts | |
| 2333 | - | // empty and the migration would otherwise have nothing to rewrite. | |
| 2334 | - | db.conn() | |
| 2335 | - | .execute_batch( | |
| 2336 | - | "INSERT INTO samples | |
| 2337 | - | (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 2338 | - | VALUES ('a', 'a.wav', 'wav', 1, 0, 0), ('b', 'b.wav', 'wav', 1, 0, 0), | |
| 2339 | - | ('c', 'c.wav', 'wav', 1, 0, 0), ('d', 'd.wav', 'wav', 1, 0, 0); | |
| 2340 | - | INSERT INTO audio_analysis | |
| 2341 | - | (hash, musical_key, duration, sample_rate, channels, analyzed_at) | |
| 2342 | - | VALUES ('a', 'Am', 1.0, 44100, 2, 0), ('b', 'C#m', 1.0, 44100, 2, 0), | |
| 2343 | - | ('c', 'F#', 1.0, 44100, 2, 0), ('d', 'A minor', 1.0, 44100, 2, 0);", | |
| 2344 | - | ) | |
| 2345 | - | .unwrap(); | |
| 2346 | - | db.conn().execute_batch(MIGRATION_034).unwrap(); | |
| 2347 | - | ||
| 2348 | - | let mut stmt = db | |
| 2349 | - | .conn() | |
| 2350 | - | .prepare("SELECT hash, musical_key FROM audio_analysis ORDER BY hash") | |
| 2351 | - | .unwrap(); | |
| 2352 | - | let got: Vec<(String, String)> = stmt | |
| 2353 | - | .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) | |
| 2354 | - | .unwrap() | |
| 2355 | - | .map(Result::unwrap) | |
| 2356 | - | .collect(); | |
| 2357 | - | ||
| 2358 | - | assert_eq!(got[0].1, "A minor"); | |
| 2359 | - | assert_eq!(got[1].1, "C# minor"); |
Lines truncated
| @@ -1,0 +1,334 @@ | |||
| 1 | + | //! SQLite database wrapper with versioned migrations for samples, VFS, tags, and analysis tables. | |
| 2 | + | ||
| 3 | + | mod migrations; | |
| 4 | + | mod stats; | |
| 5 | + | ||
| 6 | + | use std::path::Path; | |
| 7 | + | ||
| 8 | + | use rusqlite::Connection; | |
| 9 | + | use synckit_config::{ConfigError, ConfigStore}; | |
| 10 | + | use thiserror::Error; | |
| 11 | + | use tracing::instrument; | |
| 12 | + | ||
| 13 | + | use crate::config_key::{CONFIG, ConfigKey}; | |
| 14 | + | use migrations::{MIGRATIONS, register_hash_row_id}; | |
| 15 | + | ||
| 16 | + | pub use migrations::SCHEMA_VERSION; | |
| 17 | + | ||
| 18 | + | #[derive(Error, Debug)] | |
| 19 | + | pub enum DbError { | |
| 20 | + | #[error("SQLite error: {0}")] | |
| 21 | + | Sqlite(#[from] rusqlite::Error), | |
| 22 | + | ||
| 23 | + | /// The vault's schema is ahead of what this build knows how to read, so | |
| 24 | + | /// opening it would mean querying a shape this code has never seen. See | |
| 25 | + | /// [`Database::migrate`] for why that is refused rather than tolerated. | |
| 26 | + | #[error( | |
| 27 | + | "this vault was written by a newer version of audiofiles \ | |
| 28 | + | (vault schema {found}, this build understands {supported}). \ | |
| 29 | + | Update audiofiles to open it." | |
| 30 | + | )] | |
| 31 | + | VaultTooNew { | |
| 32 | + | /// `PRAGMA user_version` read off the vault. | |
| 33 | + | found: i32, | |
| 34 | + | /// The highest version this build can produce, [`SCHEMA_VERSION`]. | |
| 35 | + | supported: i32, | |
| 36 | + | }, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// The config store wraps rusqlite; its one failure mode is the database, so it | |
| 40 | + | /// folds into [`DbError::Sqlite`] rather than carrying a second SQL error type. | |
| 41 | + | impl From<ConfigError> for DbError { | |
| 42 | + | fn from(error: ConfigError) -> Self { | |
| 43 | + | match error { | |
| 44 | + | ConfigError::Db(error) => DbError::Sqlite(error), | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Core database wrapper. All access is synchronous, no async runtime needed, | |
| 50 | + | /// safe to use from a CLAP plugin host thread. | |
| 51 | + | pub struct Database { | |
| 52 | + | conn: Connection, | |
| 53 | + | /// The shared config store over `user_config`. Attached, not opened: the | |
| 54 | + | /// table and its sync triggers are stood up by this crate's migrations, so | |
| 55 | + | /// the store drives the existing table rather than creating its own. | |
| 56 | + | config: ConfigStore, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Compile-time proof that a write transaction is open on the connection. | |
| 60 | + | /// | |
| 61 | + | /// Constructed only by [`Database::transaction`], and required by the row-write | |
| 62 | + | /// functions that must run inside a batched transaction rather than as | |
| 63 | + | /// standalone autocommits in a loop (e.g. [`crate::analysis::save_analysis`], | |
| 64 | + | /// [`crate::rules::apply_tag_sourced`]). Holding a `&Tx` is the only way to call | |
| 65 | + | /// those functions, so "bulk loop of per-row autocommits", the chronic | |
| 66 | + | /// per-row-commit pattern, does not compile: there is no `Tx` to pass except | |
| 67 | + | /// inside a `transaction` closure that already batches the whole loop. | |
| 68 | + | /// | |
| 69 | + | /// The token is a zero-sized marker; the SQL still runs on `db.conn()`, which | |
| 70 | + | /// participates in the ambient `BEGIN IMMEDIATE` opened by `transaction`. | |
| 71 | + | pub struct Tx(()); | |
| 72 | + | ||
| 73 | + | impl Database { | |
| 74 | + | /// Open (or create) the database at the given path and run migrations. | |
| 75 | + | #[instrument(skip_all)] | |
| 76 | + | pub fn open(path: impl AsRef<Path>) -> Result<Self, DbError> { | |
| 77 | + | let conn = Connection::open(path)?; | |
| 78 | + | conn.execute_batch( | |
| 79 | + | // WAL + synchronous=NORMAL is the standard durable-but-fast pairing: | |
| 80 | + | // commits no longer fsync individually (only at checkpoint), which is | |
| 81 | + | // what made the import path ~2 fsyncs/file. NORMAL under WAL can lose | |
| 82 | + | // only the last few committed transactions on power loss, never | |
| 83 | + | // corruption, acceptable for a local sample library. The cache / | |
| 84 | + | // mmap / temp_store pragmas cut page churn on large scans and the | |
| 85 | + | // import write batch. | |
| 86 | + | "PRAGMA journal_mode=WAL;\ | |
| 87 | + | PRAGMA synchronous=NORMAL;\ | |
| 88 | + | PRAGMA foreign_keys=ON;\ | |
| 89 | + | PRAGMA busy_timeout=5000;\ | |
| 90 | + | PRAGMA cache_size=-16000;\ | |
| 91 | + | PRAGMA mmap_size=268435456;\ | |
| 92 | + | PRAGMA temp_store=MEMORY;\ | |
| 93 | + | PRAGMA wal_checkpoint(TRUNCATE);", | |
| 94 | + | )?; | |
| 95 | + | register_hash_row_id(&conn)?; | |
| 96 | + | let mut db = Self { | |
| 97 | + | conn, | |
| 98 | + | config: ConfigStore::attached(&CONFIG), | |
| 99 | + | }; | |
| 100 | + | db.migrate()?; | |
| 101 | + | db.seed_config_key_policy()?; | |
| 102 | + | Ok(db) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Flush the WAL back into the main database file and remove the -shm file. | |
| 106 | + | /// | |
| 107 | + | /// Call after large write batches (e.g. import completion) to keep the | |
| 108 | + | /// WAL index fresh and avoid stale memory-mapped state on macOS. | |
| 109 | + | pub fn wal_checkpoint(&self) -> Result<(), DbError> { | |
| 110 | + | self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?; | |
| 111 | + | Ok(()) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// Open an in-memory database (for tests). | |
| 115 | + | #[instrument(skip_all)] | |
| 116 | + | pub fn open_in_memory() -> Result<Self, DbError> { | |
| 117 | + | let conn = Connection::open_in_memory()?; | |
| 118 | + | conn.execute_batch("PRAGMA foreign_keys=ON;")?; | |
| 119 | + | register_hash_row_id(&conn)?; | |
| 120 | + | let mut db = Self { | |
| 121 | + | conn, | |
| 122 | + | config: ConfigStore::attached(&CONFIG), | |
| 123 | + | }; | |
| 124 | + | db.migrate()?; | |
| 125 | + | db.seed_config_key_policy()?; | |
| 126 | + | Ok(db) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Seed `config_key_policy` from the [`CONFIG`] spec, the single source of | |
| 130 | + | /// truth for which `user_config` keys may sync. Run at every open so the SQL | |
| 131 | + | /// export triggers always reflect the current registry: adding a key in Rust | |
| 132 | + | /// is enough, no migration needed. Idempotent (clear + reinsert the closed | |
| 133 | + | /// set); the rows are the spec's [`policy_rows`](synckit_config::ConfigSpec::policy_rows), | |
| 134 | + | /// so an undeclared key has no row and the export filter cannot admit it. | |
| 135 | + | fn seed_config_key_policy(&self) -> Result<(), DbError> { | |
| 136 | + | self.conn.execute("DELETE FROM config_key_policy", [])?; | |
| 137 | + | let mut stmt = self | |
| 138 | + | .conn | |
| 139 | + | .prepare("INSERT INTO config_key_policy (key, replicated) VALUES (?1, ?2)")?; | |
| 140 | + | for row in CONFIG.policy_rows() { | |
| 141 | + | stmt.execute(rusqlite::params![row.key, i64::from(row.replicated)])?; | |
| 142 | + | } | |
| 143 | + | Ok(()) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// Read a `user_config` value through the shared config store, `None` when | |
| 147 | + | /// unset. The store is attached to the `user_config` table this crate's | |
| 148 | + | /// migrations own. | |
| 149 | + | pub fn get_config(&self, key: ConfigKey) -> Result<Option<String>, DbError> { | |
| 150 | + | Ok(self.config.get(&self.conn, key.as_str())?) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /// Write a `user_config` value through the shared config store. | |
| 154 | + | /// | |
| 155 | + | /// An upsert on the key: writing a key that already exists fires the table's | |
| 156 | + | /// UPDATE trigger once, not a DELETE followed by an INSERT the way the old | |
| 157 | + | /// `INSERT OR REPLACE` did, so a synced key enqueues one changelog row per | |
| 158 | + | /// edit rather than a spurious delete-then-insert pair. | |
| 159 | + | pub fn set_config(&self, key: ConfigKey, value: &str) -> Result<(), DbError> { | |
| 160 | + | self.config.set(&self.conn, key.as_str(), value)?; | |
| 161 | + | Ok(()) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// Remove a `user_config` key through the shared config store. Absent | |
| 165 | + | /// already is not an error. | |
| 166 | + | pub fn delete_config(&self, key: ConfigKey) -> Result<(), DbError> { | |
| 167 | + | self.config.unset(&self.conn, key.as_str())?; | |
| 168 | + | Ok(()) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Apply pending migrations using PRAGMA user_version as the version tracker. | |
| 172 | + | /// | |
| 173 | + | /// Each migration step runs inside a transaction so the schema change and | |
| 174 | + | /// version bump are atomic, a crash between the two can no longer leave the | |
| 175 | + | /// database in an inconsistent state. | |
| 176 | + | /// | |
| 177 | + | /// The runner is bounded on both sides. Forward is the ordinary case. | |
| 178 | + | /// Backward is not possible and must not be attempted silently: a vault | |
| 179 | + | /// carrying a version this build has never heard of was written by a newer | |
| 180 | + | /// audiofiles, and every query past this point assumes a schema this code | |
| 181 | + | /// has seen. Applying nothing and returning `Ok` reads as success and then | |
| 182 | + | /// queries a shape it does not understand, survivable for an added column | |
| 183 | + | /// and silent data loss for a dropped one, a rename, or a NOT NULL the | |
| 184 | + | /// older code never populates. | |
| 185 | + | /// | |
| 186 | + | /// Nothing exotic is needed to reach it: a rollback to an older release | |
| 187 | + | /// after a bad update, a restored backup, or one vault opened from two | |
| 188 | + | /// machines running different versions, which is the shape the | |
| 189 | + | /// vault-per-detachable-drive workflow is made of. | |
| 190 | + | #[instrument(skip_all)] | |
| 191 | + | fn migrate(&mut self) -> Result<(), DbError> { | |
| 192 | + | let version: i32 = self | |
| 193 | + | .conn | |
| 194 | + | .query_row("PRAGMA user_version", [], |row| row.get(0))?; | |
| 195 | + | ||
| 196 | + | if version > SCHEMA_VERSION { | |
| 197 | + | return Err(DbError::VaultTooNew { | |
| 198 | + | found: version, | |
| 199 | + | supported: SCHEMA_VERSION, | |
| 200 | + | }); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | for (i, sql) in MIGRATIONS.iter().enumerate() { | |
| 204 | + | let target = (i + 1) as i32; | |
| 205 | + | if version < target { | |
| 206 | + | let batch = format!("BEGIN;\n{sql}\nPRAGMA user_version = {target};\nCOMMIT;"); | |
| 207 | + | match self.conn.execute_batch(&batch) { | |
| 208 | + | Ok(()) => {} | |
| 209 | + | Err(e) if e.to_string().contains("duplicate column") => { | |
| 210 | + | // Recovery path: a prior partial migration committed | |
| 211 | + | // some ALTERs before crashing. Re-run the migration in | |
| 212 | + | // pieces, tolerating "duplicate column" on ALTERs and | |
| 213 | + | // "already exists" on CREATEs (both mean: the prior | |
| 214 | + | // partial run got there already; the desired final | |
| 215 | + | // state is still reachable). Any OTHER error here is | |
| 216 | + | // a real failure, we roll back and surface it, | |
| 217 | + | // because silently bumping user_version on a partially | |
| 218 | + | // applied schema is the worst possible outcome. | |
| 219 | + | let _ = self.conn.execute_batch("ROLLBACK"); | |
| 220 | + | self.conn.execute_batch("BEGIN")?; | |
| 221 | + | ||
| 222 | + | // ALTER TABLEs first, individually, tolerating duplicates. | |
| 223 | + | for line in sql.lines() { | |
| 224 | + | let trimmed = line.trim(); | |
| 225 | + | if trimmed.to_uppercase().starts_with("ALTER TABLE") | |
| 226 | + | && trimmed.to_uppercase().contains("ADD COLUMN") | |
| 227 | + | && let Err(alter_err) = self.conn.execute_batch(trimmed) | |
| 228 | + | && !alter_err.to_string().contains("duplicate column") | |
| 229 | + | { | |
| 230 | + | let _ = self.conn.execute_batch("ROLLBACK"); | |
| 231 | + | return Err(DbError::Sqlite(alter_err)); | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // Non-ALTER statements (CREATE TABLE / INDEX / | |
| 236 | + | // TRIGGER, DROP IF EXISTS, INSERT OR IGNORE, plain | |
| 237 | + | // INSERT / UPDATE / DELETE). After M018, every | |
| 238 | + | // migration from M003 onward is replay-safe by | |
| 239 | + | // construction (verified by the | |
| 240 | + | // migration_replay_from_version_two_against_full_schema | |
| 241 | + | // regression test), so this batch should succeed | |
| 242 | + | // cleanly even against a populated schema. "already | |
| 243 | + | // exists" stays tolerable as a belt-and-braces guard | |
| 244 | + | // for pre-idempotent migration bodies. Anything else | |
| 245 | + | // is a real failure, fail fast, don't bump. | |
| 246 | + | let non_alter: String = sql | |
| 247 | + | .lines() | |
| 248 | + | .filter(|l| { | |
| 249 | + | let t = l.trim().to_uppercase(); | |
| 250 | + | !(t.starts_with("ALTER TABLE") && t.contains("ADD COLUMN")) | |
| 251 | + | }) | |
| 252 | + | .collect::<Vec<_>>() | |
| 253 | + | .join("\n"); | |
| 254 | + | if !non_alter.trim().is_empty() | |
| 255 | + | && let Err(e) = self.conn.execute_batch(&non_alter) | |
| 256 | + | && !e.to_string().contains("already exists") | |
| 257 | + | { | |
| 258 | + | let _ = self.conn.execute_batch("ROLLBACK"); | |
| 259 | + | return Err(DbError::Sqlite(e)); | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | self.conn | |
| 263 | + | .execute_batch(&format!("PRAGMA user_version = {target};\nCOMMIT;"))?; | |
| 264 | + | } | |
| 265 | + | Err(e) => return Err(DbError::Sqlite(e)), | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | Ok(()) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | /// Run a closure inside a SQLite transaction. | |
| 274 | + | /// | |
| 275 | + | /// Uses `BEGIN IMMEDIATE` to acquire a write lock upfront, preventing | |
| 276 | + | /// deadlocks when the closure issues writes. The closure receives a [`Tx`] | |
| 277 | + | /// token proving a transaction is open; pass it to row-write functions that | |
| 278 | + | /// require batching (the token cannot be constructed any other way, so those | |
| 279 | + | /// functions cannot be called in an un-batched per-row loop). The closure | |
| 280 | + | /// accesses the same `Database` through the shared `Mutex<Database>`, which is | |
| 281 | + | /// safe because the caller already holds the lock. | |
| 282 | + | #[instrument(skip_all)] | |
| 283 | + | pub fn transaction<T, F>(&self, f: F) -> Result<T, DbError> | |
| 284 | + | where | |
| 285 | + | F: FnOnce(&Tx) -> Result<T, DbError>, | |
| 286 | + | { | |
| 287 | + | self.conn.execute_batch("BEGIN IMMEDIATE")?; | |
| 288 | + | match f(&Tx(())) { | |
| 289 | + | Ok(val) => { | |
| 290 | + | self.conn.execute_batch("COMMIT")?; | |
| 291 | + | Ok(val) | |
| 292 | + | } | |
| 293 | + | Err(e) => { | |
| 294 | + | if let Err(rb_err) = self.conn.execute_batch("ROLLBACK") { | |
| 295 | + | tracing::warn!("ROLLBACK failed after transaction error: {rb_err}"); | |
| 296 | + | } | |
| 297 | + | Err(e) | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | /// [`transaction`](Self::transaction) for closures that produce a | |
| 303 | + | /// [`CoreError`](crate::error::CoreError), i.e. the row-write batchers in | |
| 304 | + | /// `analysis`, `rules`, and `harvest`, which call functions returning | |
| 305 | + | /// `CoreError`. Same `BEGIN IMMEDIATE` / commit / rollback semantics and the | |
| 306 | + | /// same [`Tx`] proof token; only the closure's error type differs. | |
| 307 | + | #[instrument(skip_all)] | |
| 308 | + | pub fn transaction_core<T, F>(&self, f: F) -> Result<T, crate::error::CoreError> | |
| 309 | + | where | |
| 310 | + | F: FnOnce(&Tx) -> Result<T, crate::error::CoreError>, | |
| 311 | + | { | |
| 312 | + | self.conn.execute_batch("BEGIN IMMEDIATE")?; | |
| 313 | + | match f(&Tx(())) { | |
| 314 | + | Ok(val) => { | |
| 315 | + | self.conn.execute_batch("COMMIT")?; | |
| 316 | + | Ok(val) | |
| 317 | + | } | |
| 318 | + | Err(e) => { | |
| 319 | + | if let Err(rb_err) = self.conn.execute_batch("ROLLBACK") { | |
| 320 | + | tracing::warn!("ROLLBACK failed after transaction error: {rb_err}"); | |
| 321 | + | } | |
| 322 | + | Err(e) | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /// Borrow the underlying connection for queries. | |
| 328 | + | pub fn conn(&self) -> &Connection { | |
| 329 | + | &self.conn | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | #[cfg(test)] | |
| 334 | + | mod tests; |
| @@ -1,0 +1,101 @@ | |||
| 1 | + | //! Storage accounting: the counts and byte totals the sync panel prices caps from. | |
| 2 | + | //! | |
| 3 | + | //! Extracted from the former `db.rs`; these are inherent methods on | |
| 4 | + | //! [`Database`], so the parent needs no re-export. | |
| 5 | + | ||
| 6 | + | use super::{Database, DbError}; | |
| 7 | + | ||
| 8 | + | impl Database { | |
| 9 | + | /// Aggregate storage stats: (sample_count, total_file_bytes). | |
| 10 | + | /// | |
| 11 | + | /// Excludes tombstoned rows (`deleted_at IS NOT NULL`) so the figure matches | |
| 12 | + | /// the library the user actually sees, the M019 read-path filter applies here | |
| 13 | + | /// like every other sample read site. | |
| 14 | + | pub fn storage_stats(&self) -> Result<(u64, u64), DbError> { | |
| 15 | + | let (count, total): (u64, u64) = self.conn.query_row( | |
| 16 | + | "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM live_samples", | |
| 17 | + | [], | |
| 18 | + | // SQLite integers are i64. COUNT/SUM should be non-negative, but a | |
| 19 | + | // single corrupt negative file_size must surface as an error, not | |
| 20 | + | // wrap silently to ~1.8e19 (workspace denies unwrap for this class). | |
| 21 | + | |row| { | |
| 22 | + | let count = row.get::<_, i64>(0)?; | |
| 23 | + | let total = row.get::<_, i64>(1)?; | |
| 24 | + | Ok(( | |
| 25 | + | u64::try_from(count) | |
| 26 | + | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, count))?, | |
| 27 | + | u64::try_from(total) | |
| 28 | + | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, total))?, | |
| 29 | + | )) | |
| 30 | + | }, | |
| 31 | + | )?; | |
| 32 | + | Ok((count, total)) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Per-VFS storage stats: count and total bytes of *unique* samples | |
| 36 | + | /// referenced by `vfs_id`. A sample referenced from multiple nodes in the | |
| 37 | + | /// same VFS counts once. Used by the sync panel's per-VFS toggle rows so | |
| 38 | + | /// the user can see how much would upload before enabling blob sync. | |
| 39 | + | pub fn vfs_storage_stats(&self, vfs_id: i64) -> Result<(u64, u64), DbError> { | |
| 40 | + | // Soft-delete keeps vfs placements, so a tombstoned sample would still be | |
| 41 | + | // counted/summed here and inflate the "would upload" estimate; read through | |
| 42 | + | // live_samples to exclude it. | |
| 43 | + | let (count, total): (u64, u64) = self.conn.query_row( | |
| 44 | + | "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM live_samples \ | |
| 45 | + | WHERE hash IN (\ | |
| 46 | + | SELECT DISTINCT sample_hash FROM vfs_nodes \ | |
| 47 | + | WHERE vfs_id = ? AND sample_hash IS NOT NULL\ | |
| 48 | + | )", | |
| 49 | + | [vfs_id], | |
| 50 | + | // Non-negative in practice; a corrupt negative surfaces as an error | |
| 51 | + | // rather than wrapping silently to a nonsense u64. | |
| 52 | + | |row| { | |
| 53 | + | let count = row.get::<_, i64>(0)?; | |
| 54 | + | let total = row.get::<_, i64>(1)?; | |
| 55 | + | Ok(( | |
| 56 | + | u64::try_from(count) | |
| 57 | + | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, count))?, | |
| 58 | + | u64::try_from(total) | |
| 59 | + | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, total))?, | |
| 60 | + | )) | |
| 61 | + | }, | |
| 62 | + | )?; | |
| 63 | + | Ok((count, total)) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// Count and total bytes of the samples blob sync would actually upload: | |
| 67 | + | /// the *union* of every VFS with `sync_files` set. | |
| 68 | + | /// | |
| 69 | + | /// A union rather than a sum over [`vfs_storage_stats`](Self::vfs_storage_stats), | |
| 70 | + | /// because a sample placed in two synced VFSes uploads once. Blobs are | |
| 71 | + | /// content-addressed and the server dedups on `(app, user, hash)`, so | |
| 72 | + | /// adding the per-VFS figures would overstate the need and buy the user a | |
| 73 | + | /// cap they do not require. Reads through `live_samples` for the same | |
| 74 | + | /// reason the per-VFS query does: a tombstoned sample is not going to | |
| 75 | + | /// upload. | |
| 76 | + | /// | |
| 77 | + | /// Zero synced VFSes gives `(0, 0)`, which is the honest answer — nothing | |
| 78 | + | /// is set to sync, so nothing would upload. | |
| 79 | + | pub fn synced_storage_stats(&self) -> Result<(u64, u64), DbError> { | |
| 80 | + | let (count, total): (u64, u64) = self.conn.query_row( | |
| 81 | + | "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM live_samples \ | |
| 82 | + | WHERE hash IN (\ | |
| 83 | + | SELECT DISTINCT sample_hash FROM vfs_nodes \ | |
| 84 | + | WHERE sample_hash IS NOT NULL \ | |
| 85 | + | AND vfs_id IN (SELECT id FROM vfs WHERE sync_files != 0)\ | |
| 86 | + | )", | |
| 87 | + | [], | |
| 88 | + | |row| { | |
| 89 | + | let count = row.get::<_, i64>(0)?; | |
| 90 | + | let total = row.get::<_, i64>(1)?; | |
| 91 | + | Ok(( | |
| 92 | + | u64::try_from(count) | |
| 93 | + | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, count))?, | |
| 94 | + | u64::try_from(total) | |
| 95 | + | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, total))?, | |
| 96 | + | )) | |
| 97 | + | }, | |
| 98 | + | )?; | |
| 99 | + | Ok((count, total)) | |
| 100 | + | } | |
| 101 | + | } |
| @@ -1,0 +1,106 @@ | |||
| 1 | + | //! Tests for the config store and its sync export boundary. | |
| 2 | + | //! | |
| 3 | + | //! Extracted from the former `db.rs` inline test module. | |
| 4 | + | ||
| 5 | + | use crate::db::*; | |
| 6 | + | ||
| 7 | + | #[test] | |
| 8 | + | fn user_config_export_trigger_excludes_device_local_keys() { | |
| 9 | + | // Export side of the CHRONIC fix (fuzz-2026-07-21 #3): the sync triggers | |
| 10 | + | // are generated from the ConfigKey registry via config_key_policy, so a | |
| 11 | + | // device-local key never enqueues a changelog row, while a replicated | |
| 12 | + | // key still does. Symmetric with the import-side test in audiofiles-sync. | |
| 13 | + | let db = Database::open_in_memory().unwrap(); | |
| 14 | + | let conn = db.conn(); | |
| 15 | + | let changelog_rows = |key: &str| -> i64 { | |
| 16 | + | conn.query_row( | |
| 17 | + | "SELECT COUNT(*) FROM sync_changelog WHERE table_name = 'user_config' AND row_id = ?1", | |
| 18 | + | [key], | |
| 19 | + | |r| r.get(0), | |
| 20 | + | ) | |
| 21 | + | .unwrap() | |
| 22 | + | }; | |
| 23 | + | ||
| 24 | + | for key in [ | |
| 25 | + | "mirror_path", | |
| 26 | + | "mirror_enabled", | |
| 27 | + | "import_preflight_disabled", | |
| 28 | + | "loose_files", | |
| 29 | + | ] { | |
| 30 | + | conn.execute( | |
| 31 | + | "INSERT OR REPLACE INTO user_config (key, value) VALUES (?1, '1')", | |
| 32 | + | [key], | |
| 33 | + | ) | |
| 34 | + | .unwrap(); | |
| 35 | + | assert_eq!(changelog_rows(key), 0, "{key} must not be exported"); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // A replicated key still enqueues a changelog row. | |
| 39 | + | conn.execute( | |
| 40 | + | "INSERT OR REPLACE INTO user_config (key, value) VALUES ('theme', 'dark')", | |
| 41 | + | [], | |
| 42 | + | ) | |
| 43 | + | .unwrap(); | |
| 44 | + | assert_eq!(changelog_rows("theme"), 1, "theme must be exported"); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // The same export boundary, exercised through the real write path the app | |
| 48 | + | // uses now: `Database::set_config` over the shared `ConfigStore`, not a raw | |
| 49 | + | // INSERT. The store's upsert and the spec-seeded policy must still keep a | |
| 50 | + | // device-local key off the changelog while a replicated key lands. | |
| 51 | + | #[test] | |
| 52 | + | fn set_config_respects_the_export_boundary() { | |
| 53 | + | let db = Database::open_in_memory().unwrap(); | |
| 54 | + | let changelog_rows = |key: &str| -> i64 { | |
| 55 | + | db.conn() | |
| 56 | + | .query_row( | |
| 57 | + | "SELECT COUNT(*) FROM sync_changelog WHERE table_name = 'user_config' AND row_id = ?1", | |
| 58 | + | [key], | |
| 59 | + | |r| r.get(0), | |
| 60 | + | ) | |
| 61 | + | .unwrap() | |
| 62 | + | }; | |
| 63 | + | ||
| 64 | + | db.set_config(ConfigKey::MirrorPath, "/mnt/samples") | |
| 65 | + | .unwrap(); | |
| 66 | + | assert_eq!( | |
| 67 | + | db.get_config(ConfigKey::MirrorPath).unwrap().as_deref(), | |
| 68 | + | Some("/mnt/samples"), | |
| 69 | + | "a device-local key is still stored locally", | |
| 70 | + | ); | |
| 71 | + | assert_eq!( | |
| 72 | + | changelog_rows("mirror_path"), | |
| 73 | + | 0, | |
| 74 | + | "a device-local key must never be exported", | |
| 75 | + | ); | |
| 76 | + | ||
| 77 | + | db.set_config(ConfigKey::Theme, "dark").unwrap(); | |
| 78 | + | assert_eq!(changelog_rows("theme"), 1, "a replicated key is exported"); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // The reason the store upserts instead of `INSERT OR REPLACE`: rewriting a | |
| 82 | + | // replicated key is one UPDATE, not a DELETE-then-INSERT pair. The old | |
| 83 | + | // path enqueued a spurious delete for every re-save of a synced setting. | |
| 84 | + | #[test] | |
| 85 | + | fn rewriting_a_synced_key_enqueues_an_update_not_a_delete() { | |
| 86 | + | let db = Database::open_in_memory().unwrap(); | |
| 87 | + | db.set_config(ConfigKey::Theme, "light").unwrap(); | |
| 88 | + | db.set_config(ConfigKey::Theme, "dark").unwrap(); | |
| 89 | + | ||
| 90 | + | let ops: Vec<String> = db | |
| 91 | + | .conn() | |
| 92 | + | .prepare( | |
| 93 | + | "SELECT op FROM sync_changelog WHERE table_name = 'user_config' AND row_id = 'theme' ORDER BY id", | |
| 94 | + | ) | |
| 95 | + | .unwrap() | |
| 96 | + | .query_map([], |r| r.get(0)) | |
| 97 | + | .unwrap() | |
| 98 | + | .collect::<Result<_, _>>() | |
| 99 | + | .unwrap(); | |
| 100 | + | ||
| 101 | + | assert_eq!(ops, vec!["INSERT", "UPDATE"], "one insert then one update"); | |
| 102 | + | assert!( | |
| 103 | + | !ops.iter().any(|op| op == "DELETE"), | |
| 104 | + | "no spurious delete from a re-save", | |
| 105 | + | ); | |
| 106 | + | } |
| @@ -1,0 +1,145 @@ | |||
| 1 | + | //! Open, pragma, version-guard and transaction tests for the wrapper. | |
| 2 | + | //! | |
| 3 | + | //! Extracted from the former `db.rs` inline test module. | |
| 4 | + | ||
| 5 | + | use crate::db::*; | |
| 6 | + | ||
| 7 | + | #[test] | |
| 8 | + | fn file_db_applies_performance_pragmas() { | |
| 9 | + | let dir = tempfile::tempdir().unwrap(); | |
| 10 | + | let db = Database::open(dir.path().join("audiofiles.db")).unwrap(); | |
| 11 | + | ||
| 12 | + | let journal: String = db | |
| 13 | + | .conn() | |
| 14 | + | .query_row("PRAGMA journal_mode", [], |r| r.get(0)) | |
| 15 | + | .unwrap(); | |
| 16 | + | assert_eq!(journal.to_lowercase(), "wal"); | |
| 17 | + | ||
| 18 | + | // synchronous: 0=OFF, 1=NORMAL, 2=FULL. We want NORMAL under WAL. | |
| 19 | + | let synchronous: i64 = db | |
| 20 | + | .conn() | |
| 21 | + | .query_row("PRAGMA synchronous", [], |r| r.get(0)) | |
| 22 | + | .unwrap(); | |
| 23 | + | assert_eq!(synchronous, 1, "synchronous should be NORMAL"); | |
| 24 | + | ||
| 25 | + | let temp_store: i64 = db | |
| 26 | + | .conn() | |
| 27 | + | .query_row("PRAGMA temp_store", [], |r| r.get(0)) | |
| 28 | + | .unwrap(); | |
| 29 | + | assert_eq!(temp_store, 2, "temp_store should be MEMORY"); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// A vault written by a newer audiofiles is refused, not opened. | |
| 33 | + | /// | |
| 34 | + | /// The bug this pins: `migrate()` only ever compared `version < target`, so | |
| 35 | + | /// a vault ahead of the build applied nothing, returned `Ok`, and left every | |
| 36 | + | /// query below running against a schema this code has never seen. Two things | |
| 37 | + | /// are asserted, and the second is the one that matters: the open fails, AND | |
| 38 | + | /// it fails without having touched the database, so an older build cannot | |
| 39 | + | /// half-write a newer vault on its way to giving up. | |
| 40 | + | #[test] | |
| 41 | + | fn open_refuses_a_vault_from_a_newer_audiofiles() { | |
| 42 | + | let dir = tempfile::tempdir().unwrap(); | |
| 43 | + | let path = dir.path().join("audiofiles.db"); | |
| 44 | + | ||
| 45 | + | Database::open(&path).unwrap(); | |
| 46 | + | let ahead = SCHEMA_VERSION + 1; | |
| 47 | + | { | |
| 48 | + | let conn = Connection::open(&path).unwrap(); | |
| 49 | + | conn.execute_batch(&format!("PRAGMA user_version = {ahead}")) | |
| 50 | + | .unwrap(); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | let Err(err) = Database::open(&path) else { | |
| 54 | + | panic!("a newer vault must not open"); | |
| 55 | + | }; | |
| 56 | + | let DbError::VaultTooNew { found, supported } = err else { | |
| 57 | + | panic!("expected VaultTooNew, got {err:?}"); | |
| 58 | + | }; | |
| 59 | + | assert_eq!(found, ahead); | |
| 60 | + | assert_eq!(supported, SCHEMA_VERSION); | |
| 61 | + | // The message is the whole remedy the user gets, so it has to say which | |
| 62 | + | // side is old rather than printing two bare numbers. | |
| 63 | + | let text = err.to_string(); | |
| 64 | + | assert!(text.contains("newer version of audiofiles"), "{text}"); | |
| 65 | + | ||
| 66 | + | let after: i32 = Connection::open(&path) | |
| 67 | + | .unwrap() | |
| 68 | + | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 69 | + | .unwrap(); | |
| 70 | + | assert_eq!(after, ahead, "a refused open must not rewrite the vault"); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// The boundary is `>`, not `>=`: a vault at exactly this build's version is | |
| 74 | + | /// the ordinary case and opens with no migration run. Guards against a | |
| 75 | + | /// one-off that would refuse every up-to-date vault. | |
| 76 | + | #[test] | |
| 77 | + | fn open_accepts_a_vault_at_the_current_version() { | |
| 78 | + | let dir = tempfile::tempdir().unwrap(); | |
| 79 | + | let path = dir.path().join("audiofiles.db"); | |
| 80 | + | ||
| 81 | + | Database::open(&path).unwrap(); | |
| 82 | + | let db = Database::open(&path).expect("a current vault opens"); | |
| 83 | + | let version: i32 = db | |
| 84 | + | .conn() | |
| 85 | + | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 86 | + | .unwrap(); | |
| 87 | + | assert_eq!(version, SCHEMA_VERSION); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | #[test] | |
| 91 | + | fn foreign_keys_enforced() { | |
| 92 | + | let db = Database::open_in_memory().unwrap(); | |
| 93 | + | // Inserting a vfs_node referencing a non-existent vfs should fail | |
| 94 | + | let result = db.conn().execute( | |
| 95 | + | "INSERT INTO vfs_nodes (vfs_id, name, node_type, created_at) VALUES (999, 'test', 'directory', 0)", | |
| 96 | + | [], | |
| 97 | + | ); | |
| 98 | + | assert!(result.is_err()); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | #[test] | |
| 102 | + | fn transaction_commits_on_success() { | |
| 103 | + | let db = Database::open_in_memory().unwrap(); | |
| 104 | + | db.transaction(|_tx| { | |
| 105 | + | db.conn().execute( | |
| 106 | + | "INSERT INTO user_config (key, value) VALUES ('test_key', 'test_value')", | |
| 107 | + | [], | |
| 108 | + | )?; | |
| 109 | + | Ok(()) | |
| 110 | + | }) | |
| 111 | + | .unwrap(); | |
| 112 | + | ||
| 113 | + | let val: String = db | |
| 114 | + | .conn() | |
| 115 | + | .query_row( | |
| 116 | + | "SELECT value FROM user_config WHERE key = 'test_key'", | |
| 117 | + | [], | |
| 118 | + | |row| row.get(0), | |
| 119 | + | ) | |
| 120 | + | .unwrap(); | |
| 121 | + | assert_eq!(val, "test_value"); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | #[test] | |
| 125 | + | fn transaction_rolls_back_on_error() { | |
| 126 | + | let db = Database::open_in_memory().unwrap(); | |
| 127 | + | let result: Result<(), DbError> = db.transaction(|_tx| { | |
| 128 | + | db.conn().execute( | |
| 129 | + | "INSERT INTO user_config (key, value) VALUES ('rollback_key', 'val')", | |
| 130 | + | [], | |
| 131 | + | )?; | |
| 132 | + | Err(DbError::Sqlite(rusqlite::Error::QueryReturnedNoRows)) | |
| 133 | + | }); | |
| 134 | + | assert!(result.is_err()); | |
| 135 | + | ||
| 136 | + | let count: i64 = db | |
| 137 | + | .conn() | |
| 138 | + | .query_row( | |
| 139 | + | "SELECT COUNT(*) FROM user_config WHERE key = 'rollback_key'", | |
| 140 | + | [], | |
| 141 | + | |row| row.get(0), | |
| 142 | + | ) | |
| 143 | + | .unwrap(); | |
| 144 | + | assert_eq!(count, 0); | |
| 145 | + | } |
| @@ -1,0 +1,704 @@ | |||
| 1 | + | //! Migration-log tests: schema shape, replay, idempotence and the M018/M019 rewrites. | |
| 2 | + | //! | |
| 3 | + | //! Extracted from the former `db.rs` inline test module. | |
| 4 | + | ||
| 5 | + | use crate::db::migrations::{MIGRATION_034, MIGRATION_035, MIGRATIONS}; | |
| 6 | + | use crate::db::*; | |
| 7 | + | ||
| 8 | + | #[test] | |
| 9 | + | fn migration_034_normalises_legacy_key_spellings() { | |
| 10 | + | let db = Database::open_in_memory().unwrap(); | |
| 11 | + | // Rows written before the detector normalised its output. Inserted | |
| 12 | + | // post-migration and re-run explicitly, since an in-memory DB starts | |
| 13 | + | // empty and the migration would otherwise have nothing to rewrite. | |
| 14 | + | db.conn() | |
| 15 | + | .execute_batch( | |
| 16 | + | "INSERT INTO samples | |
| 17 | + | (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 18 | + | VALUES ('a', 'a.wav', 'wav', 1, 0, 0), ('b', 'b.wav', 'wav', 1, 0, 0), | |
| 19 | + | ('c', 'c.wav', 'wav', 1, 0, 0), ('d', 'd.wav', 'wav', 1, 0, 0); | |
| 20 | + | INSERT INTO audio_analysis | |
| 21 | + | (hash, musical_key, duration, sample_rate, channels, analyzed_at) | |
| 22 | + | VALUES ('a', 'Am', 1.0, 44100, 2, 0), ('b', 'C#m', 1.0, 44100, 2, 0), | |
| 23 | + | ('c', 'F#', 1.0, 44100, 2, 0), ('d', 'A minor', 1.0, 44100, 2, 0);", | |
| 24 | + | ) | |
| 25 | + | .unwrap(); | |
| 26 | + | db.conn().execute_batch(MIGRATION_034).unwrap(); | |
| 27 | + | ||
| 28 | + | let mut stmt = db | |
| 29 | + | .conn() | |
| 30 | + | .prepare("SELECT hash, musical_key FROM audio_analysis ORDER BY hash") | |
| 31 | + | .unwrap(); | |
| 32 | + | let got: Vec<(String, String)> = stmt | |
| 33 | + | .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) | |
| 34 | + | .unwrap() | |
| 35 | + | .map(Result::unwrap) | |
| 36 | + | .collect(); | |
| 37 | + | ||
| 38 | + | assert_eq!(got[0].1, "A minor"); | |
| 39 | + | assert_eq!(got[1].1, "C# minor"); | |
| 40 | + | assert_eq!(got[2].1, "F# major"); | |
| 41 | + | // Already canonical, must not be rewritten to "A minor major". | |
| 42 | + | assert_eq!(got[3].1, "A minor"); | |
| 43 | + | ||
| 44 | + | // Idempotent: a second application changes nothing. | |
| 45 | + | db.conn().execute_batch(MIGRATION_034).unwrap(); | |
| 46 | + | let after: String = db | |
| 47 | + | .conn() | |
| 48 | + | .query_row( | |
| 49 | + | "SELECT musical_key FROM audio_analysis WHERE hash = 'a'", | |
| 50 | + | [], | |
| 51 | + | |r| r.get(0), | |
| 52 | + | ) | |
| 53 | + | .unwrap(); | |
| 54 | + | assert_eq!(after, "A minor"); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[test] | |
| 58 | + | fn migration_035_rewrites_legacy_key_tags() { | |
| 59 | + | let db = Database::open_in_memory().unwrap(); | |
| 60 | + | db.conn() | |
| 61 | + | .execute_batch( | |
| 62 | + | "INSERT INTO samples | |
| 63 | + | (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 64 | + | VALUES ('a', 'a.wav', 'wav', 1, 0, 0), ('b', 'b.wav', 'wav', 1, 0, 0), | |
| 65 | + | ('c', 'c.wav', 'wav', 1, 0, 0); | |
| 66 | + | INSERT INTO tags (sample_hash, tag) VALUES | |
| 67 | + | ('a', 'key.am'), | |
| 68 | + | ('a', 'genre.techno'), | |
| 69 | + | ('b', 'key.c-sharpm'), | |
| 70 | + | ('b', 'key.f-sharp'), | |
| 71 | + | -- already migrated, plus its legacy twin: must not collide | |
| 72 | + | ('c', 'key.a-minor'), | |
| 73 | + | ('c', 'key.am');", | |
| 74 | + | ) | |
| 75 | + | .unwrap(); | |
| 76 | + | db.conn().execute_batch(MIGRATION_035).unwrap(); | |
| 77 | + | ||
| 78 | + | let tags = |hash: &str| -> Vec<String> { | |
| 79 | + | let mut stmt = db | |
| 80 | + | .conn() | |
| 81 | + | .prepare("SELECT tag FROM tags WHERE sample_hash = ?1 ORDER BY tag") | |
| 82 | + | .unwrap(); | |
| 83 | + | let v: Vec<String> = stmt | |
| 84 | + | .query_map([hash], |r| r.get(0)) | |
| 85 | + | .unwrap() | |
| 86 | + | .map(Result::unwrap) | |
| 87 | + | .collect(); | |
| 88 | + | v | |
| 89 | + | }; | |
| 90 | + | ||
| 91 | + | assert_eq!(tags("a"), vec!["genre.techno", "key.a-minor"]); | |
| 92 | + | assert_eq!(tags("b"), vec!["key.c-sharp-minor", "key.f-sharp-major"]); | |
| 93 | + | // The collision collapses to the single canonical tag rather than | |
| 94 | + | // failing the migration on the primary key. | |
| 95 | + | assert_eq!(tags("c"), vec!["key.a-minor"]); | |
| 96 | + | ||
| 97 | + | // Idempotent: canonical tags match no legacy spelling. | |
| 98 | + | db.conn().execute_batch(MIGRATION_035).unwrap(); | |
| 99 | + | assert_eq!(tags("a"), vec!["genre.techno", "key.a-minor"]); | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | #[test] | |
| 103 | + | fn key_migrations_do_not_enqueue_sync_changelog() { | |
| 104 | + | // Both key migrations normalise a local format that every device fixes | |
| 105 | + | // for itself. Replicating them would push canonical values to peers | |
| 106 | + | // still running the old detector, which would keep writing the compact | |
| 107 | + | // spelling and leave the vault holding both. | |
| 108 | + | let db = Database::open_in_memory().unwrap(); | |
| 109 | + | db.conn() | |
| 110 | + | .execute_batch( | |
| 111 | + | "INSERT INTO sync_state (key, value) VALUES ('applying_remote', '0') | |
| 112 | + | ON CONFLICT(key) DO UPDATE SET value = '0'; | |
| 113 | + | INSERT INTO samples | |
| 114 | + | (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 115 | + | VALUES ('a', 'a.wav', 'wav', 1, 0, 0); | |
| 116 | + | INSERT INTO audio_analysis | |
| 117 | + | (hash, musical_key, duration, sample_rate, channels, analyzed_at) | |
| 118 | + | VALUES ('a', 'Am', 1.0, 44100, 2, 0);", | |
| 119 | + | ) | |
| 120 | + | .unwrap(); | |
| 121 | + | let before: i64 = db | |
| 122 | + | .conn() | |
| 123 | + | .query_row("SELECT count(*) FROM sync_changelog", [], |r| r.get(0)) | |
| 124 | + | .unwrap(); | |
| 125 | + | ||
| 126 | + | db.conn().execute_batch(MIGRATION_034).unwrap(); | |
| 127 | + | db.conn().execute_batch(MIGRATION_035).unwrap(); | |
| 128 | + | ||
| 129 | + | let after: i64 = db | |
| 130 | + | .conn() | |
| 131 | + | .query_row("SELECT count(*) FROM sync_changelog", [], |r| r.get(0)) | |
| 132 | + | .unwrap(); | |
| 133 | + | assert_eq!( | |
| 134 | + | before, after, | |
| 135 | + | "key migrations must not enqueue changelog rows" | |
| 136 | + | ); | |
| 137 | + | ||
| 138 | + | // Control: the same write outside the migration must enqueue, otherwise | |
| 139 | + | // the assertion above would hold even if the triggers never fired here | |
| 140 | + | // and would prove nothing. | |
| 141 | + | db.conn() | |
| 142 | + | .execute_batch("UPDATE audio_analysis SET musical_key = 'B minor' WHERE hash = 'a';") | |
| 143 | + | .unwrap(); | |
| 144 | + | let control: i64 = db | |
| 145 | + | .conn() | |
| 146 | + | .query_row("SELECT count(*) FROM sync_changelog", [], |r| r.get(0)) | |
| 147 | + | .unwrap(); | |
| 148 | + | assert!( | |
| 149 | + | control > after, | |
| 150 | + | "sync trigger never fired, so the suppression assertion is vacuous" | |
| 151 | + | ); | |
| 152 | + | ||
| 153 | + | // And the flag is left back where it started, not stuck at '1', which | |
| 154 | + | // would silently stop capturing every later user edit. | |
| 155 | + | let flag: String = db | |
| 156 | + | .conn() | |
| 157 | + | .query_row( | |
| 158 | + | "SELECT value FROM sync_state WHERE key = 'applying_remote'", | |
| 159 | + | [], | |
| 160 | + | |r| r.get(0), | |
| 161 | + | ) | |
| 162 | + | .unwrap(); | |
| 163 | + | assert_eq!(flag, "0"); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | #[test] | |
| 167 | + | fn open_in_memory_creates_all_tables() { | |
| 168 | + | let db = Database::open_in_memory().unwrap(); | |
| 169 | + | ||
| 170 | + | // Exclude the FTS5 shadow tables (vfs_nodes_fts, _data, _idx, _docsize, | |
| 171 | + | // _config) created by M027, this asserts the set of logical tables. | |
| 172 | + | let tables: Vec<String> = db | |
| 173 | + | .conn() | |
| 174 | + | .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'vfs_nodes_fts%' ORDER BY name") | |
| 175 | + | .unwrap() | |
| 176 | + | .query_map([], |row| row.get(0)) | |
| 177 | + | .unwrap() | |
| 178 | + | .collect::<Result<_, _>>() | |
| 179 | + | .unwrap(); | |
| 180 | + | ||
| 181 | + | let expected = vec![ | |
| 182 | + | "audio_analysis", | |
| 183 | + | "classifier_exemplars", | |
| 184 | + | "classifier_layer_rules", | |
| 185 | + | "classifier_layers", | |
| 186 | + | "cluster_members", | |
| 187 | + | "clusters", | |
| 188 | + | "collection_members", | |
| 189 | + | "collections", | |
| 190 | + | "config_key_policy", | |
| 191 | + | "edit_history", | |
| 192 | + | "fingerprints", | |
| 193 | + | "hlc_ledger", | |
| 194 | + | "neighbour_graph_dirty", | |
| 195 | + | "neighbour_graph_meta", | |
| 196 | + | "sample_features", | |
| 197 | + | "sample_neighbours", | |
| 198 | + | "samples", | |
| 199 | + | "sync_changelog", | |
| 200 | + | "sync_state", | |
| 201 | + | "tag_policy", | |
| 202 | + | "tag_provenance", | |
| 203 | + | "tag_rules", | |
| 204 | + | "tags", | |
| 205 | + | "trained_head", | |
| 206 | + | "user_config", | |
| 207 | + | "vfs", | |
| 208 | + | "vfs_nodes", | |
| 209 | + | "waveform_data", | |
| 210 | + | ]; | |
| 211 | + | assert_eq!(tables, expected); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[test] | |
| 215 | + | fn migration_sets_user_version() { | |
| 216 | + | let db = Database::open_in_memory().unwrap(); | |
| 217 | + | let version: i32 = db | |
| 218 | + | .conn() | |
| 219 | + | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 220 | + | .unwrap(); | |
| 221 | + | assert_eq!(version, SCHEMA_VERSION); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | #[test] | |
| 225 | + | fn migration_is_idempotent() { | |
| 226 | + | let db = Database::open_in_memory().unwrap(); | |
| 227 | + | // Opening again on the same connection shouldn't fail | |
| 228 | + | let version: i32 = db | |
| 229 | + | .conn() | |
| 230 | + | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 231 | + | .unwrap(); | |
| 232 | + | assert_eq!(version, SCHEMA_VERSION); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | #[test] | |
| 236 | + | fn audio_analysis_sync_triggers_carry_all_columns() { | |
| 237 | + | // Regression guard for the M018 -> M032 fix: M018 recreated these | |
| 238 | + | // triggers with the pre-M011 column list, so edits to the columns below | |
| 239 | + | // stopped propagating to sync_changelog (and thus across devices). | |
| 240 | + | // Assert the live trigger bodies emit every later-added analysis column. | |
| 241 | + | // (classification_confidence was in this list until it was retired.) | |
| 242 | + | let db = Database::open_in_memory().unwrap(); | |
| 243 | + | let later_columns = [ | |
| 244 | + | "spectral_bandwidth", | |
| 245 | + | "centroid_variance", | |
| 246 | + | "crest_factor", | |
| 247 | + | "attack_time", | |
| 248 | + | ]; | |
| 249 | + | for trigger in ["sync_audio_analysis_insert", "sync_audio_analysis_update"] { | |
| 250 | + | let sql: String = db | |
| 251 | + | .conn() | |
| 252 | + | .query_row( | |
| 253 | + | "SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1", | |
| 254 | + | [trigger], | |
| 255 | + | |row| row.get(0), | |
| 256 | + | ) | |
| 257 | + | .unwrap(); | |
| 258 | + | for col in later_columns { | |
| 259 | + | assert!(sql.contains(col), "{trigger} is missing column {col}"); | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | /// The retired sample-class columns must not come back. They were removed from | |
| 265 | + | /// the migration bodies that added them (M003, M011) rather than dropped by a | |
| 266 | + | /// later migration, which is only safe while nothing is deployed, so this is | |
| 267 | + | /// the guard that the edit stays coherent: absent from the table, and absent | |
| 268 | + | /// from the changelog payload a peer would receive. | |
| 269 | + | #[test] | |
| 270 | + | fn retired_class_columns_are_absent() { | |
| 271 | + | let db = Database::open_in_memory().unwrap(); | |
| 272 | + | let columns: Vec<String> = db | |
| 273 | + | .conn() | |
| 274 | + | .prepare("SELECT name FROM pragma_table_info('audio_analysis')") | |
| 275 | + | .unwrap() | |
| 276 | + | .query_map([], |row| row.get(0)) | |
| 277 | + | .unwrap() | |
| 278 | + | .collect::<Result<_, _>>() | |
| 279 | + | .unwrap(); | |
| 280 | + | for retired in ["classification", "classification_confidence"] { | |
| 281 | + | assert!( | |
| 282 | + | !columns.iter().any(|c| c == retired), | |
| 283 | + | "audio_analysis still has {retired}" | |
| 284 | + | ); | |
| 285 | + | for trigger in ["sync_audio_analysis_insert", "sync_audio_analysis_update"] { | |
| 286 | + | let sql: String = db | |
| 287 | + | .conn() | |
| 288 | + | .query_row( | |
| 289 | + | "SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1", | |
| 290 | + | [trigger], | |
| 291 | + | |row| row.get(0), | |
| 292 | + | ) | |
| 293 | + | .unwrap(); | |
| 294 | + | assert!(!sql.contains(retired), "{trigger} still emits {retired}"); | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// Open a fresh file-backed DB, close, reopen. The second open re-enters | |
| 300 | + | /// `migrate()`; with `user_version=17` no migration body runs, but the | |
| 301 | + | /// shape verifies our open/close cycle is clean (no locks, no WAL leak). | |
| 302 | + | #[test] | |
| 303 | + | fn migration_replay_from_file_no_op() { | |
| 304 | + | let dir = tempfile::tempdir().unwrap(); | |
| 305 | + | let path = dir.path().join("audiofiles.db"); | |
| 306 | + | ||
| 307 | + | let db = Database::open(&path).unwrap(); | |
| 308 | + | drop(db); | |
| 309 | + | ||
| 310 | + | let db = Database::open(&path).unwrap(); | |
| 311 | + | let version: i32 = db | |
| 312 | + | .conn() | |
| 313 | + | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 314 | + | .unwrap(); | |
| 315 | + | assert_eq!(version, SCHEMA_VERSION); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | /// Simulates the worst-case recovery path: a prior partial migration left | |
| 319 | + | /// every object in place but `user_version` rolled back. Re-running | |
| 320 | + | /// `migrate()` against the pre-populated schema must succeed without | |
| 321 | + | /// silent failure. This catches the "silent failure → bump user_version" | |
| 322 | + | /// bug class for every migration past the inherently-one-shot ones. | |
| 323 | + | /// | |
| 324 | + | /// The inherently-one-shot migrations are excluded from this replay | |
| 325 | + | /// loop: | |
| 326 | + | /// * M001, initial schema; bare CREATE TABLEs, runs against an empty DB. | |
| 327 | + | /// * M002, `DROP TABLE tags; ALTER tags_v2 RENAME TO tags` rebuild dance. | |
| 328 | + | /// * M015, adds `collections.filter_json` and backfills from | |
| 329 | + | /// `smart_folders`, then drops `smart_folders`. The backfill SELECT | |
| 330 | + | /// references a table that no longer exists after the migration runs, | |
| 331 | + | /// so it cannot parse on replay against a post-M015 schema. None of | |
| 332 | + | /// these need replay safety: SQLite's atomic-transaction guarantee | |
| 333 | + | /// means each migration either fully commits or fully rolls back, so | |
| 334 | + | /// the realistic recovery scenario is "re-apply the one migration | |
| 335 | + | /// that crashed", not "re-apply every migration from scratch". | |
| 336 | + | /// | |
| 337 | + | /// Every migration from M003 onward (excluding M015) MUST be | |
| 338 | + | /// replay-safe against a populated schema; if you add a new one that | |
| 339 | + | /// isn't, this test fails and you should add `IF NOT EXISTS` / | |
| 340 | + | /// `DROP IF EXISTS` / `INSERT OR IGNORE` accordingly, or add it to the | |
| 341 | + | /// one-shot list above with a clear rationale. | |
| 342 | + | #[test] | |
| 343 | + | fn migration_replay_from_version_fifteen_against_full_schema() { | |
| 344 | + | let dir = tempfile::tempdir().unwrap(); | |
| 345 | + | let path = dir.path().join("audiofiles.db"); | |
| 346 | + | ||
| 347 | + | Database::open(&path).unwrap(); | |
| 348 | + | ||
| 349 | + | { | |
| 350 | + | let conn = Connection::open(&path).unwrap(); | |
| 351 | + | conn.execute_batch("PRAGMA user_version = 15").unwrap(); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | let db = Database::open(&path).unwrap(); | |
| 355 | + | let version: i32 = db | |
| 356 | + | .conn() | |
| 357 | + | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 358 | + | .unwrap(); | |
| 359 | + | assert_eq!(version, SCHEMA_VERSION); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | /// `SCHEMA_VERSION` is derived from `MIGRATIONS`, and the migration runner | |
| 363 | + | /// keys the version it writes off the same list. Pinning the number here | |
| 364 | + | /// means adding a migration without meaning to shows up as a failure. | |
| 365 | + | #[test] | |
| 366 | + | fn schema_version_matches_the_migration_list() { | |
| 367 | + | assert_eq!(SCHEMA_VERSION, 39); | |
| 368 | + | assert_eq!(SCHEMA_VERSION as usize, MIGRATIONS.len()); | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | /// M037 contract: the browse-list sort must not build a temp B-tree. | |
| 372 | + | /// | |
| 373 | + | /// Asserting the index exists would be the weaker test, because an index | |
| 374 | + | /// SQLite declines to use buys nothing. What actually regressed here was the | |
| 375 | + | /// PLAN: `SCAN n` plus `USE TEMP B-TREE FOR ORDER BY` sorted the whole | |
| 376 | + | /// library to return 500 rows, which cost 63 ms at 40k nodes against 0.81 ms | |
| 377 | + | /// once the sort could be walked from the index. So this pins the plan. | |
| 378 | + | /// | |
| 379 | + | /// It breaks if someone changes the ORDER BY in `search_global` without | |
| 380 | + | /// moving the index with it, which is the failure that would silently | |
| 381 | + | /// restore the full sort. | |
| 382 | + | #[test] | |
| 383 | + | fn m037_browse_sort_uses_the_index_and_not_a_temp_btree() { | |
| 384 | + | let db = Database::open_in_memory().unwrap(); | |
| 385 | + | let plan: Vec<String> = db | |
| 386 | + | .conn() | |
| 387 | + | .prepare( | |
| 388 | + | "EXPLAIN QUERY PLAN | |
| 389 | + | SELECT n.id, n.name FROM vfs_nodes n | |
| 390 | + | LEFT JOIN audio_analysis a ON n.sample_hash = a.hash | |
| 391 | + | LEFT JOIN samples s ON n.sample_hash = s.hash | |
| 392 | + | WHERE s.deleted_at IS NULL | |
| 393 | + | ORDER BY n.node_type ASC, n.name ASC LIMIT 500", | |
| 394 | + | ) | |
| 395 | + | .unwrap() | |
| 396 | + | .query_map([], |row| row.get::<_, String>(3)) | |
| 397 | + | .unwrap() | |
| 398 | + | .collect::<std::result::Result<Vec<_>, _>>() | |
| 399 | + | .unwrap(); | |
| 400 | + | let plan = plan.join("\n"); | |
| 401 | + | ||
| 402 | + | assert!( | |
| 403 | + | !plan.to_uppercase().contains("TEMP B-TREE"), | |
| 404 | + | "browse sort fell back to a full sort:\n{plan}" | |
| 405 | + | ); | |
| 406 | + | assert!( | |
| 407 | + | plan.contains("idx_vfs_nodes_sort"), | |
| 408 | + | "browse sort is not walking the sort index:\n{plan}" | |
| 409 | + | ); | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | /// M018 contract: the `sync_changelog.row_id` for sensitive tables must | |
| 413 | + | /// be a 64-hex SHA-256 (per `hash_row_id`), NOT the cleartext content | |
| 414 | + | /// fingerprint or tag string. The cleartext key lives only in `data`. | |
| 415 | + | /// This test is the regression gate for the upload audit fix. | |
| 416 | + | #[test] | |
| 417 | + | fn m018_hashes_sensitive_row_ids() { | |
| 418 | + | let db = Database::open_in_memory().unwrap(); | |
| 419 | + | let conn = db.conn(); | |
| 420 | + | ||
| 421 | + | // Seed: insert a sample and a tag. Both should fire triggers that | |
| 422 | + | // write to sync_changelog with a hashed row_id. | |
| 423 | + | conn.execute( | |
| 424 | + | "INSERT INTO samples (hash, original_name, file_extension, file_size, \ | |
| 425 | + | import_date, last_modified) VALUES \ | |
| 426 | + | ('abc123', 'kick.wav', 'wav', 100, 0, 0)", | |
| 427 | + | [], | |
| 428 | + | ) | |
| 429 | + | .unwrap(); | |
| 430 | + | conn.execute( | |
| 431 | + | "INSERT INTO tags (sample_hash, tag) VALUES ('abc123', 'drums')", | |
| 432 | + | [], | |
| 433 | + | ) | |
| 434 | + | .unwrap(); | |
| 435 | + | ||
| 436 | + | // samples row_id: 64-hex hash, NOT "abc123". | |
| 437 | + | let row_id: String = conn | |
| 438 | + | .query_row( | |
| 439 | + | "SELECT row_id FROM sync_changelog WHERE table_name = 'samples' AND op = 'INSERT'", | |
| 440 | + | [], | |
| 441 | + | |row| row.get(0), | |
| 442 | + | ) | |
| 443 | + | .unwrap(); | |
| 444 | + | assert_eq!(row_id.len(), 64, "row_id should be SHA-256 hex"); | |
| 445 | + | assert!(row_id.chars().all(|c| c.is_ascii_hexdigit())); | |
| 446 | + | assert_ne!(row_id, "abc123", "cleartext sample hash must not leak"); | |
| 447 | + | ||
| 448 | + | // tags row_id: 64-hex hash, NOT "abc123:drums". | |
| 449 | + | let row_id: String = conn | |
| 450 | + | .query_row( | |
| 451 | + | "SELECT row_id FROM sync_changelog WHERE table_name = 'tags' AND op = 'INSERT'", | |
| 452 | + | [], | |
| 453 | + | |row| row.get(0), | |
| 454 | + | ) | |
| 455 | + | .unwrap(); | |
| 456 | + | assert_eq!(row_id.len(), 64); | |
| 457 | + | assert_ne!(row_id, "abc123:drums", "cleartext tag string must not leak"); | |
| 458 | + | ||
| 459 | + | // Salted: hash depends on the per-user salt, so two fresh DBs see | |
| 460 | + | // different row_ids for the same logical key. | |
| 461 | + | let db2 = Database::open_in_memory().unwrap(); | |
| 462 | + | let conn2 = db2.conn(); | |
| 463 | + | conn2 | |
| 464 | + | .execute( | |
| 465 | + | "INSERT INTO samples (hash, original_name, file_extension, file_size, \ | |
| 466 | + | import_date, last_modified) VALUES \ | |
| 467 | + | ('abc123', 'kick.wav', 'wav', 100, 0, 0)", | |
| 468 | + | [], | |
| 469 | + | ) | |
| 470 | + | .unwrap(); | |
| 471 | + | let row_id2: String = conn2 | |
| 472 | + | .query_row( | |
| 473 | + | "SELECT row_id FROM sync_changelog WHERE table_name = 'samples' AND op = 'INSERT'", | |
| 474 | + | [], | |
| 475 | + | |row| row.get(0), | |
| 476 | + | ) | |
| 477 | + | .unwrap(); | |
| 478 | + | assert_ne!(row_id, row_id2, "salt should differ between DBs"); | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | /// M018 contract: DELETE rows must carry the canonical PK in `data` so | |
| 482 | + | /// the receiving device's `resolve::apply_delete` can reconstruct the | |
| 483 | + | /// WHERE clause without parsing the (now-hashed) row_id. | |
| 484 | + | #[test] | |
| 485 | + | fn m018_delete_triggers_emit_canonical_key_in_data() { | |
| 486 | + | let db = Database::open_in_memory().unwrap(); | |
| 487 | + | let conn = db.conn(); | |
| 488 | + | ||
| 489 | + | conn.execute( | |
| 490 | + | "INSERT INTO samples (hash, original_name, file_extension, file_size, \ | |
| 491 | + | import_date, last_modified) VALUES \ | |
| 492 | + | ('abc', 'k.wav', 'wav', 1, 0, 0)", | |
| 493 | + | [], | |
| 494 | + | ) | |
| 495 | + | .unwrap(); | |
| 496 | + | conn.execute( | |
| 497 | + | "INSERT INTO tags (sample_hash, tag) VALUES ('abc', 'kick')", | |
| 498 | + | [], | |
| 499 | + | ) | |
| 500 | + | .unwrap(); |
Lines truncated
| @@ -1,0 +1,8 @@ | |||
| 1 | + | //! Tests for the database wrapper. | |
| 2 | + | //! | |
| 3 | + | //! Extracted from the former `db.rs` inline test module. | |
| 4 | + | ||
| 5 | + | mod config; | |
| 6 | + | mod lifecycle; | |
| 7 | + | mod migrations; | |
| 8 | + | mod stats; |
| @@ -1,0 +1,69 @@ | |||
| 1 | + | //! Tests for the storage-accounting queries. | |
| 2 | + | //! | |
| 3 | + | //! Extracted from the former `db.rs` inline test module. | |
| 4 | + | ||
| 5 | + | use crate::db::*; | |
| 6 | + | ||
| 7 | + | /// The need blob sync computes is a union over synced VFSes, not a sum. | |
| 8 | + | /// | |
| 9 | + | /// The distinction is the whole reason `synced_storage_stats` exists | |
| 10 | + | /// separately from summing `vfs_storage_stats`: a sample placed in two | |
| 11 | + | /// synced VFSes uploads once, because blobs are content-addressed. Summing | |
| 12 | + | /// would report 300 here and propose a cap for storage nobody needs. | |
| 13 | + | #[test] | |
| 14 | + | fn synced_storage_counts_a_shared_sample_once() { | |
| 15 | + | let db = Database::open_in_memory().unwrap(); | |
| 16 | + | db.conn() | |
| 17 | + | .execute_batch( | |
| 18 | + | "INSERT INTO samples | |
| 19 | + | (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 20 | + | VALUES ('shared', 's.wav', 'wav', 100, 0, 0), | |
| 21 | + | ('only_a', 'a.wav', 'wav', 50, 0, 0), | |
| 22 | + | ('unsynced', 'u.wav', 'wav', 999, 0, 0); | |
| 23 | + | INSERT INTO vfs (id, name, created_at, modified_at, sync_files) | |
| 24 | + | VALUES (1, 'A', 0, 0, 1), (2, 'B', 0, 0, 1), (3, 'Off', 0, 0, 0); | |
| 25 | + | INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) | |
| 26 | + | VALUES (1, NULL, 's.wav', 'sample', 'shared', 0), | |
| 27 | + | (2, NULL, 's.wav', 'sample', 'shared', 0), | |
| 28 | + | (1, NULL, 'a.wav', 'sample', 'only_a', 0), | |
| 29 | + | (3, NULL, 'u.wav', 'sample', 'unsynced', 0);", | |
| 30 | + | ) | |
| 31 | + | .unwrap(); | |
| 32 | + | ||
| 33 | + | let (count, bytes) = db.synced_storage_stats().unwrap(); | |
| 34 | + | assert_eq!( | |
| 35 | + | count, 2, | |
| 36 | + | "the shared sample counts once, the unsynced not at all" | |
| 37 | + | ); | |
| 38 | + | assert_eq!(bytes, 150, "100 + 50; summing the two VFSes would say 250"); | |
| 39 | + | ||
| 40 | + | // The per-VFS figures are what a naive sum would have used. | |
| 41 | + | assert_eq!(db.vfs_storage_stats(1).unwrap(), (2, 150)); | |
| 42 | + | assert_eq!(db.vfs_storage_stats(2).unwrap(), (1, 100)); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// Nothing set to sync means nothing would upload, and the honest answer is | |
| 46 | + | /// zero rather than the whole library. A default vault has `sync_files = 0` | |
| 47 | + | /// on every VFS, so this is the state a new user is actually in. | |
| 48 | + | #[test] | |
| 49 | + | fn synced_storage_is_zero_when_no_vfs_syncs_files() { | |
| 50 | + | let db = Database::open_in_memory().unwrap(); | |
| 51 | + | db.conn() | |
| 52 | + | .execute_batch( | |
| 53 | + | "INSERT INTO samples | |
| 54 | + | (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 55 | + | VALUES ('a', 'a.wav', 'wav', 100, 0, 0); | |
| 56 | + | INSERT INTO vfs (id, name, created_at, modified_at, sync_files) | |
| 57 | + | VALUES (1, 'A', 0, 0, 0); | |
| 58 | + | INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) | |
| 59 | + | VALUES (1, NULL, 'a.wav', 'sample', 'a', 0);", | |
| 60 | + | ) | |
| 61 | + | .unwrap(); | |
| 62 | + | ||
| 63 | + | assert_eq!(db.synced_storage_stats().unwrap(), (0, 0)); | |
| 64 | + | assert_eq!( | |
| 65 | + | db.storage_stats().unwrap(), | |
| 66 | + | (1, 100), | |
| 67 | + | "the library is not empty" | |
| 68 | + | ); | |
| 69 | + | } |