mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
5cd01c90ac
* fix(library): multi-genre local index with track_genre and backfill Restore atomic genre browse, filters, and counts via track_genre: OpenSubsonic genres[] first with Navidrome-default split fallback, sync write path, read-path query switches, blocking startup backfill with progress, and v12 repair migration for DBs that recorded legacy 002–011. TS fallback adds genreTagsFor and migration gate i18n across locales. * fix(library): address multi-genre review — robust TS genres and scope join genreTagsFor routes raw genres through parseItemGenres (single-object Subsonic quirk and bare strings). Library-scoped genre browse/counts join track for raw_json library_id fallback. Statistics keeps empty-genre bucket. * docs: CHANGELOG and credits for multi-genre local index (PR #1059) * docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059)
778 lines
28 KiB
Rust
778 lines
28 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 = 12;
|
||
|
||
/// 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");
|
||
/// Version 12 is above the removed legacy migrations 002–011 so existing DBs
|
||
/// still pick up `track_genre` + `library_data_migration`.
|
||
pub(crate) const MIGRATION_012_TRACK_GENRE_LEGACY: &str =
|
||
include_str!("../migrations/012_track_genre_legacy_repair.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), (12, MIGRATION_012_TRACK_GENRE_LEGACY)];
|
||
|
||
/// Idempotent repair — also runs after the migration runner on every open so
|
||
/// DBs that recorded the wrong version numbers still get the tables.
|
||
pub(crate) fn ensure_genre_tags_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||
conn.execute_batch(MIGRATION_012_TRACK_GENRE_LEGACY)
|
||
}
|
||
|
||
#[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())?;
|
||
ensure_genre_tags_schema(&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");
|
||
ensure_genre_tags_schema(&write_conn).expect("genre tags schema");
|
||
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();
|
||
let expected: Vec<i64> = MIGRATIONS.iter().map(|(version, _)| *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,
|
||
MIGRATIONS.len() as i64,
|
||
"one schema_migrations row per embedded migration, no duplicates"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn migration_012_repairs_db_that_recorded_legacy_versions_without_genre_tables() {
|
||
let uri = in_memory_uri();
|
||
let conn = Connection::open(&uri).expect("connection");
|
||
configure_write_connection(&conn).expect("pragmas");
|
||
conn.execute_batch(INITIAL_SQL).expect("initial");
|
||
conn.execute("DROP TABLE IF EXISTS track_genre", [])
|
||
.expect("drop track_genre");
|
||
conn.execute("DROP TABLE IF EXISTS library_data_migration", [])
|
||
.expect("drop cursor table");
|
||
for version in 1..=11_i64 {
|
||
conn.execute(
|
||
"INSERT INTO schema_migrations (version, applied_at) VALUES (?1, ?1)",
|
||
params![version],
|
||
)
|
||
.expect("seed legacy versions");
|
||
}
|
||
|
||
let outcome = run_migrations_with(
|
||
&conn,
|
||
MIGRATIONS,
|
||
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
|
||
no_op_hook,
|
||
)
|
||
.expect("apply v12 repair");
|
||
assert_eq!(outcome, MigrationOutcome::Applied);
|
||
ensure_genre_tags_schema(&conn).expect("ensure");
|
||
|
||
for table in ["track_genre", "library_data_migration"] {
|
||
let exists: i64 = conn
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM sqlite_master \
|
||
WHERE type = 'table' AND name = ?1",
|
||
params![table],
|
||
|r| r.get(0),
|
||
)
|
||
.expect("table probe");
|
||
assert_eq!(exists, 1, "missing table {table}");
|
||
}
|
||
}
|
||
|
||
#[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();
|
||
let mut expected: Vec<i64> = MIGRATIONS.iter().map(|(version, _)| *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);
|
||
}
|
||
}
|