import Database from 'better-sqlite3'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { runMigrations, type Migration } from '../../src/lib/server/db/migration-runner.js'; let handle: Database.Database; beforeEach(() => { handle = new Database(':memory:'); handle.pragma('foreign_keys = ON'); }); afterEach(() => { vi.restoreAllMocks(); handle.close(); }); it('applies ordered upgrades once and appends only pending versions', () => { const first = vi.fn((db: Database.Database) => db.exec("CREATE TABLE records(value TEXT); INSERT INTO records VALUES('original')") ); const migrations: Migration[] = [{ version: 1, name: 'records', run: first }]; runMigrations(handle, migrations); handle.exec("UPDATE records SET value='edited'"); const next = vi.fn((db: Database.Database) => { expect(db.pragma('user_version', { simple: true })).toBe(1); db.exec('ALTER TABLE records ADD COLUMN added INTEGER DEFAULT 7'); }); migrations.push({ version: 2, name: 'additional field', run: next }); runMigrations(handle, migrations); runMigrations(handle, migrations); expect(first).toHaveBeenCalledTimes(1); expect(next).toHaveBeenCalledTimes(1); expect(handle.prepare('SELECT * FROM records').get()).toEqual({ value: 'edited', added: 7 }); expect(handle.pragma('user_version', { simple: true })).toBe(2); }); it('rolls back schema, data and version on failure, then retries only the failed step', () => { let fail = true; const migrations: Migration[] = [ { version: 1, name: 'records', run: (db) => db.exec("CREATE TABLE records(value TEXT); INSERT INTO records VALUES('old')") }, { version: 2, name: 'backfill', run: (db) => { db.exec( "ALTER TABLE records ADD COLUMN extra TEXT; UPDATE records SET value='new'" ); if (fail) throw new Error('interrupted'); } }, { version: 3, name: 'index', run: (db) => db.exec('CREATE INDEX records_value ON records(value)') } ]; expect(() => runMigrations(handle, migrations)).toThrow('interrupted'); expect(handle.pragma('user_version', { simple: true })).toBe(1); expect(handle.prepare('SELECT * FROM records').get()).toEqual({ value: 'old' }); expect(handle.pragma('index_list(records)')).toEqual([]); fail = false; runMigrations(handle, migrations); expect(handle.prepare('SELECT * FROM records').get()).toEqual({ value: 'new', extra: null }); expect(handle.pragma('user_version', { simple: true })).toBe(3); }); it('does not commit a migration if recording its version fails', () => { const pragma = handle.pragma.bind(handle); vi.spyOn(handle, 'pragma').mockImplementation((sql, options) => { if (sql === 'user_version = 1') throw new Error('version write failed'); return pragma(sql, options); }); expect(() => runMigrations(handle, [ { version: 1, name: 'records', run: (db) => db.exec('CREATE TABLE records(value TEXT)') } ]) ).toThrow('version write failed'); expect(pragma('user_version', { simple: true })).toBe(0); expect( handle.prepare("SELECT 1 FROM sqlite_master WHERE name='records'").get() ).toBeUndefined(); }); it('rebuilds a referenced table without cascading and restores foreign key enforcement', () => { handle.exec(`CREATE TABLE parent(id INTEGER PRIMARY KEY); CREATE TABLE child(parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE); INSERT INTO parent VALUES(1); INSERT INTO child VALUES(1);`); runMigrations(handle, [ { version: 1, name: 'rebuild', mode: 'foreign-keys-off', run: (db) => db.exec(`CREATE TABLE replacement(id INTEGER PRIMARY KEY, label TEXT); INSERT INTO replacement(id) SELECT id FROM parent; DROP TABLE parent; ALTER TABLE replacement RENAME TO parent;`) } ]); expect(handle.prepare('SELECT * FROM child').all()).toEqual([{ parent_id: 1 }]); expect(handle.pragma('foreign_keys', { simple: true })).toBe(1); expect(handle.pragma('foreign_key_check')).toEqual([]); }); it('rolls back broken references and restores foreign keys on failure', () => { handle.exec(`CREATE TABLE parent(id INTEGER PRIMARY KEY); CREATE TABLE child(parent_id INTEGER REFERENCES parent(id));`); expect(() => runMigrations(handle, [ { version: 1, name: 'broken reference', mode: 'foreign-keys-off', run: (db) => db.exec('INSERT INTO child VALUES(99)') } ]) ).toThrow('broken references'); expect(handle.prepare('SELECT * FROM child').all()).toEqual([]); expect(handle.pragma('foreign_keys', { simple: true })).toBe(1); expect(handle.pragma('user_version', { simple: true })).toBe(0); }); it('leaves interrupted autocommit work pending for a resumable retry', () => { let fail = true; const migrations: Migration[] = [ { version: 1, name: 'vacuum', mode: 'autocommit', run: (db) => { expect(db.inTransaction).toBe(false); db.exec('CREATE TABLE IF NOT EXISTS records(value TEXT); VACUUM'); if (fail) throw new Error('interrupted after vacuum'); } } ]; expect(() => runMigrations(handle, migrations)).toThrow('interrupted after vacuum'); expect(handle.pragma('user_version', { simple: true })).toBe(0); fail = false; runMigrations(handle, migrations); expect(handle.pragma('user_version', { simple: true })).toBe(1); }); it('rejects newer databases and invalid migration sequences before running work', () => { const run = vi.fn(); handle.pragma('user_version = 2'); expect(() => runMigrations(handle, [{ version: 1, name: 'older app', run }])).toThrow( 'Database version 2 is not supported' ); handle.pragma('user_version = 0'); expect(() => runMigrations(handle, [{ version: 2, name: 'gap', run }])).toThrow('consecutive'); expect(run).not.toHaveBeenCalled(); });