feat(storage): add resilient SQLite foundation

This commit is contained in:
chick
2026-07-16 13:20:59 +08:00
parent 26c1bcc6da
commit ef9a634a02
9 changed files with 1282 additions and 1 deletions
@@ -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();
});
});