100 lines
5.7 KiB
TypeScript
100 lines
5.7 KiB
TypeScript
import { Database } from "bun:sqlite";
|
||
import { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs";
|
||
import { dirname, join, resolve } from "node:path";
|
||
|
||
export type BackupConfig = { directory: string; replica?: string; retain: number; intervalMs: number; enabled: boolean };
|
||
const filenamePattern = /^minabot-\d{4}-\d{2}-\d{2}T[\d-]+Z-[a-f0-9-]+\.sqlite$/;
|
||
export function backupConfig(database: string, env = process.env): BackupConfig {
|
||
const retain = Number(env.BACKUP_RETAIN ?? 7);
|
||
const hours = Number(env.BACKUP_INTERVAL_HOURS ?? 24);
|
||
if (!Number.isInteger(retain) || retain < 2 || retain > 365 || !Number.isFinite(hours) || hours < 1 || hours > 168)
|
||
throw new Error("BACKUP_RETAIN must be 2–365 and BACKUP_INTERVAL_HOURS must be 1–168");
|
||
const base = dirname(database);
|
||
const directory = resolve(base, env.BACKUP_DIR ?? "backups");
|
||
const replica = env.BACKUP_REPLICA_DIR ? resolve(base, env.BACKUP_REPLICA_DIR) : undefined;
|
||
if (replica === directory) throw new Error("The backup replica must use a separate directory");
|
||
return { directory, replica, retain, intervalMs: hours * 3600000, enabled: env.BACKUP_ENABLED !== "false" };
|
||
}
|
||
function durable(path: string) {
|
||
chmodSync(path, 0o600);
|
||
const fd = openSync(path, "r"); try { fsyncSync(fd); } finally { closeSync(fd); }
|
||
}
|
||
export function verifyBackup(path: string) {
|
||
const db = new Database(path, { readonly: true, strict: true });
|
||
try {
|
||
const integrity = db.query("PRAGMA integrity_check").all() as { integrity_check: string }[];
|
||
if (integrity.length !== 1 || integrity[0]?.integrity_check !== "ok" || db.query("PRAGMA foreign_key_check").all().length)
|
||
throw new Error("Backup integrity check failed");
|
||
db.query("SELECT id FROM users LIMIT 1").all();
|
||
db.query("SELECT id FROM habits LIMIT 1").all();
|
||
db.query("SELECT hash FROM __drizzle_migrations LIMIT 1").all();
|
||
} finally { db.close(); }
|
||
}
|
||
function snapshots(directory: string) {
|
||
if (!existsSync(directory)) return [];
|
||
return readdirSync(directory).filter(name => filenamePattern.test(name)).sort().reverse();
|
||
}
|
||
export function latestBackupTime(config: BackupConfig) {
|
||
const latest = snapshots(config.directory)[0];
|
||
if (!latest || (config.replica && !existsSync(join(config.replica, latest)))) return 0;
|
||
return statSync(join(config.directory, latest)).mtimeMs;
|
||
}
|
||
export function createBackup(database: Database, config: BackupConfig) {
|
||
const name = `minabot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomUUID()}.sqlite`;
|
||
mkdirSync(config.directory, { recursive: true, mode: 0o700 });
|
||
const path = join(config.directory, name);
|
||
const staging = `${path}.partial`;
|
||
mkdirSync(staging, { mode: 0o700 });
|
||
const temporary = join(staging, "snapshot.sqlite");
|
||
try {
|
||
// VACUUM INTO includes committed WAL transactions in one consistent snapshot.
|
||
// SQLite creates the output inside a private staging directory.
|
||
database.query("VACUUM INTO ?").run(temporary);
|
||
verifyBackup(temporary); durable(temporary); renameSync(temporary, path);
|
||
if (config.replica) {
|
||
// Require the configured mount to exist; never silently create an absent mount.
|
||
if (!statSync(config.replica).isDirectory()) throw new Error("Backup replica directory unavailable");
|
||
const replica = join(config.replica, name);
|
||
copyFileSync(path, `${replica}.partial`, constants.COPYFILE_EXCL);
|
||
durable(`${replica}.partial`); verifyBackup(`${replica}.partial`); renameSync(`${replica}.partial`, replica);
|
||
}
|
||
for (const directory of [config.directory, ...(config.replica ? [config.replica] : [])])
|
||
for (const old of snapshots(directory).slice(config.retain)) unlinkSync(join(directory, old));
|
||
return path;
|
||
} finally { rmSync(staging, { recursive: true, force: true }); }
|
||
}
|
||
export function restoreBackup(source: string, destination: string) {
|
||
if (existsSync(destination) || existsSync(`${destination}-wal`) || existsSync(`${destination}-shm`))
|
||
throw new Error("Restore destination must be a new path with no SQLite sidecars");
|
||
verifyBackup(source);
|
||
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
||
const temporary = `${destination}.${crypto.randomUUID()}.partial`;
|
||
try {
|
||
copyFileSync(source, temporary, constants.COPYFILE_EXCL); chmodSync(temporary, 0o600);
|
||
const restored = new Database(temporary);
|
||
try {
|
||
restored.exec("PRAGMA journal_mode=DELETE; DELETE FROM sessions;");
|
||
if (restored.query("SELECT name FROM sqlite_master WHERE name = 'reminder_settings'").get()) restored.exec("UPDATE reminder_settings SET enabled=0");
|
||
} finally { restored.close(); }
|
||
verifyBackup(temporary); durable(temporary);
|
||
// Link is exclusive: a concurrently-created destination is never overwritten.
|
||
linkSync(temporary, destination);
|
||
} finally { if (existsSync(temporary)) unlinkSync(temporary); }
|
||
return destination;
|
||
}
|
||
export function startBackups(database: Database, config: BackupConfig) {
|
||
const state = { enabled: config.enabled, lastSuccess: 0, failed: false };
|
||
if (!config.enabled) return { state, stop() {} };
|
||
const tick = () => {
|
||
try {
|
||
const latest = latestBackupTime(config);
|
||
if (latest && Date.now() - latest < config.intervalMs) { state.lastSuccess = latest; state.failed = false; return; }
|
||
createBackup(database, config); state.lastSuccess = Date.now(); state.failed = false;
|
||
console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date().toISOString() }));
|
||
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); }
|
||
};
|
||
tick();
|
||
const timer = setInterval(tick, 60000); timer.unref();
|
||
return { state, stop: () => clearInterval(timer) };
|
||
}
|