feat(backup): runDaily() with .last-snapshot marker + rotate after snapshot

Skips when marker matches today's KST date. Marker written after
successful snapshot, before rotation. lastSnapshotAt() exposed for UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-26 03:08:30 +09:00
parent a728434b2e
commit 4898e13308
2 changed files with 112 additions and 1 deletions

View File

@@ -1,9 +1,10 @@
import type Database from 'better-sqlite3';
import { mkdir, rename, stat, readdir, unlink } from 'node:fs/promises';
import { mkdir, rename, stat, readdir, unlink, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { applyGfsRetention } from './backupRotation.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const MARKER_FILENAME = '.last-snapshot';
function toKstDateKey(d: Date): string {
const k = new Date(d.getTime() + KST_OFFSET_MS);
@@ -20,6 +21,15 @@ export interface RotateResult {
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,
@@ -64,4 +74,32 @@ export class BackupService {
}
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
};
}
}

View File

@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runMigrations } from '@main/db/migrations/index.js';
import { BackupService } from '@main/services/BackupService.js';
import { readFileSync, writeFileSync } from 'node:fs';
describe('BackupService.snapshot', () => {
let dir: string;
@@ -96,3 +97,75 @@ describe('BackupService.snapshot', () => {
expect(remaining.filter((f) => f.endsWith('.sqlite'))).toEqual([]);
});
});
describe('BackupService.runDaily', () => {
let dir: string;
let db: Database.Database;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'inkling-backup-'));
db = new Database(':memory:');
runMigrations(db);
});
afterEach(() => {
db.close();
rmSync(dir, { recursive: true, force: true });
});
it('snapshots when marker is absent', async () => {
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
const r = await svc.runDaily();
expect(r.snapshotted).toBe(true);
expect(existsSync(join(dir, '.last-snapshot'))).toBe(true);
expect(existsSync(join(dir, 'inkling-2026-04-26.sqlite'))).toBe(true);
});
it('skips when marker shows today already snapshotted', async () => {
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
await svc.runDaily(); // first
const r = await svc.runDaily(); // second same day
expect(r.snapshotted).toBe(false);
expect(r.reason).toMatch(/already/);
});
it('snapshots again when marker shows different date', async () => {
// Pre-seed marker as yesterday
const dir2 = dir;
await new BackupService(db, dir2, () => new Date('2026-04-25T12:00:00Z')).runDaily();
const svc = new BackupService(db, dir2, () => new Date('2026-04-26T12:00:00Z'));
const r = await svc.runDaily();
expect(r.snapshotted).toBe(true);
expect(existsSync(join(dir2, 'inkling-2026-04-26.sqlite'))).toBe(true);
expect(existsSync(join(dir2, 'inkling-2026-04-25.sqlite'))).toBe(true);
});
it('runs rotation after snapshot', async () => {
// Pre-create an old file that should be rotated out
const ancient = join(dir, 'inkling-2024-01-01.sqlite');
writeFileSync(ancient, 'fake');
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
const r = await svc.runDaily();
expect(r.snapshotted).toBe(true);
expect(r.removed).toContain('inkling-2024-01-01.sqlite');
expect(existsSync(ancient)).toBe(false);
});
it('marker contains ISO date matching the snapshot file', async () => {
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
await svc.runDaily();
const marker = readFileSync(join(dir, '.last-snapshot'), 'utf8').trim();
expect(marker).toBe('2026-04-26');
});
it('lastSnapshotAt returns null when marker absent', async () => {
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
expect(await svc.lastSnapshotAt()).toBeNull();
});
it('lastSnapshotAt returns marker date when present', async () => {
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
await svc.runDaily();
expect(await svc.lastSnapshotAt()).toBe('2026-04-26');
});
});