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:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user