mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
Serialize all backup operations through a promise-based queue (`withBackupLock`) to prevent race conditions under concurrent requests. Extract internal helpers so `pruneBackups` and `deleteBackup` can participate in the same lock. Strengthen `restoreFromBackup`: write the temp file alongside the live database (`dbDir`), validate it with a full integrity check, foreign key check, and a required-tables list before touching the live DB, then use a synchronous `renameSync` for an atomic swap. Clear WAL/SHM files synchronously in the same tick to avoid interleaving. Run migrations after restore so older backups are brought up-to-date automatically. Add milliseconds to all backup timestamps (`HHmmssSSS`) to avoid filename collisions when backups are created in rapid succession. Update filename regexes to accept both the old and new formats.
271 lines
7.7 KiB
TypeScript
271 lines
7.7 KiB
TypeScript
import { Database } from "bun:sqlite";
|
|
import { renameSync, unlinkSync } from "node:fs";
|
|
import { mkdir, readdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { format } from "date-fns";
|
|
import { sql } from "drizzle-orm";
|
|
import { closeDatabase, db } from "@/lib/db/client";
|
|
import { runMigrations } from "@/lib/db/migrate";
|
|
import { createLogger } from "@/lib/logger";
|
|
|
|
const log = createLogger("backup");
|
|
|
|
const DATA_DIR = process.env.DATA_DIR || "./data";
|
|
const DATABASE_URL =
|
|
process.env.DATABASE_URL || path.join(DATA_DIR, "sqlite.db");
|
|
const BACKUP_DIR = path.join(DATA_DIR, "backups");
|
|
|
|
const MANUAL_PATTERN = /^sofa-manual-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
|
|
const SCHEDULED_PATTERN =
|
|
/^sofa-scheduled-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
|
|
const PRE_RESTORE_PATTERN =
|
|
/^pre-restore-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
|
|
|
|
export type BackupSource = "manual" | "scheduled" | "pre-restore";
|
|
type BackupPrefix = "sofa-manual" | "sofa-scheduled" | "pre-restore";
|
|
|
|
export interface BackupInfo {
|
|
filename: string;
|
|
sizeBytes: number;
|
|
createdAt: string;
|
|
source: BackupSource;
|
|
}
|
|
|
|
const REQUIRED_TABLES = [
|
|
"account",
|
|
"appSettings",
|
|
"availabilityOffers",
|
|
"cronRuns",
|
|
"episodes",
|
|
"seasons",
|
|
"session",
|
|
"titleRecommendations",
|
|
"titles",
|
|
"user",
|
|
"userEpisodeWatches",
|
|
"userMovieWatches",
|
|
"userRatings",
|
|
"userTitleStatus",
|
|
"verification",
|
|
"webhookConnections",
|
|
"webhookEventLog",
|
|
] as const;
|
|
|
|
let backupOpQueue: Promise<void> = Promise.resolve();
|
|
|
|
function getBackupSource(filename: string): BackupSource {
|
|
if (SCHEDULED_PATTERN.test(filename)) return "scheduled";
|
|
if (PRE_RESTORE_PATTERN.test(filename)) return "pre-restore";
|
|
return "manual";
|
|
}
|
|
|
|
function isKnownBackup(filename: string): boolean {
|
|
return (
|
|
MANUAL_PATTERN.test(filename) ||
|
|
SCHEDULED_PATTERN.test(filename) ||
|
|
PRE_RESTORE_PATTERN.test(filename)
|
|
);
|
|
}
|
|
|
|
export async function ensureBackupDir() {
|
|
await mkdir(BACKUP_DIR, { recursive: true });
|
|
}
|
|
|
|
async function withBackupLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
const previous = backupOpQueue;
|
|
let release: (() => void) | undefined;
|
|
backupOpQueue = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
|
|
await previous;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
release?.();
|
|
}
|
|
}
|
|
|
|
function unlinkIfExistsSync(filePath: string): void {
|
|
try {
|
|
unlinkSync(filePath);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateBackupDatabase(filePath: string): void {
|
|
const testDb = new Database(filePath, { readonly: true });
|
|
try {
|
|
const integrityRows = testDb.query("PRAGMA integrity_check").all() as {
|
|
integrity_check: string;
|
|
}[];
|
|
if (
|
|
integrityRows.length === 0 ||
|
|
integrityRows.some((row) => row.integrity_check !== "ok")
|
|
) {
|
|
throw new Error("Database integrity check failed");
|
|
}
|
|
|
|
const foreignKeyErrors = testDb.query("PRAGMA foreign_key_check").all();
|
|
if (foreignKeyErrors.length > 0) {
|
|
throw new Error("Database foreign key check failed");
|
|
}
|
|
|
|
const tableRows = testDb
|
|
.query("SELECT name FROM sqlite_master WHERE type='table'")
|
|
.all() as { name: string }[];
|
|
const tableSet = new Set(tableRows.map((row) => row.name));
|
|
const missing = REQUIRED_TABLES.filter((table) => !tableSet.has(table));
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`Invalid backup: missing required tables (${missing.join(", ")})`,
|
|
);
|
|
}
|
|
} finally {
|
|
testDb.close();
|
|
}
|
|
}
|
|
|
|
function isValidBackupFilename(filename: string): boolean {
|
|
const base = path.basename(filename);
|
|
return (
|
|
base === filename && !filename.includes("..") && isKnownBackup(filename)
|
|
);
|
|
}
|
|
|
|
async function createBackupInternal(prefix: BackupPrefix): Promise<BackupInfo> {
|
|
await ensureBackupDir();
|
|
|
|
const timestamp = format(new Date(), "yyyy-MM-dd-HHmmssSSS");
|
|
const filename = `${prefix}-${timestamp}.db`;
|
|
const dest = path.join(BACKUP_DIR, filename);
|
|
|
|
// VACUUM INTO atomically creates a clean, self-contained copy (safe for WAL mode)
|
|
db.run(sql.raw(`VACUUM INTO '${dest.replace(/'/g, "''")}'`));
|
|
|
|
const s = await Bun.file(dest).stat();
|
|
log.info(`Created backup: ${filename} (${s.size} bytes)`);
|
|
|
|
return {
|
|
filename,
|
|
sizeBytes: s.size,
|
|
createdAt: s.mtime.toISOString(),
|
|
source: getBackupSource(filename),
|
|
};
|
|
}
|
|
|
|
export async function createBackup(
|
|
prefix: BackupPrefix = "sofa-manual",
|
|
): Promise<BackupInfo> {
|
|
return withBackupLock(async () => createBackupInternal(prefix));
|
|
}
|
|
|
|
export async function listBackups(): Promise<BackupInfo[]> {
|
|
await ensureBackupDir();
|
|
|
|
const files = (await readdir(BACKUP_DIR)).filter((f) => isKnownBackup(f));
|
|
|
|
const results: BackupInfo[] = [];
|
|
for (const filename of files) {
|
|
const s = await Bun.file(path.join(BACKUP_DIR, filename)).stat();
|
|
results.push({
|
|
filename,
|
|
sizeBytes: s.size,
|
|
createdAt: s.mtime.toISOString(),
|
|
source: getBackupSource(filename),
|
|
});
|
|
}
|
|
|
|
return results.sort(
|
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
);
|
|
}
|
|
|
|
async function deleteBackupInternal(filename: string): Promise<void> {
|
|
if (!isValidBackupFilename(filename)) {
|
|
throw new Error("Invalid backup filename");
|
|
}
|
|
|
|
const filePath = path.join(BACKUP_DIR, filename);
|
|
if (!(await Bun.file(filePath).exists())) {
|
|
throw new Error("Backup not found");
|
|
}
|
|
|
|
await Bun.file(filePath).delete();
|
|
log.info(`Deleted backup: ${filename}`);
|
|
}
|
|
|
|
export async function deleteBackup(filename: string): Promise<void> {
|
|
await withBackupLock(async () => deleteBackupInternal(filename));
|
|
}
|
|
|
|
export async function getBackupPath(filename: string): Promise<string | null> {
|
|
if (!isValidBackupFilename(filename)) return null;
|
|
|
|
const filePath = path.join(BACKUP_DIR, filename);
|
|
return (await Bun.file(filePath).exists()) ? filePath : null;
|
|
}
|
|
|
|
export async function readBackupFile(filename: string): Promise<Buffer | null> {
|
|
const filePath = await getBackupPath(filename);
|
|
if (!filePath) return null;
|
|
return Buffer.from(await Bun.file(filePath).arrayBuffer());
|
|
}
|
|
|
|
export async function restoreFromBackup(buffer: Buffer): Promise<void> {
|
|
await withBackupLock(async () => {
|
|
await ensureBackupDir();
|
|
|
|
const dbDir = path.dirname(DATABASE_URL);
|
|
await mkdir(dbDir, { recursive: true });
|
|
|
|
const timestamp = format(new Date(), "yyyy-MM-dd-HHmmssSSS");
|
|
const tempPath = path.join(
|
|
dbDir,
|
|
`.restore-temp-${timestamp}-${crypto.randomUUID()}.db`,
|
|
);
|
|
|
|
try {
|
|
await Bun.write(tempPath, buffer);
|
|
validateBackupDatabase(tempPath);
|
|
|
|
log.info("Creating pre-restore safety backup...");
|
|
await createBackupInternal("pre-restore");
|
|
|
|
// Keep the close+replace window synchronous to avoid event-loop interleaving.
|
|
log.info("Replacing database...");
|
|
closeDatabase();
|
|
renameSync(tempPath, DATABASE_URL);
|
|
unlinkIfExistsSync(`${DATABASE_URL}-wal`);
|
|
unlinkIfExistsSync(`${DATABASE_URL}-shm`);
|
|
|
|
// Ensure restored backups from older app versions are brought up-to-date.
|
|
runMigrations();
|
|
log.info("Database restored successfully");
|
|
} finally {
|
|
const tempFile = Bun.file(tempPath);
|
|
if (await tempFile.exists()) await tempFile.delete();
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function pruneBackups(maxKeep: number): Promise<void> {
|
|
await withBackupLock(async () => {
|
|
const backups = (await listBackups()).filter((b) =>
|
|
SCHEDULED_PATTERN.test(b.filename),
|
|
);
|
|
|
|
if (maxKeep === 0 || backups.length <= maxKeep) return;
|
|
|
|
const toDelete = backups.slice(maxKeep);
|
|
for (const backup of toDelete) {
|
|
await deleteBackupInternal(backup.filename);
|
|
}
|
|
|
|
log.info(`Pruned ${toDelete.length} old backup(s), kept ${maxKeep}`);
|
|
});
|
|
}
|