1
0

feat: schema versioning with v2 migration (loans, last_played_at)

This commit is contained in:
2026-09-03 20:43:33 +02:00
parent bc3db9e4f7
commit 3bdbd8315c
4 changed files with 108 additions and 3 deletions

View File

@@ -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<string, string | undefined> = 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),

View File

@@ -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)
}

View File

@@ -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',

View File

@@ -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')
})
})