Files
inkling/src/main/services/BackupService.ts
altair823 41310dbe6a refactor(v032): KST_OFFSET_MS inline → @shared/util/kstDate import (#19)
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>
2026-05-10 14:38:58 +09:00

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
};
}
}