Files
Psychotoxical-psysonic/src-tauri/crates/psysonic-library/src/store.rs
T
cucadmuh b2a5baa48d fix(library-db): name slow-write ops for macOS stall diagnosis (#1043)
* fix(library-db): name slow-write ops for macOS stall diagnosis (#1040)

Replace generic op=misc labels on production library-db write paths with
stable module.action names (sync_state.*, track.*, tombstone.*, cmd.*, …)
so SLOW write logs pinpoint the call site. Document the naming convention
on LibraryStore::with_conn.

Diagnostic step for #1040 — no behaviour change.

* docs: add CHANGELOG and credits for PR #1043

Library-db slow-write op naming diagnostic for issue #1040.
2026-06-09 11:03:22 +03:00

726 lines
26 KiB
Rust

use std::path::{Path, PathBuf};
use std::{fs, io};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use rusqlite::{params, Connection, OpenFlags};
use tauri::Manager;
/// Current head of the embedded migrations. Bump each time a new
/// `migrations/NNN_*.sql` is added.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
/// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook
/// fires (spec §5.7 / P22): the library is treated as incompatible, must be
/// dropped, and initial sync must restart.
///
/// At v1 launch this equals `LIBRARY_DB_SCHEMA_VERSION` — no real DB can
/// trip the hook. Bump independently of `SCHEMA_VERSION` only when a
/// migration cannot be expressed additively.
pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1;
pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL)];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationOutcome {
/// Every missing migration was applied (or the DB was already at head).
Applied,
/// The DB carried a schema below `LIBRARY_DB_MIN_COMPATIBLE_VERSION`,
/// so the breaking-bump hook fired. Callers should treat the library
/// data as discarded and trigger a fresh initial sync (P22).
BreakingBump,
}
/// In-memory tests share one DB across the read/write pair in a single store.
static IN_MEMORY_DB_COUNTER: AtomicU64 = AtomicU64::new(0);
fn in_memory_uri() -> String {
let n = IN_MEMORY_DB_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("file:psysonic_library_mem_{n}?mode=memory&cache=shared")
}
pub struct LibraryStore {
/// Writes, migrations, and sync ingest (single writer).
write_conn: Mutex<Connection>,
/// Read-only handle for search / status / hydrate while sync writes (WAL).
read_conn: Mutex<Connection>,
/// IS-3 bulk ingest in progress — read paths skip write-lock work.
bulk_ingest_active: AtomicBool,
}
impl LibraryStore {
pub fn init(app: &tauri::AppHandle) -> Result<Self, String> {
let db_path = library_db_path(app)?;
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
Self::open_file(&db_path)
}
fn open_file(db_path: &Path) -> Result<Self, String> {
let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?;
configure_write_connection(&write_conn).map_err(|e| e.to_string())?;
run_migrations(&write_conn).map_err(|e| e.to_string())?;
checkpoint_wal_conn(&write_conn, "open").map_err(|e| e.to_string())?;
let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&read_conn).map_err(|e| e.to_string())?;
Ok(Self {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
bulk_ingest_active: AtomicBool::new(false),
})
}
/// Build an in-memory DB with the production schema applied.
pub fn open_in_memory() -> Self {
let uri = in_memory_uri();
let write_conn = Connection::open(&uri).expect("in-memory write connection");
configure_write_connection(&write_conn).expect("write pragmas");
run_migrations(&write_conn).expect("schema migration");
let read_conn = Connection::open(&uri).expect("in-memory read connection");
configure_read_connection(&read_conn).expect("read pragmas");
Self {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
bulk_ingest_active: AtomicBool::new(false),
}
}
pub(crate) fn set_bulk_ingest_active(&self, active: bool) {
self.bulk_ingest_active
.store(active, Ordering::Release);
}
pub(crate) fn bulk_ingest_active(&self) -> bool {
self.bulk_ingest_active.load(Ordering::Acquire)
}
/// Writer connection — sync ingest, migrations, mutations.
///
/// `op` is logged on slow writes (`[library-db] SLOW write op=…`) — use a
/// stable `module.action` label (e.g. `sync_state.set_sync_phase`,
/// `track.upsert_batch_remap`), not the generic `"misc"`, so production
/// stalls can be attributed to a specific call site.
pub(crate) fn with_conn<R>(
&self,
op: &'static str,
f: impl FnOnce(&Connection) -> rusqlite::Result<R>,
) -> Result<R, String> {
let lock_start = std::time::Instant::now();
let conn = self
.write_conn
.lock()
.map_err(|_| "library store write lock poisoned".to_string())?;
let lock_wait_ms = lock_start.elapsed().as_millis();
let exec_start = std::time::Instant::now();
let out = f(&conn).map_err(|e| e.to_string());
let exec_ms = exec_start.elapsed().as_millis();
log_write_op(op, lock_wait_ms, exec_ms);
out
}
/// Read-only connection — search, status, hydrate; does not block on sync writes.
pub(crate) fn with_read_conn<R>(
&self,
f: impl FnOnce(&Connection) -> rusqlite::Result<R>,
) -> Result<R, String> {
let conn = self
.read_conn
.lock()
.map_err(|_| "library store read lock poisoned".to_string())?;
f(&conn).map_err(|e| e.to_string())
}
pub(crate) fn with_conn_mut<R>(
&self,
op: &'static str,
f: impl FnOnce(&mut Connection) -> rusqlite::Result<R>,
) -> Result<R, String> {
self.with_conn_mut_timed(op, f).map(|(value, _)| value)
}
pub(crate) fn with_conn_mut_timed<R>(
&self,
op: &'static str,
f: impl FnOnce(&mut Connection) -> rusqlite::Result<R>,
) -> Result<(R, WriteOpTiming), String> {
let lock_start = std::time::Instant::now();
let mut conn = self
.write_conn
.lock()
.map_err(|_| "library store write lock poisoned".to_string())?;
let lock_wait_ms = lock_start.elapsed().as_millis() as u64;
let exec_start = std::time::Instant::now();
let out = f(&mut conn).map_err(|e| e.to_string())?;
let exec_ms = exec_start.elapsed().as_millis() as u64;
log_write_op(op, lock_wait_ms as u128, exec_ms as u128);
Ok((out, WriteOpTiming { lock_wait_ms, exec_ms }))
}
pub(crate) fn checkpoint_wal(&self, op: &'static str) -> Result<(), String> {
self.with_conn_mut(op, |conn| {
checkpoint_wal_conn(conn, op)?;
Ok(())
})
}
/// Atomically switch the active sqlite file while replacing long-lived
/// write/read connections under the same locks so no command can keep
/// writing to the old inode after the swap.
pub fn swap_database_file(
&self,
active_path: &Path,
destination_path: &Path,
) -> Result<Option<PathBuf>, String> {
if !destination_path.exists() {
return Ok(None);
}
let mut write_conn = self
.write_conn
.lock()
.map_err(|_| "library store write lock poisoned".to_string())?;
let mut read_conn = self
.read_conn
.lock()
.map_err(|_| "library store read lock poisoned".to_string())?;
let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
let old_write = std::mem::replace(&mut *write_conn, write_tmp);
let old_read = std::mem::replace(&mut *read_conn, read_tmp);
drop(old_write);
drop(old_read);
let backup = active_path.with_file_name(format!(
"{}.backup-pre-indexkey",
active_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("library.sqlite")
));
remove_db_with_sidecars(&backup).ok();
if active_path.exists() {
fs::rename(active_path, &backup).map_err(|e| e.to_string())?;
move_sidecar(active_path, &backup, "-wal")?;
move_sidecar(active_path, &backup, "-shm")?;
}
if let Err(err) = fs::rename(destination_path, active_path) {
if backup.exists() {
let _ = fs::rename(&backup, active_path);
let _ = move_sidecar(&backup, active_path, "-wal");
let _ = move_sidecar(&backup, active_path, "-shm");
}
return Err(err.to_string());
}
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
*write_conn = reopened_write;
*read_conn = reopened_read;
Ok(Some(backup))
}
pub fn restore_database_backup(&self, backup_path: &Path, active_path: &Path) -> Result<(), String> {
let mut write_conn = self
.write_conn
.lock()
.map_err(|_| "library store write lock poisoned".to_string())?;
let mut read_conn = self
.read_conn
.lock()
.map_err(|_| "library store read lock poisoned".to_string())?;
let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
let old_write = std::mem::replace(&mut *write_conn, write_tmp);
let old_read = std::mem::replace(&mut *read_conn, read_tmp);
drop(old_write);
drop(old_read);
if active_path.exists() {
remove_db_with_sidecars(active_path)?;
}
if backup_path.exists() {
fs::rename(backup_path, active_path).map_err(|e| e.to_string())?;
move_sidecar(backup_path, active_path, "-wal")?;
move_sidecar(backup_path, active_path, "-shm")?;
}
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
*write_conn = reopened_write;
*read_conn = reopened_read;
Ok(())
}
}
/// Timing split returned to ingest progress (DevTools / terminal).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WriteOpTiming {
pub lock_wait_ms: u64,
pub exec_ms: u64,
}
impl WriteOpTiming {
pub fn total_ms(&self) -> u64 {
self.lock_wait_ms.saturating_add(self.exec_ms)
}
}
fn log_write_op(op: &str, lock_wait_ms: u128, exec_ms: u128) {
if lock_wait_ms >= 1000 || exec_ms >= 1000 {
crate::app_eprintln!(
"[library-db] SLOW write op={op} lock_wait_ms={lock_wait_ms} exec_ms={exec_ms}"
);
} else if lock_wait_ms >= 50 || exec_ms >= 200 {
crate::app_eprintln!("[library-db] write op={op} lock_wait_ms={lock_wait_ms} exec_ms={exec_ms}");
}
}
fn library_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
let db_dir = base.join("databases").join("library");
let db_path = db_dir.join("library.sqlite");
let legacy = base.join("library.sqlite");
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
if db_path.exists() {
cleanup_legacy_db_if_present(&legacy, &db_path)?;
return Ok(db_path);
}
if legacy.exists() {
migrate_db_file(&legacy, &db_path).map_err(|e| e.to_string())?;
migrate_db_sidecar(&legacy, &db_path, "-wal").map_err(|e| e.to_string())?;
migrate_db_sidecar(&legacy, &db_path, "-shm").map_err(|e| e.to_string())?;
}
cleanup_legacy_db_if_present(&legacy, &db_path)?;
Ok(db_path)
}
fn cleanup_legacy_db_if_present(legacy_path: &Path, active_path: &Path) -> Result<(), String> {
if legacy_path == active_path {
return Ok(());
}
remove_db_with_sidecars(legacy_path)
}
fn migrate_db_file(from: &Path, to: &Path) -> io::Result<()> {
if let Some(parent) = to.parent() {
fs::create_dir_all(parent)?;
}
match fs::rename(from, to) {
Ok(()) => Ok(()),
Err(_) => {
fs::copy(from, to)?;
fs::remove_file(from)?;
Ok(())
}
}
}
fn migrate_db_sidecar(from: &Path, to: &Path, suffix: &str) -> io::Result<()> {
let from_path = PathBuf::from(format!("{}{}", from.display(), suffix));
if !from_path.exists() {
return Ok(());
}
let to_path = PathBuf::from(format!("{}{}", to.display(), suffix));
if let Some(parent) = to_path.parent() {
fs::create_dir_all(parent)?;
}
match fs::rename(&from_path, &to_path) {
Ok(()) => Ok(()),
Err(_) => {
fs::copy(&from_path, &to_path)?;
fs::remove_file(&from_path)?;
Ok(())
}
}
}
fn move_sidecar(from_base: &Path, to_base: &Path, suffix: &str) -> Result<(), String> {
let from = PathBuf::from(format!("{}{}", from_base.display(), suffix));
if !from.exists() {
return Ok(());
}
let to = PathBuf::from(format!("{}{}", to_base.display(), suffix));
if let Some(parent) = to.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::rename(from, to).map_err(|e| e.to_string())
}
fn remove_db_with_sidecars(path: &Path) -> Result<(), String> {
if path.exists() {
fs::remove_file(path).map_err(|e| e.to_string())?;
}
for suffix in ["-wal", "-shm"] {
let sidecar = PathBuf::from(format!("{}{}", path.display(), suffix));
if sidecar.exists() {
fs::remove_file(sidecar).map_err(|e| e.to_string())?;
}
}
Ok(())
}
fn configure_write_connection(conn: &Connection) -> rusqlite::Result<()> {
conn.busy_timeout(Duration::from_secs(30))?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
Ok(())
}
fn configure_read_connection(conn: &Connection) -> rusqlite::Result<()> {
conn.busy_timeout(Duration::from_secs(5))?;
conn.pragma_update(None, "foreign_keys", "ON")?;
// Search / browse hot path on large libraries (read-only handle).
conn.pragma_update(None, "cache_size", -64_000)?;
Ok(())
}
fn checkpoint_wal_conn(conn: &Connection, op: &str) -> rusqlite::Result<()> {
let (busy, log, checkpointed): (i32, i32, i32) =
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})?;
if busy != 0 {
crate::app_eprintln!(
"[library-db] wal checkpoint busy op={op} busy={busy} log={log} checkpointed={checkpointed}"
);
}
Ok(())
}
fn run_migrations(conn: &Connection) -> rusqlite::Result<MigrationOutcome> {
run_migrations_with(
conn,
MIGRATIONS,
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
handle_breaking_schema_bump,
)
}
/// Test-friendly entry point. Production code goes through `run_migrations`,
/// which fixes `migrations`, `min_compatible`, and `hook` to the prod values.
pub(crate) fn run_migrations_with(
conn: &Connection,
migrations: &[(i64, &str)],
min_compatible: i64,
hook: fn(&Connection, i64, i64) -> rusqlite::Result<()>,
) -> rusqlite::Result<MigrationOutcome> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);",
)?;
// Breaking-bump detection only meaningful for already-initialised DBs.
let max_applied: Option<i64> = conn.query_row(
"SELECT MAX(version) FROM schema_migrations",
[],
|row| row.get::<_, Option<i64>>(0),
)?;
if let Some(max_applied) = max_applied {
if max_applied < min_compatible {
hook(conn, max_applied, LIBRARY_DB_SCHEMA_VERSION)?;
return Ok(MigrationOutcome::BreakingBump);
}
}
let mut ordered: Vec<(i64, &str)> = migrations.iter().map(|(v, s)| (*v, *s)).collect();
ordered.sort_by_key(|(v, _)| *v);
for (version, sql) in ordered {
let already: i64 = conn.query_row(
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?1",
params![version],
|row| row.get(0),
)?;
if already > 0 {
continue;
}
conn.execute_batch(sql)?;
conn.execute(
"INSERT INTO schema_migrations (version, applied_at) VALUES (?1, strftime('%s','now'))",
params![version],
)?;
}
Ok(MigrationOutcome::Applied)
}
/// P22 breaking-schema-bump hook. PR-1b ships a no-op stub: the function
/// signature, call site, and `MigrationOutcome::BreakingBump` signal are in
/// place, but the actual library-drop + sync-reset logic lands when the
/// first real breaking bump happens. Until then the constants guarantee the
/// hook never fires on production data.
fn handle_breaking_schema_bump(
_conn: &Connection,
_max_applied: i64,
_target_version: i64,
) -> rusqlite::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_conn_sees_committed_writes_from_write_conn() {
let store = LibraryStore::open_in_memory();
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO sync_state (server_id, library_scope, sync_phase) \
VALUES ('s1', '', 'ready')",
[],
)
})
.unwrap();
let phase: String = store
.with_read_conn(|c| {
c.query_row(
"SELECT sync_phase FROM sync_state WHERE server_id = 's1'",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(phase, "ready");
}
#[test]
fn open_in_memory_creates_all_expected_tables() {
let store = LibraryStore::open_in_memory();
let tables = store
.with_conn("misc", |c| {
let mut stmt =
c.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")?;
let rows: rusqlite::Result<Vec<String>> =
stmt.query_map([], |r| r.get::<_, String>(0))?.collect();
rows
})
.unwrap();
for expected in [
"album",
"artist",
"canonical_enrichment_link",
"canonical_identity",
"canonical_track",
"schema_migrations",
"sync_state",
"track",
"track_artifact",
"track_canonical_link",
"track_extension",
"track_fact",
"track_id_history",
"track_offline",
"play_session",
] {
assert!(
tables.iter().any(|t| t == expected),
"missing table `{expected}` — got {tables:?}"
);
}
}
#[test]
fn schema_migrations_records_head_version() {
let store = LibraryStore::open_in_memory();
let versions: Vec<i64> = store
.with_conn("misc", |c| {
let mut stmt =
c.prepare("SELECT version FROM schema_migrations ORDER BY version")?;
let rows: rusqlite::Result<Vec<i64>> =
stmt.query_map([], |r| r.get(0))?.collect();
rows
})
.unwrap();
// Embedded migrations are numbered 1..=head, all applied on a fresh DB.
let expected: Vec<i64> = (1..=LIBRARY_DB_SCHEMA_VERSION).collect();
assert_eq!(versions, expected);
}
#[test]
fn run_migrations_is_idempotent_across_reopens() {
let store = LibraryStore::open_in_memory();
let outcome = store
.with_conn("migrate", run_migrations)
.expect("second migration pass must be a no-op");
assert_eq!(outcome, MigrationOutcome::Applied);
let count: i64 = store
.with_conn("misc", |c| {
c.query_row("SELECT COUNT(*) FROM schema_migrations", [], |r| r.get(0))
})
.unwrap();
assert_eq!(
count, LIBRARY_DB_SCHEMA_VERSION,
"one schema_migrations row per embedded migration, no duplicates"
);
}
#[test]
fn fts_virtual_table_exists() {
let store = LibraryStore::open_in_memory();
let count: i64 = store
.with_conn("misc", |c| {
c.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name='track_fts'",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count, 1);
}
// ── PR-1b: edge-case tests via the test-only `run_migrations_with` ─────
/// `ALTER TABLE artist ADD COLUMN bio TEXT;` — minimal additive fixture,
/// nullable column with no default. Mirrors the §5.7 additive-first rule.
/// Numbered above the real embedded head so it stacks on a migrated DB.
const FIXTURE_ADD_BIO: &str = "ALTER TABLE artist ADD COLUMN bio TEXT;";
const FIXTURE_ADD_BIO_VERSION: i64 = LIBRARY_DB_SCHEMA_VERSION + 1;
fn no_op_hook(_c: &Connection, _from: i64, _to: i64) -> rusqlite::Result<()> {
Ok(())
}
fn always_fail_hook(_c: &Connection, _from: i64, _to: i64) -> rusqlite::Result<()> {
panic!("breaking-bump hook must NOT fire in this test");
}
#[test]
fn additive_migration_preserves_existing_data() {
let store = LibraryStore::open_in_memory();
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO artist (server_id, id, name, synced_at) \
VALUES ('s1', 'a1', 'Existing Artist', 1)",
[],
)
})
.unwrap();
let outcome = store
.with_conn("misc", |c| {
run_migrations_with(
c,
&[(1, INITIAL_SQL), (FIXTURE_ADD_BIO_VERSION, FIXTURE_ADD_BIO)],
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
always_fail_hook,
)
})
.unwrap();
assert_eq!(outcome, MigrationOutcome::Applied);
let (name, bio): (String, Option<String>) = store
.with_conn("misc", |c| {
c.query_row(
"SELECT name, bio FROM artist WHERE id = 'a1'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
})
.unwrap();
assert_eq!(name, "Existing Artist");
assert!(bio.is_none());
let versions: Vec<i64> = store
.with_conn("misc", |c| {
let mut stmt =
c.prepare("SELECT version FROM schema_migrations ORDER BY version")?;
let rows: rusqlite::Result<Vec<i64>> =
stmt.query_map([], |r| r.get(0))?.collect();
rows
})
.unwrap();
// Real embedded migrations (1..=head) plus the additive fixture.
let mut expected: Vec<i64> = (1..=LIBRARY_DB_SCHEMA_VERSION).collect();
expected.push(FIXTURE_ADD_BIO_VERSION);
assert_eq!(versions, expected);
}
#[test]
fn runner_sorts_unsorted_migration_slice_before_applying() {
// If a future contributor lists migrations out of order in the
// source slice, the runner must still apply them ascending.
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
let outcome = run_migrations_with(
&conn,
&[(2, FIXTURE_ADD_BIO), (1, INITIAL_SQL)],
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
always_fail_hook,
)
.unwrap();
assert_eq!(outcome, MigrationOutcome::Applied);
let versions: Vec<i64> = {
let mut stmt = conn
.prepare("SELECT version FROM schema_migrations ORDER BY applied_at, version")
.unwrap();
let rows: rusqlite::Result<Vec<i64>> =
stmt.query_map([], |r| r.get(0)).unwrap().collect();
rows.unwrap()
};
assert_eq!(versions, vec![1, 2]);
}
#[test]
fn breaking_bump_hook_fires_when_db_below_min_compatible() {
// Simulate a future code release where MIN_COMPATIBLE was bumped past
// the version the DB currently carries (the real embedded head).
let store = LibraryStore::open_in_memory();
let outcome = store
.with_conn("misc", |c| {
run_migrations_with(
c,
MIGRATIONS,
LIBRARY_DB_SCHEMA_VERSION + 1, // bumped past current applied
no_op_hook,
)
})
.unwrap();
assert_eq!(outcome, MigrationOutcome::BreakingBump);
}
#[test]
fn breaking_bump_hook_does_not_fire_on_fresh_db() {
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
let outcome = run_migrations_with(
&conn,
MIGRATIONS,
// Even a wildly future min_compatible must not trip on a fresh DB:
// no rows in schema_migrations means "nothing to migrate from".
999,
always_fail_hook,
)
.unwrap();
assert_eq!(outcome, MigrationOutcome::Applied);
}
}