mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
be016f180e
The specs written alongside the session, transaction and lifecycle work still seeded and read with raw statements. They go through the ORM factories now; the few that test SQL below the ORM carry a test-sql-allow reason. The raw SQL baseline holds 177 statements in 6 files.
253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
/**
|
|
* SqliteDatabaseBackup: the database backup port for a SQLite file.
|
|
*
|
|
* The snapshot and the swap run for real here, against a database file in a
|
|
* temp directory: a VACUUM INTO through the real MaintenanceRepository, and the
|
|
* copy, sidecar removal and rename of a restore. Only the connection lifecycle
|
|
* is a stand-in, because closing and reopening the process's own connection is
|
|
* DatabaseLifecycle's business (tests/unit/nest/database/).
|
|
*/
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import Database from 'better-sqlite3';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), logDebug: vi.fn() }));
|
|
vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
|
|
|
|
import { SqliteDatabaseBackup } from '../../../src/nest/backup/sqlite-database-backup';
|
|
import { DatabaseConnectionLostError } from '../../../src/nest/database/database-backup.interface';
|
|
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
|
|
import type { DatabaseLifecycle } from '../../../src/nest/database/database-lifecycle.service';
|
|
import { withRequestContext } from '../../../src/nest/database/request-context';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
|
|
let dir: string;
|
|
let liveFile: string;
|
|
let lifecycle: { file: string; close: ReturnType<typeof vi.fn>; reopen: ReturnType<typeof vi.fn> };
|
|
|
|
function maintenanceStub() {
|
|
return { walCheckpoint: vi.fn(async () => {}), vacuumInto: vi.fn(async (_target: string) => {}) };
|
|
}
|
|
|
|
function portWith(maintenance: { walCheckpoint: unknown; vacuumInto: unknown }): SqliteDatabaseBackup {
|
|
return new SqliteDatabaseBackup(lifecycle as unknown as DatabaseLifecycle, maintenance as unknown as MaintenanceRepository);
|
|
}
|
|
|
|
function writeDb(file: string, label: string): void {
|
|
const db = new Database(file);
|
|
db.exec('CREATE TABLE marker (label TEXT NOT NULL)');
|
|
// test-sql-allow: the marker table is a bare fixture file no entity maps, and no ORM is bound to this handle.
|
|
db.prepare('INSERT INTO marker (label) VALUES (?)').run(label);
|
|
db.close();
|
|
}
|
|
|
|
function readLabel(file: string): string {
|
|
const db = new Database(file, { readonly: true });
|
|
try {
|
|
// test-sql-allow: reads the copied file on a read-only handle no ORM is bound to, from a table no entity maps.
|
|
return (db.prepare('SELECT label FROM marker').get() as { label: string }).label;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-sqlite-backup-'));
|
|
liveFile = path.join(dir, 'live.db');
|
|
lifecycle = { file: liveFile, close: vi.fn(), reopen: vi.fn(async () => {}) };
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('SqliteDatabaseBackup', () => {
|
|
it('SQLBK-001: names the archive entry travel.db and reports the file the connection runs on', () => {
|
|
const port = portWith(maintenanceStub());
|
|
expect(port.archiveEntry).toBe('travel.db');
|
|
expect(port.location()).toBe(liveFile);
|
|
});
|
|
|
|
it('SQLBK-002: can snapshot an existing file, never the in-memory database or a missing file', () => {
|
|
const port = portWith(maintenanceStub());
|
|
expect(port.canSnapshot()).toBe(false);
|
|
writeDb(liveFile, 'live');
|
|
expect(port.canSnapshot()).toBe(true);
|
|
lifecycle.file = ':memory:';
|
|
expect(port.canSnapshot()).toBe(false);
|
|
});
|
|
|
|
it('SQLBK-003: the checkpoint is the WAL checkpoint', async () => {
|
|
const maintenance = maintenanceStub();
|
|
await portWith(maintenance).checkpoint();
|
|
expect(maintenance.walCheckpoint).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
describe('snapshot, through the real VACUUM INTO', () => {
|
|
let live: Database.Database;
|
|
let orm: TestOrm;
|
|
|
|
beforeEach(async () => {
|
|
writeDb(liveFile, 'live');
|
|
live = new Database(liveFile);
|
|
live.exec('PRAGMA journal_mode = WAL');
|
|
orm = await createTestOrm(live);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await orm.close();
|
|
live.close();
|
|
});
|
|
|
|
it('SQLBK-004: writes a consistent copy of the live database', async () => {
|
|
// A write that sits in the WAL only: VACUUM INTO still carries it.
|
|
// test-sql-allow: the marker table is a bare fixture no entity maps; the write has to land in the WAL of this file.
|
|
live.prepare('UPDATE marker SET label = ?').run('after');
|
|
const target = path.join(dir, 'copy.db');
|
|
const port = portWith(new MaintenanceRepository(orm.em));
|
|
|
|
await withRequestContext(orm.orm, () => port.snapshot(target));
|
|
|
|
expect(readLabel(target)).toBe('after');
|
|
});
|
|
|
|
it('SQLBK-005: a VACUUM INTO that fails rejects and leaves nothing at the target', async () => {
|
|
const target = path.join(dir, 'missing-dir', 'copy.db');
|
|
const port = portWith(new MaintenanceRepository(orm.em));
|
|
|
|
await expect(withRequestContext(orm.orm, () => port.snapshot(target))).rejects.toThrow();
|
|
|
|
expect(fs.existsSync(target)).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('SQLBK-006: a partial file a failed snapshot left behind is removed before the error goes on', async () => {
|
|
const target = path.join(dir, 'partial.db');
|
|
const maintenance = maintenanceStub();
|
|
maintenance.vacuumInto.mockImplementationOnce(async (to: string) => {
|
|
fs.writeFileSync(to, 'half a database');
|
|
throw new Error('database or disk is full');
|
|
});
|
|
|
|
await expect(portWith(maintenance).snapshot(target)).rejects.toThrow('database or disk is full');
|
|
|
|
expect(fs.existsSync(target)).toBe(false);
|
|
});
|
|
|
|
it('SQLBK-007: verify refuses an unpacked archive without a database', () => {
|
|
expect(portWith(maintenanceStub()).verify(dir)).toEqual({ error: 'Invalid backup: travel.db not found', status: 400 });
|
|
});
|
|
|
|
it('SQLBK-008: keeps a copy of the database a restore replaces next to it, and says where', async () => {
|
|
const maintenance = maintenanceStub();
|
|
|
|
const kept = await portWith(maintenance).keepCopyBeforeRestore();
|
|
|
|
expect(kept).not.toBeNull();
|
|
expect(path.dirname(kept as string)).toBe(dir);
|
|
expect(path.basename(kept as string)).toMatch(/^pre-restore-\d+\.db$/);
|
|
expect(maintenance.vacuumInto).toHaveBeenCalledWith(kept);
|
|
expect(logMock.logInfo).toHaveBeenCalledWith(`Restore: the replaced database was kept as ${kept}`);
|
|
});
|
|
|
|
it('SQLBK-009: a copy that cannot be kept does not block the restore', async () => {
|
|
const maintenance = maintenanceStub();
|
|
maintenance.vacuumInto.mockRejectedValueOnce('database disk image is malformed');
|
|
|
|
await expect(portWith(maintenance).keepCopyBeforeRestore()).resolves.toBeNull();
|
|
|
|
expect(logMock.logWarn).toHaveBeenCalledWith('Restore: could not keep a copy of the current database (database disk image is malformed)');
|
|
});
|
|
|
|
describe('replace', () => {
|
|
it('SQLBK-010: closes, swaps the file in by rename, drops the old sidecars and reopens', async () => {
|
|
writeDb(liveFile, 'live');
|
|
const source = path.join(dir, 'restored.db');
|
|
writeDb(source, 'restored');
|
|
const liveBytes = fs.readFileSync(liveFile);
|
|
const sourceBytes = fs.readFileSync(source);
|
|
fs.writeFileSync(liveFile + '-wal', 'stale wal');
|
|
fs.writeFileSync(liveFile + '-shm', 'stale shm');
|
|
// Which database sits at the live path at each step, told by its bytes:
|
|
// no handle is opened on a file whose sidecars are deliberately stale.
|
|
const which = () => {
|
|
const now = fs.readFileSync(liveFile);
|
|
if (now.equals(liveBytes)) return 'live';
|
|
return now.equals(sourceBytes) ? 'restored' : 'other';
|
|
};
|
|
const order: string[] = [];
|
|
lifecycle.close.mockImplementation(() => order.push(`close:${which()}`));
|
|
lifecycle.reopen.mockImplementation(async () => {
|
|
order.push(`reopen:${which()}`);
|
|
});
|
|
|
|
const result = await portWith(maintenanceStub()).replace(source);
|
|
|
|
expect(result).toEqual({ reopenError: null });
|
|
expect(order).toEqual(['close:live', 'reopen:restored']);
|
|
expect(fs.existsSync(liveFile + '-wal')).toBe(false);
|
|
expect(fs.existsSync(liveFile + '-shm')).toBe(false);
|
|
expect(fs.existsSync(liveFile + '.restore-tmp')).toBe(false);
|
|
// The source stays: it belongs to the caller (an unpacked archive, the demo baseline).
|
|
expect(readLabel(source)).toBe('restored');
|
|
});
|
|
|
|
it('SQLBK-011: a reopen that fails after the swap is reported, not thrown', async () => {
|
|
writeDb(liveFile, 'live');
|
|
const source = path.join(dir, 'restored.db');
|
|
writeDb(source, 'restored');
|
|
const failure = new Error('database is locked');
|
|
lifecycle.reopen.mockRejectedValueOnce(failure);
|
|
|
|
const result = await portWith(maintenanceStub()).replace(source);
|
|
|
|
expect(result.reopenError).toBe(failure);
|
|
expect(readLabel(liveFile)).toBe('restored');
|
|
});
|
|
|
|
it('SQLBK-012: a swap that fails still reopens, leaves the live file as it was and rethrows', async () => {
|
|
writeDb(liveFile, 'live');
|
|
|
|
await expect(portWith(maintenanceStub()).replace(path.join(dir, 'does-not-exist.db'))).rejects.toThrow(/ENOENT/);
|
|
|
|
expect(lifecycle.close).toHaveBeenCalledTimes(1);
|
|
expect(lifecycle.reopen).toHaveBeenCalledTimes(1);
|
|
expect(readLabel(liveFile)).toBe('live');
|
|
});
|
|
|
|
it('SQLBK-013: a swap that fails and a reopen that fails too both go up, so the lost connection is not hidden', async () => {
|
|
writeDb(liveFile, 'live');
|
|
const reopenFailure = new Error('database is locked');
|
|
lifecycle.reopen.mockRejectedValueOnce(reopenFailure);
|
|
|
|
const thrown = await portWith(maintenanceStub())
|
|
.replace(path.join(dir, 'does-not-exist.db'))
|
|
.then(
|
|
() => null,
|
|
(err: unknown) => err,
|
|
);
|
|
|
|
expect(thrown).toBeInstanceOf(DatabaseConnectionLostError);
|
|
const lost = thrown as DatabaseConnectionLostError;
|
|
expect((lost.swapError as Error).message).toMatch(/ENOENT/);
|
|
expect(lost.cause).toBe(reopenFailure);
|
|
expect(lost.message).toMatch(/could not be replaced \(ENOENT.*\) and the connection could not be reopened \(database is locked\)\. Restart the server\.$/);
|
|
expect(lifecycle.reopen).toHaveBeenCalledTimes(1);
|
|
expect(readLabel(liveFile)).toBe('live');
|
|
});
|
|
});
|
|
|
|
it('SQLBK-014: the lost-connection error names values that are not Errors as they are', () => {
|
|
const lost = new DatabaseConnectionLostError('disk gone', 42);
|
|
expect(lost.name).toBe('DatabaseConnectionLostError');
|
|
expect(lost.swapError).toBe('disk gone');
|
|
expect(lost.cause).toBe(42);
|
|
expect(lost.message).toBe(
|
|
'The database could not be replaced (disk gone) and the connection could not be reopened (42). Restart the server.',
|
|
);
|
|
});
|
|
});
|