feat(backup): atomic SQLite snapshot to inkling-YYYY-MM-DD.sqlite

KST date filename, tmp+rename atomic write, mkdir on demand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-26 02:44:28 +09:00
parent 603588cc4f
commit 714dd3fc9f
2 changed files with 133 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
import type Database from 'better-sqlite3';
import { mkdir, rename, stat, readdir, unlink } from 'node:fs/promises';
import { join } from 'node:path';
import { applyGfsRetention } from './backupRotation.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
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 class BackupService {
constructor(
private db: Database.Database,
private backupDir: string,
private now: () => Date = () => new Date()
) {}
async snapshot(): Promise<SnapshotResult> {
await mkdir(this.backupDir, { recursive: true });
const dateKey = toKstDateKey(this.now());
const finalPath = join(this.backupDir, `inkling-${dateKey}.sqlite`);
const tmpPath = `${finalPath}.tmp`;
await this.db.backup(tmpPath);
await rename(tmpPath, finalPath);
const st = await stat(finalPath);
return { path: finalPath, bytes: st.size };
}
async rotate(): Promise<RotateResult> {
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 };
}
}