diff --git a/src/main/services/BackupService.ts b/src/main/services/BackupService.ts index 06b975a..6e60a0b 100644 --- a/src/main/services/BackupService.ts +++ b/src/main/services/BackupService.ts @@ -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 { + 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 { + 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 + }; + } } diff --git a/tests/unit/BackupService.test.ts b/tests/unit/BackupService.test.ts index cfe9eef..4a0ad8e 100644 --- a/tests/unit/BackupService.test.ts +++ b/tests/unit/BackupService.test.ts @@ -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'); + }); +});