Skip to main content

max / audiofiles

Split audiofiles-core's db.rs into a module directory db.rs was 3331 lines: a Database type, 39 migration constants, three storage-stats queries and 1000 lines of tests in one file. It becomes db/mod.rs at 334 lines holding the type and its lifecycle, with migrations.rs, stats.rs, and a five-file tests/ tree beside it. Every public path survives through the facade. SCHEMA_VERSION moves into migrations.rs and is re-exported; MIGRATIONS, register_hash_row_id and MIGRATION_034/035 were private and are now pub(super), which is narrower than crate-public, so no surface is added. No consumer line changed. The tombstone seal learns the third form of the test convention. It already skipped an inline module and a tests.rs sibling; this split creates the crate's first tests/ directory, so the skip becomes a path component check. The rule it guards is unchanged: no raw FROM samples read outside test code without a justification. migrations.rs lands at 1676 production lines and is over budget. It is 89% SQL inside string literals, 1696 of its 1912 lines, so it wants the include_str! remedy rather than a further module split. Filed separately.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-04 15:02 UTC
Signed with PGP, not checked
Commit: ef67eff98065afa5e10fbffc8c568c95145de0ec
Parent: 3ee56a2
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 + }