From 3bdbd8315c72234cbf34b123697d0a9c74475ab4 Mon Sep 17 00:00:00 2001 From: Samu Date: Thu, 3 Sep 2026 20:43:33 +0200 Subject: [PATCH] feat: schema versioning with v2 migration (loans, last_played_at) --- server/src/config.ts | 4 +++ server/src/db.ts | 42 +++++++++++++++++++++++-- server/test/helpers.ts | 3 ++ server/test/migrate.test.ts | 62 +++++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 server/test/migrate.test.ts diff --git a/server/src/config.ts b/server/src/config.ts index 73e7241..efef054 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -5,6 +5,7 @@ import crypto from 'node:crypto' export interface Config { dataDir: string artworkDir: string + backupsDir: string dbPath: string port: number sessionSecret: string @@ -15,9 +16,12 @@ export function loadConfig(env: Record = process.env mkdirSync(dataDir, { recursive: true }) const artworkDir = path.join(dataDir, 'artwork-cache') mkdirSync(artworkDir, { recursive: true }) + const backupsDir = path.join(dataDir, 'backups') + mkdirSync(backupsDir, { recursive: true }) return { dataDir, artworkDir, + backupsDir, dbPath: path.join(dataDir, 'record-shop.db'), port: Number(env.PORT ?? 3000), sessionSecret: getOrCreateSecret(dataDir), diff --git a/server/src/db.ts b/server/src/db.ts index 7ffe290..bc2ffdd 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -10,8 +10,7 @@ export function openDatabase(path: string): DB { return db } -export function migrate(db: DB): void { - db.exec(` +const BASE_SCHEMA = ` CREATE TABLE IF NOT EXISTS app_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -76,5 +75,42 @@ export function migrate(db: DB): void { album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE, PRIMARY KEY (user_id, item_id) ); - `) + ` + +function getSchemaVersion(db: DB): number { + const row = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() as + | { value: string } + | undefined + return row ? Number(row.value) : 0 +} + +function setSchemaVersion(db: DB, version: number): void { + db.prepare( + `INSERT INTO app_meta (key, value) VALUES ('schema_version', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(String(version)) +} + +/** Upgrade steps for databases created before schema versioning existed. */ +export function migrateUpgrades(db: DB): void { + const version = getSchemaVersion(db) + if (version < 2) { + db.exec(` + ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT; + CREATE TABLE IF NOT EXISTS loans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE, + borrower TEXT NOT NULL, + lent_at TEXT NOT NULL DEFAULT (datetime('now')), + returned_at TEXT + ); + `) + setSchemaVersion(db, 2) + } +} + +export function migrate(db: DB): void { + db.exec(BASE_SCHEMA) + migrateUpgrades(db) } diff --git a/server/test/helpers.ts b/server/test/helpers.ts index f4d0fcf..c3fdc4c 100644 --- a/server/test/helpers.ts +++ b/server/test/helpers.ts @@ -9,11 +9,14 @@ import type { FastifyInstance } from 'fastify' export function testConfig(): Config { // artworkDir must be a real writable directory (cached images land there) const artworkDir = mkdtempSync(path.join(tmpdir(), 'rs-art-')) + // backupsDir must be a real writable directory (backup files land there) + const backupsDir = mkdtempSync(path.join(tmpdir(), 'rs-bak-')) // dataDir must be a real writable directory (release-cache payloads land there) const dataDir = mkdtempSync(path.join(tmpdir(), 'rs-data-')) return { dataDir, artworkDir, + backupsDir, dbPath: ':memory:', port: 0, sessionSecret: 'test-secret-test-secret-test-secret-1234', diff --git a/server/test/migrate.test.ts b/server/test/migrate.test.ts new file mode 100644 index 0000000..ef90be3 --- /dev/null +++ b/server/test/migrate.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import Database from 'better-sqlite3' +import { openDatabase, migrateUpgrades } from '../src/db.js' + +/** Builds a pre-v2 database (plan-1 schema, no version row, no new columns). */ +function legacyDb(): Database.Database { + const db = new Database(':memory:') + db.pragma('journal_mode = WAL') + db.pragma('foreign_keys = ON') + db.exec(` + CREATE TABLE app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now'))); + CREATE TABLE sessions (token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL); + CREATE TABLE settings (user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, discogs_token TEXT, subsonic_url TEXT, subsonic_username TEXT, subsonic_password TEXT); + CREATE TABLE collection_items (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, discogs_release_id INTEGER NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL, year INTEGER, formats TEXT NOT NULL DEFAULT '[]', genres TEXT NOT NULL DEFAULT '[]', labels TEXT NOT NULL DEFAULT '[]', tracklist TEXT NOT NULL DEFAULT '[]', catno TEXT, country TEXT, cover_url TEXT, local_artwork_path TEXT, barcodes TEXT NOT NULL DEFAULT '[]', rip_override INTEGER, date_added TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (user_id, discogs_release_id)); + CREATE TABLE digital_albums (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, subsonic_id TEXT NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL, UNIQUE (user_id, subsonic_id)); + CREATE TABLE match_links (user_id INTEGER NOT NULL, item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE, album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE, PRIMARY KEY (user_id, item_id)); + INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'sam', 'x', 1); + INSERT INTO digital_albums (id, user_id, subsonic_id, title, artist) VALUES (1, 1, 'a1', 'Motion', 'The Cinematic Orchestra'); + `) + return db +} + +function columns(db: Database.Database, table: string): string[] { + return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name) +} + +describe('schema migration v2', () => { + it('migrates a legacy database: loans table, last_played_at, version row', () => { + const db = legacyDb() + migrateUpgrades(db) + expect(columns(db, 'digital_albums')).toContain('last_played_at') + expect(columns(db, 'loans')).toEqual([ + 'id', + 'user_id', + 'item_id', + 'borrower', + 'lent_at', + 'returned_at', + ]) + const version = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() + expect(version).toEqual({ value: '2' }) + // existing data survives + expect(db.prepare('SELECT title FROM digital_albums').get()).toEqual({ title: 'Motion' }) + }) + + it('openDatabase creates a fresh v2 database directly', () => { + const db = openDatabase(':memory:') + expect(columns(db, 'digital_albums')).toContain('last_played_at') + expect(columns(db, 'loans')).toContain('borrower') + const version = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() + expect(version).toEqual({ value: '2' }) + }) + + it('migrations are idempotent', () => { + const db = openDatabase(':memory:') + expect(() => migrateUpgrades(db)).not.toThrow() + expect( + (db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() as { value: string }).value + ).toBe('2') + }) +})