import type Database from 'better-sqlite3'; import { mkdir, rename, stat, readdir, unlink, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { applyGfsRetention } from './backupRotation.js'; import { KST_OFFSET_MS } from '../../shared/util/kstDate.js'; const MARKER_FILENAME = '.last-snapshot'; function toKstDateKey(d: Date): string { const k = new Date(d.getTime() + KST_OFFSET_MS); return k.toISOString().slice(0, 10); } export interface SnapshotResult { path: string; bytes: number; } export interface RotateResult { kept: string[]; removed: string[]; } export interface DailyResult { snapshotted: boolean; reason?: string; path?: string; bytes?: number; kept?: string[]; removed?: string[]; } export class BackupService { constructor( private db: Database.Database, private backupDir: string, private now: () => Date = () => new Date() ) {} /** * Atomic snapshot of the SQLite DB to `inkling-YYYY-MM-DD.sqlite` (KST). * * NOT safe for concurrent calls — two parallel calls race on the same * tmp/final path. Callers should serialize via {@link runDaily}, which * gates on the `.last-snapshot` marker. */ async snapshot(): Promise { await mkdir(this.backupDir, { recursive: true }); const dateKey = toKstDateKey(this.now()); const finalPath = join(this.backupDir, `inkling-${dateKey}.sqlite`); const tmpPath = `${finalPath}.tmp`; try { await this.db.backup(tmpPath); await rename(tmpPath, finalPath); } catch (e) { await unlink(tmpPath).catch(() => { /* tmp may not exist */ }); throw e; } const st = await stat(finalPath); return { path: finalPath, bytes: st.size }; } async rotate(): Promise { let entries: string[]; try { entries = await readdir(this.backupDir); } catch (e: unknown) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { kept: [], removed: [] }; throw e; } const decision = applyGfsRetention(entries, this.now()); for (const name of decision.remove) { await unlink(join(this.backupDir, name)); } return { kept: decision.keep, removed: decision.remove }; } async lastSnapshotAt(): Promise { try { const raw = await readFile(join(this.backupDir, MARKER_FILENAME), 'utf8'); return raw.trim() || null; } catch (e: unknown) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; throw e; } } async runDaily(): Promise { const today = toKstDateKey(this.now()); const last = await this.lastSnapshotAt(); if (last === today) { return { snapshotted: false, reason: 'already snapshotted today' }; } const snap = await this.snapshot(); await writeFile(join(this.backupDir, MARKER_FILENAME), today, 'utf8'); const rot = await this.rotate(); return { snapshotted: true, path: snap.path, bytes: snap.bytes, kept: rot.kept, removed: rot.removed }; } }