Files
electric-cherry/tests/unit/migration-runner.test.ts
T
josh 8d1bb69591
CI / checks (push) Successful in 5m6s
Use ordered SQLite migrations (Refs #75)
2026-10-10 00:01:18 -04:00

160 lines
6.3 KiB
TypeScript

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();
});