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,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();
}
}