5 callsite (NoteRepository, ftsHelpers, BackupService, ContinuityService,
NoteCard) 모두 canonical export 로 정리. 알고리즘 동일 (9 * 60 * 60 * 1000),
회귀 PASS 검증.
v0.2.6 commit 3cfa60b 가 4 callsite migrate 했지만 5 callsite 잔여.
Cut F audit 에서 발견.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
105 lines
3.0 KiB
TypeScript
105 lines
3.0 KiB
TypeScript
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<SnapshotResult> {
|
|
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<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 };
|
|
}
|
|
|
|
async lastSnapshotAt(): Promise<string | null> {
|
|
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<DailyResult> {
|
|
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
|
|
};
|
|
}
|
|
}
|