use rusqlite::Connection;
pub const SCHEMA_VERSION: i64 = 1;
pub fn create_tables(conn: &Connection) -> rusqlite::Result<()> {
super::connection::register_library_collation(conn)?;
let found: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if found > SCHEMA_VERSION {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
Some(format!(
"database schema version {found} is newer than this build understands \
({SCHEMA_VERSION}) — upgrade koan rather than downgrading the library"
)),
));
}
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS artists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
sort_name TEXT,
mbid TEXT,
remote_id TEXT,
UNIQUE(name)
);
CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
artist_id INTEGER REFERENCES artists(id),
date TEXT,
total_discs INTEGER,
total_tracks INTEGER,
codec TEXT,
label TEXT,
remote_id TEXT,
added_at TEXT,
UNIQUE(title, artist_id)
);
CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY,
album_id INTEGER REFERENCES albums(id),
artist_id INTEGER REFERENCES artists(id),
disc INTEGER,
track_number INTEGER,
title TEXT NOT NULL,
duration_ms INTEGER,
path TEXT,
codec TEXT,
sample_rate INTEGER,
bit_depth INTEGER,
channels INTEGER,
bitrate INTEGER,
size_bytes INTEGER,
mtime INTEGER,
genre TEXT,
source TEXT NOT NULL DEFAULT 'local' CHECK (source IN ('local', 'remote', 'cached')),
remote_id TEXT,
remote_url TEXT,
cached_path TEXT,
UNIQUE(path)
);
CREATE INDEX IF NOT EXISTS idx_tracks_album ON tracks(album_id);
CREATE INDEX IF NOT EXISTS idx_tracks_artist ON tracks(artist_id);
CREATE INDEX IF NOT EXISTS idx_tracks_source ON tracks(source);
CREATE INDEX IF NOT EXISTS idx_tracks_remote_id ON tracks(remote_id);
CREATE INDEX IF NOT EXISTS idx_albums_artist ON albums(artist_id);
CREATE INDEX IF NOT EXISTS idx_tracks_album_order ON tracks(album_id, disc, track_number);
CREATE VIRTUAL TABLE IF NOT EXISTS tracks_fts USING fts5(
title,
artist_name,
album_title,
genre
);
CREATE TABLE IF NOT EXISTS library_folders (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
last_scan INTEGER
);
CREATE TABLE IF NOT EXISTS scan_cache (
path TEXT PRIMARY KEY,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL,
track_id INTEGER REFERENCES tracks(id)
);
CREATE TABLE IF NOT EXISTS remote_servers (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
last_sync INTEGER
);
CREATE TABLE IF NOT EXISTS organize_log (
id INTEGER PRIMARY KEY,
batch_id TEXT NOT NULL,
track_id INTEGER,
from_path TEXT NOT NULL,
to_path TEXT NOT NULL,
size_bytes INTEGER,
mtime INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS lyrics_cache (
id INTEGER PRIMARY KEY,
track_id INTEGER REFERENCES tracks(id),
source TEXT NOT NULL,
synced INTEGER DEFAULT 0,
content TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
UNIQUE(track_id)
);
CREATE TABLE IF NOT EXISTS favourites (
track_path TEXT PRIMARY KEY,
created_at TEXT DEFAULT (datetime('now'))
);
-- Albums and artists are favourited by name, not by row id, for the
-- same reason tracks are favourited by path: a rebuilt index assigns
-- new ids, and losing every favourite to a reindex is not acceptable.
CREATE TABLE IF NOT EXISTS favourite_albums (
artist_name TEXT NOT NULL,
album_title TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (artist_name, album_title)
);
CREATE TABLE IF NOT EXISTS favourite_artists (
artist_name TEXT PRIMARY KEY,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS playback_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
queue_json TEXT NOT NULL DEFAULT '[]',
cursor_id TEXT,
position_ms INTEGER NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS similar_artists (
artist_id INTEGER NOT NULL REFERENCES artists(id),
similar_id INTEGER NOT NULL REFERENCES artists(id),
score REAL NOT NULL DEFAULT 0.0,
source TEXT NOT NULL DEFAULT 'subsonic',
relationship TEXT NOT NULL DEFAULT 'similar',
updated_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (artist_id, similar_id, source)
);
CREATE TABLE IF NOT EXISTS play_history (
id INTEGER PRIMARY KEY,
track_id INTEGER REFERENCES tracks(id) ON DELETE CASCADE,
played_at INTEGER NOT NULL,
duration_ms INTEGER,
source TEXT DEFAULT 'local'
);
CREATE INDEX IF NOT EXISTS idx_play_history_track ON play_history(track_id);
CREATE INDEX IF NOT EXISTS idx_play_history_time ON play_history(played_at);
CREATE TABLE IF NOT EXISTS queue_snapshots (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
queue_json TEXT NOT NULL DEFAULT '[]',
cursor_path TEXT,
position_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS track_vectors (
track_id INTEGER PRIMARY KEY REFERENCES tracks(id),
embedding BLOB NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
-- Auth tables
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user', 'readonly')),
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at);
",
)?;
apply_migrations(conn)?;
conn.pragma_update(None, "user_version", SCHEMA_VERSION)?;
Ok(())
}
const ADDED_COLUMNS: &[(&str, &str, &str)] = &[
("tracks", "cache_size_bytes", "INTEGER"),
("tracks", "cache_download_date", "INTEGER"),
(
"similar_artists",
"relationship",
"TEXT NOT NULL DEFAULT 'similar'",
),
("organize_log", "size_bytes", "INTEGER"),
("organize_log", "mtime", "INTEGER"),
("albums", "added_at", "TEXT"),
(
"playback_state",
"was_playing",
"INTEGER NOT NULL DEFAULT 0",
),
(
"playback_state",
"radio_enabled",
"INTEGER NOT NULL DEFAULT 0",
),
("albums", "mbid", "TEXT"),
("tracks", "mbid", "TEXT"),
("albums", "sort_name", "TEXT"),
];
fn apply_migrations(conn: &Connection) -> rusqlite::Result<()> {
for (table, column, ty) in ADDED_COLUMNS {
if !column_exists(conn, table, column)? {
conn.execute(&format!("ALTER TABLE {table} ADD COLUMN {column} {ty}"), [])?;
}
}
conn.execute(
"UPDATE albums SET added_at = NULL
WHERE added_at IS NOT NULL AND added_at NOT LIKE '%T%Z'",
[],
)?;
cascade_play_history(conn)?;
Ok(())
}
fn cascade_play_history(conn: &Connection) -> rusqlite::Result<()> {
if fk_cascades(conn, "play_history")? {
return Ok(());
}
conn.pragma_update(None, "foreign_keys", "off")?;
let rebuild = conn.execute_batch(
"BEGIN;
CREATE TABLE play_history_new (
id INTEGER PRIMARY KEY,
track_id INTEGER REFERENCES tracks(id) ON DELETE CASCADE,
played_at INTEGER NOT NULL,
duration_ms INTEGER,
source TEXT DEFAULT 'local'
);
-- Entries whose track has already gone would violate the new
-- constraint the moment it is enforced. They are unreachable anyway.
INSERT INTO play_history_new (id, track_id, played_at, duration_ms, source)
SELECT id, track_id, played_at, duration_ms, source FROM play_history
WHERE track_id IS NULL OR track_id IN (SELECT id FROM tracks);
DROP TABLE play_history;
ALTER TABLE play_history_new RENAME TO play_history;
CREATE INDEX IF NOT EXISTS idx_play_history_track ON play_history(track_id);
CREATE INDEX IF NOT EXISTS idx_play_history_time ON play_history(played_at);
COMMIT;",
);
conn.pragma_update(None, "foreign_keys", "on")?;
rebuild
}
fn fk_cascades(conn: &Connection, table: &str) -> rusqlite::Result<bool> {
let mut stmt = conn.prepare(&format!("PRAGMA foreign_key_list({table})"))?;
let mut rows = stmt.query([])?;
let mut any = false;
while let Some(row) = rows.next()? {
any = true;
if !row.get::<_, String>(6)?.eq_ignore_ascii_case("CASCADE") {
return Ok(false);
}
}
Ok(any)
}
fn column_exists(conn: &Connection, table: &str, column: &str) -> rusqlite::Result<bool> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
if row.get::<_, String>(1)? == column {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::connection::Database;
#[test]
fn clears_scan_time_added_at_but_keeps_the_servers() {
let conn = Connection::open_in_memory().unwrap();
create_tables(&conn).unwrap();
conn.execute_batch(
"INSERT INTO artists (id, name) VALUES (1, 'Klaxons');
INSERT INTO albums (id, title, artist_id, added_at)
VALUES (1, 'Local', 1, '2026-08-23 12:14:57'),
(2, 'Remote', 1, '2026-08-06T22:53:14.851697506Z'),
(3, 'Neither', 1, NULL);",
)
.unwrap();
create_tables(&conn).unwrap();
let added = |id: i64| -> Option<String> {
conn.query_row("SELECT added_at FROM albums WHERE id = ?1", [id], |r| {
r.get(0)
})
.unwrap()
};
assert_eq!(added(1), None, "scan-time stamp cleared");
assert_eq!(
added(2).as_deref(),
Some("2026-08-06T22:53:14.851697506Z"),
"the server's own date is left alone"
);
assert_eq!(added(3), None);
}
#[test]
fn migrates_similar_artists_relationship_column() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE artists (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
CREATE TABLE similar_artists (
artist_id INTEGER NOT NULL REFERENCES artists(id),
similar_id INTEGER NOT NULL REFERENCES artists(id),
score REAL NOT NULL DEFAULT 0.0,
source TEXT NOT NULL DEFAULT 'subsonic',
updated_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (artist_id, similar_id, source)
);",
)
.unwrap();
create_tables(&conn).unwrap();
let has_relationship: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('similar_artists') WHERE name = 'relationship'",
[],
|row| row.get::<_, i64>(0).map(|n| n > 0),
)
.unwrap();
assert!(has_relationship, "relationship column was not added");
conn.execute(
"INSERT INTO artists (id, name) VALUES (1, 'A'), (2, 'B')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO similar_artists (artist_id, similar_id, score, source)
VALUES (1, 2, 0.9, 'subsonic')",
[],
)
.unwrap();
let rel: String = conn
.query_row(
"SELECT relationship FROM similar_artists WHERE artist_id = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(rel, "similar");
}
#[test]
fn sqlite_still_reports_duplicate_column() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE t (a INTEGER, b INTEGER);")
.unwrap();
let err = conn
.execute("ALTER TABLE t ADD COLUMN b INTEGER", [])
.unwrap_err();
assert!(
err.to_string().contains("duplicate column"),
"SQLite error wording moved, create_tables no longer detects \
already-applied migrations: {err}"
);
}
#[test]
fn reopening_a_migrated_database_succeeds() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("koan.db");
Database::open(&path).unwrap();
Database::open(&path).unwrap();
Database::open(&path).unwrap();
}
#[test]
fn pre_migration_database_gains_the_new_columns() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("koan.db");
{
let db = Database::open(&path).unwrap();
db.conn
.execute_batch(
"ALTER TABLE tracks DROP COLUMN cache_size_bytes;
ALTER TABLE tracks DROP COLUMN cache_download_date;
ALTER TABLE similar_artists DROP COLUMN relationship;",
)
.unwrap();
}
let db = Database::open(&path).unwrap();
for (table, column) in [
("tracks", "cache_size_bytes"),
("tracks", "cache_download_date"),
("similar_artists", "relationship"),
] {
let found: i64 = db
.conn
.query_row(
&format!(
"SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = '{column}'"
),
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(found, 1, "{table}.{column} was not migrated");
}
}
#[test]
fn foreign_keys_are_enforced() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("koan.db");
let db = Database::open(&path).unwrap();
db.conn
.execute(
"INSERT INTO users (id, username, password_hash) VALUES (1, 'u', 'h')",
[],
)
.unwrap();
db.conn
.execute(
"INSERT INTO refresh_tokens (id, user_id, expires_at) VALUES ('t', 1, 9999)",
[],
)
.unwrap();
assert!(
db.conn
.execute(
"INSERT INTO refresh_tokens (id, user_id, expires_at) VALUES ('t2', 999, 9999)",
[],
)
.is_err(),
"foreign key constraint did not fire"
);
db.conn
.execute("DELETE FROM users WHERE id = 1", [])
.unwrap();
let remaining: i64 = db
.conn
.query_row("SELECT COUNT(*) FROM refresh_tokens", [], |row| row.get(0))
.unwrap();
assert_eq!(remaining, 0, "ON DELETE CASCADE did not fire");
}
#[test]
fn play_history_from_before_the_cascade_is_rebuilt_keeping_its_rows() {
let conn = Connection::open_in_memory().unwrap();
create_tables(&conn).unwrap();
conn.pragma_update(None, "foreign_keys", "off").unwrap();
conn.execute_batch(
"DROP TABLE play_history;
CREATE TABLE play_history (
id INTEGER PRIMARY KEY,
track_id INTEGER REFERENCES tracks(id),
played_at INTEGER NOT NULL,
duration_ms INTEGER,
source TEXT DEFAULT 'local'
);
INSERT INTO artists (id, name) VALUES (1, 'A');
INSERT INTO tracks (id, artist_id, title, source) VALUES (7, 1, 'T', 'local');
INSERT INTO play_history (id, track_id, played_at, duration_ms, source)
VALUES (1, 7, 100, 5000, 'local'),
(2, 999, 200, NULL, 'local');",
)
.unwrap();
assert!(!fk_cascades(&conn, "play_history").unwrap());
apply_migrations(&conn).unwrap();
assert!(fk_cascades(&conn, "play_history").unwrap());
let kept: Vec<(i64, i64, Option<i64>)> = conn
.prepare("SELECT id, played_at, duration_ms FROM play_history ORDER BY id")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(
kept,
vec![(1, 100, Some(5000))],
"the live entry survives; the one pointing at a track that is gone does not"
);
conn.pragma_update(None, "foreign_keys", "on").unwrap();
conn.execute("DELETE FROM tracks WHERE id = 7", []).unwrap();
let left: i64 = conn
.query_row("SELECT COUNT(*) FROM play_history", [], |r| r.get(0))
.unwrap();
assert_eq!(left, 0);
}
#[test]
fn cascading_play_history_is_idempotent() {
let conn = Connection::open_in_memory().unwrap();
create_tables(&conn).unwrap();
cascade_play_history(&conn).unwrap();
cascade_play_history(&conn).unwrap();
assert!(fk_cascades(&conn, "play_history").unwrap());
}
#[test]
fn fresh_database_is_stamped_with_the_current_version() {
let conn = Connection::open_in_memory().unwrap();
create_tables(&conn).unwrap();
let v: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, SCHEMA_VERSION);
}
#[test]
fn create_tables_is_idempotent_across_repeated_opens() {
let conn = Connection::open_in_memory().unwrap();
for _ in 0..3 {
create_tables(&conn).unwrap();
}
assert!(column_exists(&conn, "tracks", "cache_size_bytes").unwrap());
assert!(column_exists(&conn, "organize_log", "mtime").unwrap());
}
#[test]
fn a_database_missing_added_columns_is_migrated() {
let conn = Connection::open_in_memory().unwrap();
create_tables(&conn).unwrap();
conn.execute_batch(
"DROP TABLE organize_log;
CREATE TABLE organize_log (
id INTEGER PRIMARY KEY,
batch_id TEXT NOT NULL,
track_id INTEGER,
from_path TEXT NOT NULL,
to_path TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
PRAGMA user_version = 0;",
)
.unwrap();
assert!(!column_exists(&conn, "organize_log", "size_bytes").unwrap());
create_tables(&conn).unwrap();
assert!(column_exists(&conn, "organize_log", "size_bytes").unwrap());
assert!(column_exists(&conn, "organize_log", "mtime").unwrap());
}
#[test]
fn migration_does_not_depend_on_sqlite_error_text() {
let conn = Connection::open_in_memory().unwrap();
create_tables(&conn).unwrap();
apply_migrations(&conn).unwrap();
apply_migrations(&conn).unwrap();
}
#[test]
fn a_newer_database_is_refused_rather_than_written_to() {
let conn = Connection::open_in_memory().unwrap();
create_tables(&conn).unwrap();
conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
.unwrap();
let err = create_tables(&conn).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("newer than this build"), "unexpected: {msg}");
}
}