This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user