feat(storage): add resilient SQLite foundation
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { chmod, copyFile, lstat, mkdir, open, rename, rm, stat } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { beginRestore, getManagedDatabaseIdentity, type SqliteDatabase } from './database.js';
|
||||
|
||||
async function assertRegularFile(path: string, label: string): Promise<void> {
|
||||
const details = await lstat(path);
|
||||
if (details.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link`);
|
||||
if (!details.isFile()) throw new Error(`${label} must be a regular file`);
|
||||
}
|
||||
|
||||
async function assertDistinctFiles(databasePath: string, backupPath: string): Promise<void> {
|
||||
if (resolve(databasePath) === resolve(backupPath))
|
||||
throw new Error('Live and backup paths are the same');
|
||||
await assertRegularFile(backupPath, 'Backup');
|
||||
try {
|
||||
await assertRegularFile(databasePath, 'Live database');
|
||||
const [live, backup] = await Promise.all([stat(databasePath), stat(backupPath)]);
|
||||
if (live.dev === backup.dev && live.ino === backup.ino)
|
||||
throw new Error('Live and backup paths are aliases of the same file');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function reservePrivateFile(path: string): Promise<void> {
|
||||
const handle = await open(path, 'wx', 0o600);
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
async function assertValidDatabase(path: string): Promise<void> {
|
||||
let database: Database.Database | undefined;
|
||||
try {
|
||||
database = new Database(path, { fileMustExist: true, readonly: true });
|
||||
const integrity = database.pragma('integrity_check', { simple: true });
|
||||
if (integrity !== 'ok') throw new Error(`Backup integrity check failed: ${String(integrity)}`);
|
||||
} catch (error) {
|
||||
throw new Error('Backup is not a valid SQLite database', { cause: error });
|
||||
} finally {
|
||||
database?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncFile(path: string): Promise<void> {
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function backupDatabase(database: SqliteDatabase, backupPath: string): Promise<void> {
|
||||
const source = getManagedDatabaseIdentity(database);
|
||||
const directory = dirname(backupPath);
|
||||
await mkdir(directory, { recursive: true });
|
||||
try {
|
||||
const destination = await stat(backupPath);
|
||||
if (`${destination.dev}:${destination.ino}` === source.deviceInode) {
|
||||
throw new Error('Backup destination aliases the live database identity');
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
const temporaryPath = `${backupPath}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await reservePrivateFile(temporaryPath);
|
||||
await database.backup(temporaryPath);
|
||||
await chmod(temporaryPath, 0o600);
|
||||
await assertValidDatabase(temporaryPath);
|
||||
await syncFile(temporaryPath);
|
||||
await rename(temporaryPath, backupPath);
|
||||
await chmod(backupPath, 0o600);
|
||||
await syncDirectory(directory);
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreDatabase(databasePath: string, backupPath: string): Promise<void> {
|
||||
const endRestore = beginRestore(databasePath);
|
||||
const directory = dirname(databasePath);
|
||||
const temporaryPath = `${databasePath}.${randomUUID()}.restore`;
|
||||
try {
|
||||
await assertDistinctFiles(databasePath, backupPath);
|
||||
await assertValidDatabase(backupPath);
|
||||
await mkdir(directory, { recursive: true });
|
||||
await reservePrivateFile(temporaryPath);
|
||||
await copyFile(backupPath, temporaryPath);
|
||||
await chmod(temporaryPath, 0o600);
|
||||
await assertValidDatabase(temporaryPath);
|
||||
await syncFile(temporaryPath);
|
||||
|
||||
// Atomic replacement comes first: any failure before this point leaves live and sidecars intact.
|
||||
await rename(temporaryPath, databasePath);
|
||||
await chmod(databasePath, 0o600);
|
||||
await syncDirectory(directory);
|
||||
// No managed connection can exist under the exclusive gate; stale sidecars are now safe to remove.
|
||||
await rm(`${databasePath}-wal`, { force: true });
|
||||
await rm(`${databasePath}-shm`, { force: true });
|
||||
await syncDirectory(directory);
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true });
|
||||
endRestore();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user