feat(storage): add resilient SQLite foundation
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import { link, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { backupDatabase, restoreDatabase } from './backup.js';
|
||||
import { openDatabase } from './database.js';
|
||||
import { MIGRATIONS, migrateDatabase } from './migrations.js';
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-database-'));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('database migrations', () => {
|
||||
it('rejects duplicate, unordered, backfilled, and drifted migration catalogs', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const database = openDatabase(join(directory, 'app.sqlite'));
|
||||
const one = { id: 1, name: 'one', statements: ['CREATE TABLE one (id TEXT)'] };
|
||||
migrateDatabase(database, [one]);
|
||||
|
||||
expect(() =>
|
||||
migrateDatabase(database, [{ ...one, statements: ['CREATE TABLE changed (id TEXT)'] }]),
|
||||
).toThrow(/checksum|drift/i);
|
||||
expect(() => migrateDatabase(database, [one, { ...one, name: 'two' }])).toThrow(
|
||||
/duplicate.*id/i,
|
||||
);
|
||||
expect(() => migrateDatabase(database, [one, { id: 2, name: 'one', statements: [] }])).toThrow(
|
||||
/duplicate.*name/i,
|
||||
);
|
||||
expect(() => migrateDatabase(database, [{ id: 2, name: 'two', statements: [] }, one])).toThrow(
|
||||
/increasing/i,
|
||||
);
|
||||
migrateDatabase(database, [{ id: 2, name: 'two', statements: [] }]);
|
||||
expect(() => migrateDatabase(database, [{ id: 0, name: 'backfill', statements: [] }])).toThrow(
|
||||
/backfill/i,
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
it('creates all business tables, migration metadata, indexes, foreign keys, and UTC timestamp fields', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const database = openDatabase(join(directory, 'app.sqlite'));
|
||||
migrateDatabase(database);
|
||||
|
||||
const tables = database
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
||||
)
|
||||
.all()
|
||||
.map((row) => (row as { name: string }).name);
|
||||
expect(tables).toEqual([
|
||||
'app_settings',
|
||||
'audit_events',
|
||||
'capabilities',
|
||||
'instance_tags',
|
||||
'instances',
|
||||
'job_items',
|
||||
'jobs',
|
||||
'schema_migrations',
|
||||
'secret_references',
|
||||
'status_snapshots',
|
||||
]);
|
||||
|
||||
expect(database.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||
const instanceColumns = database.pragma('table_info(instances)') as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
}>;
|
||||
expect(instanceColumns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'config_revision', notnull: 1 }),
|
||||
expect.objectContaining({ name: 'created_at', notnull: 1 }),
|
||||
expect.objectContaining({ name: 'updated_at', notnull: 1 }),
|
||||
]),
|
||||
);
|
||||
const foreignKeys = database.pragma('foreign_key_list(job_items)') as Array<{
|
||||
from: string;
|
||||
on_delete: string;
|
||||
table: string;
|
||||
}>;
|
||||
expect(foreignKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ from: 'job_id', on_delete: 'CASCADE', table: 'jobs' }),
|
||||
expect.objectContaining({ from: 'instance_id', on_delete: 'RESTRICT', table: 'instances' }),
|
||||
]),
|
||||
);
|
||||
const indexes = database
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%' ORDER BY name",
|
||||
)
|
||||
.all()
|
||||
.map((row) => (row as { name: string }).name);
|
||||
expect(indexes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'idx_audit_events_created_at',
|
||||
'idx_jobs_status_created_at',
|
||||
'idx_status_snapshots_instance_observed_at',
|
||||
]),
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
it('replays without resetting persisted data', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const database = openDatabase(join(directory, 'app.sqlite'));
|
||||
migrateDatabase(database);
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO instances (id, name, base_url, config_revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
.run(
|
||||
'one',
|
||||
'One',
|
||||
'http://example.invalid',
|
||||
7,
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
);
|
||||
|
||||
migrateDatabase(database);
|
||||
|
||||
expect(
|
||||
database.prepare('SELECT name, config_revision FROM instances WHERE id = ?').get('one'),
|
||||
).toEqual({
|
||||
config_revision: 7,
|
||||
name: 'One',
|
||||
});
|
||||
expect(database.prepare('SELECT COUNT(*) AS count FROM schema_migrations').get()).toEqual({
|
||||
count: MIGRATIONS.length,
|
||||
});
|
||||
database.close();
|
||||
});
|
||||
|
||||
it('rolls back the entire migration when a statement fails', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const database = openDatabase(join(directory, 'app.sqlite'));
|
||||
expect(() =>
|
||||
migrateDatabase(database, [
|
||||
{
|
||||
id: 999,
|
||||
name: 'deliberately-broken',
|
||||
statements: ['CREATE TABLE should_rollback (id TEXT PRIMARY KEY)', 'THIS IS NOT SQL'],
|
||||
},
|
||||
]),
|
||||
).toThrow();
|
||||
expect(
|
||||
database.prepare("SELECT name FROM sqlite_master WHERE name = 'should_rollback'").get(),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
database.prepare("SELECT name FROM sqlite_master WHERE name = 'schema_migrations'").get(),
|
||||
).toBeUndefined();
|
||||
database.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('database backup and restore', () => {
|
||||
it('refuses restore while a managed live connection is open', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const databasePath = join(directory, 'app.sqlite');
|
||||
const backupPath = join(directory, 'backup.sqlite');
|
||||
const database = openDatabase(databasePath);
|
||||
migrateDatabase(database);
|
||||
await backupDatabase(database, backupPath);
|
||||
await expect(restoreDatabase(databasePath, backupPath)).rejects.toThrow(/open|connection/i);
|
||||
database.close();
|
||||
});
|
||||
|
||||
it('refuses restore through hardlink and symlinked-parent aliases while a managed connection is open', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const realDirectory = join(directory, 'real');
|
||||
const aliasDirectory = join(directory, 'alias');
|
||||
await mkdir(realDirectory);
|
||||
await symlink(realDirectory, aliasDirectory);
|
||||
const databasePath = join(realDirectory, 'app.sqlite');
|
||||
const hardlinkPath = join(directory, 'hardlink.sqlite');
|
||||
const backupPath = join(directory, 'backup.sqlite');
|
||||
const database = openDatabase(databasePath);
|
||||
migrateDatabase(database);
|
||||
await backupDatabase(database, backupPath);
|
||||
await link(databasePath, hardlinkPath);
|
||||
|
||||
await expect(restoreDatabase(hardlinkPath, backupPath)).rejects.toThrow(
|
||||
/open|connection|identity/i,
|
||||
);
|
||||
await expect(restoreDatabase(join(aliasDirectory, 'app.sqlite'), backupPath)).rejects.toThrow(
|
||||
/open|connection|identity/i,
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
it('rejects backup destinations that alias the managed live database', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const databasePath = join(directory, 'app.sqlite');
|
||||
const hardlinkPath = join(directory, 'hardlink.sqlite');
|
||||
const database = openDatabase(databasePath);
|
||||
migrateDatabase(database);
|
||||
await link(databasePath, hardlinkPath);
|
||||
|
||||
await expect(backupDatabase(database, hardlinkPath)).rejects.toThrow(/same|alias|identity/i);
|
||||
database.close();
|
||||
});
|
||||
|
||||
it('creates backups with owner-only permissions', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const database = openDatabase(join(directory, 'app.sqlite'));
|
||||
migrateDatabase(database);
|
||||
const backupPath = join(directory, 'backup.sqlite');
|
||||
await backupDatabase(database, backupPath);
|
||||
expect((await lstat(backupPath)).mode & 0o777).toBe(0o600);
|
||||
database.close();
|
||||
});
|
||||
|
||||
it('rejects same-path, symlink, and hardlink restore aliases', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const databasePath = join(directory, 'app.sqlite');
|
||||
const backupPath = join(directory, 'backup.sqlite');
|
||||
const database = openDatabase(databasePath);
|
||||
migrateDatabase(database);
|
||||
await backupDatabase(database, backupPath);
|
||||
database.close();
|
||||
await expect(restoreDatabase(databasePath, databasePath)).rejects.toThrow(/same|alias/i);
|
||||
const symlinkPath = join(directory, 'backup-link.sqlite');
|
||||
await symlink(backupPath, symlinkPath);
|
||||
await expect(restoreDatabase(databasePath, symlinkPath)).rejects.toThrow(/symbolic|symlink/i);
|
||||
const hardlinkPath = join(directory, 'live-hardlink.sqlite');
|
||||
await link(databasePath, hardlinkPath);
|
||||
await expect(restoreDatabase(databasePath, hardlinkPath)).rejects.toThrow(/same|alias/i);
|
||||
});
|
||||
|
||||
it('backs up an active WAL database, then atomically restores its earlier state', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const databasePath = join(directory, 'app.sqlite');
|
||||
const backupPath = join(directory, 'backup.sqlite');
|
||||
const database = openDatabase(databasePath);
|
||||
migrateDatabase(database);
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO app_settings (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
.run('colour', '"blue"', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
|
||||
await backupDatabase(database, backupPath);
|
||||
database.prepare('UPDATE app_settings SET value_json = ? WHERE key = ?').run('"red"', 'colour');
|
||||
database.close();
|
||||
|
||||
await restoreDatabase(databasePath, backupPath);
|
||||
|
||||
const restored = openDatabase(databasePath);
|
||||
expect(
|
||||
restored.prepare('SELECT value_json FROM app_settings WHERE key = ?').get('colour'),
|
||||
).toEqual({ value_json: '"blue"' });
|
||||
expect(restored.pragma('integrity_check', { simple: true })).toBe('ok');
|
||||
restored.close();
|
||||
});
|
||||
|
||||
it('rejects a corrupt backup without changing the live database', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const databasePath = join(directory, 'app.sqlite');
|
||||
const corruptPath = join(directory, 'corrupt.sqlite');
|
||||
const database = openDatabase(databasePath);
|
||||
migrateDatabase(database);
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO app_settings (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
.run('safe', 'true', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
|
||||
database.close();
|
||||
const before = await readFile(databasePath);
|
||||
await writeFile(corruptPath, 'not a sqlite database');
|
||||
|
||||
await expect(restoreDatabase(databasePath, corruptPath)).rejects.toThrow(
|
||||
/backup|integrity|database/i,
|
||||
);
|
||||
|
||||
expect(await readFile(databasePath)).toEqual(before);
|
||||
const unchanged = openDatabase(databasePath);
|
||||
expect(
|
||||
unchanged.prepare('SELECT value_json FROM app_settings WHERE key = ?').get('safe'),
|
||||
).toEqual({ value_json: 'true' });
|
||||
unchanged.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user