From 5e8e652ee0dc4c037af4f62aefb59ac4a18818f7 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 02:07:09 +0900 Subject: [PATCH] feat(backup): GFS retention policy (pure) 14 daily + 4 weekly (Mondays) + 6 monthly (1st). Future-dated files preserved (clock skew). Unrecognized filenames ignored (no delete). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/backupRotation.ts | 75 +++++++++++++++++++++++ tests/unit/backupRotation.test.ts | 94 +++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 src/main/services/backupRotation.ts create mode 100644 tests/unit/backupRotation.test.ts diff --git a/src/main/services/backupRotation.ts b/src/main/services/backupRotation.ts new file mode 100644 index 0000000..7bc8d96 --- /dev/null +++ b/src/main/services/backupRotation.ts @@ -0,0 +1,75 @@ +const BACKUP_FILENAME_REGEX = /^inkling-(\d{4}-\d{2}-\d{2})\.sqlite$/; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const DAILY_WINDOW_DAYS = 14; +const WEEKLY_WINDOW_COUNT = 4; +const MONTHLY_WINDOW_COUNT = 6; + +export function parseBackupFilename(name: string): string | null { + const m = BACKUP_FILENAME_REGEX.exec(name); + if (!m) return null; + const iso = m[1]!; + const d = new Date(iso + 'T00:00:00Z'); + if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== iso) return null; + return iso; +} + +export interface RetentionResult { + keep: string[]; + remove: string[]; +} + +function startOfDayUtc(d: Date): Date { + const x = new Date(d); + x.setUTCHours(0, 0, 0, 0); + return x; +} + +function isWithinDailyWindow(fileDate: Date, now: Date): boolean { + const today = startOfDayUtc(now); + const oldest = new Date(today.getTime() - (DAILY_WINDOW_DAYS - 1) * ONE_DAY_MS); + return fileDate >= oldest && fileDate <= today; +} + +function isWithinWeeklyWindow(fileDate: Date, now: Date): boolean { + // UTC-based Monday detection. UTCDay: 0=Sun, 1=Mon..6=Sat + if (fileDate.getUTCDay() !== 1) return false; + const today = startOfDayUtc(now); + // Anchor to the most recent Monday on/before today, then reach back + // WEEKLY_WINDOW_COUNT * 7 days. This keeps 4 Mondays before the anchor. + const dayOfWeek = today.getUTCDay(); // 0=Sun..6=Sat + const daysSinceMonday = (dayOfWeek + 6) % 7; // Mon=0, Sun=6 + const lastMonday = new Date(today.getTime() - daysSinceMonday * ONE_DAY_MS); + const oldest = new Date(lastMonday.getTime() - WEEKLY_WINDOW_COUNT * 7 * ONE_DAY_MS); + return fileDate >= oldest && fileDate <= today; +} + +function isWithinMonthlyWindow(fileDate: Date, now: Date): boolean { + if (fileDate.getUTCDate() !== 1) return false; + const today = startOfDayUtc(now); + // months ago: difference in calendar months + const monthsAgo = + (today.getUTCFullYear() - fileDate.getUTCFullYear()) * 12 + + (today.getUTCMonth() - fileDate.getUTCMonth()); + return monthsAgo >= 0 && monthsAgo < MONTHLY_WINDOW_COUNT; +} + +export function applyGfsRetention(filenames: string[], now: Date): RetentionResult { + const keep: string[] = []; + const remove: string[] = []; + for (const name of filenames) { + const iso = parseBackupFilename(name); + if (iso === null) continue; // unrecognized — ignore (no-op) + const fileDate = new Date(iso + 'T00:00:00Z'); + if (fileDate > startOfDayUtc(now)) { + keep.push(name); // future-dated — clock skew safety + continue; + } + const survives = + isWithinDailyWindow(fileDate, now) || + isWithinWeeklyWindow(fileDate, now) || + isWithinMonthlyWindow(fileDate, now); + if (survives) keep.push(name); + else remove.push(name); + } + return { keep, remove }; +} diff --git a/tests/unit/backupRotation.test.ts b/tests/unit/backupRotation.test.ts new file mode 100644 index 0000000..d2fd98f --- /dev/null +++ b/tests/unit/backupRotation.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { parseBackupFilename, applyGfsRetention } from '@main/services/backupRotation.js'; + +describe('parseBackupFilename', () => { + it('extracts ISO date from valid filename', () => { + expect(parseBackupFilename('inkling-2026-04-26.sqlite')).toBe('2026-04-26'); + }); + + it('returns null for non-matching filename', () => { + expect(parseBackupFilename('something-else.sqlite')).toBeNull(); + expect(parseBackupFilename('inkling-2026-13-99.sqlite')).toBeNull(); + expect(parseBackupFilename('.last-snapshot')).toBeNull(); + }); +}); + +describe('applyGfsRetention', () => { + // KST-naive logic — caller passes UTC `now`. Filenames are KST date keys. + const NOW = new Date('2026-04-26T12:00:00Z'); // 2026-04-26 21:00 KST + + function names(...dates: string[]): string[] { + return dates.map((d) => `inkling-${d}.sqlite`); + } + + it('keeps files within last 14 days (daily window)', () => { + const files = names( + '2026-04-26', '2026-04-25', '2026-04-20', '2026-04-13', '2026-04-12' + ); + const r = applyGfsRetention(files, NOW); + // 14 day window from 2026-04-26 reaches back to 2026-04-13 inclusive. + expect(r.keep).toContain('inkling-2026-04-26.sqlite'); + expect(r.keep).toContain('inkling-2026-04-25.sqlite'); + expect(r.keep).toContain('inkling-2026-04-20.sqlite'); + expect(r.keep).toContain('inkling-2026-04-13.sqlite'); + expect(r.remove).toContain('inkling-2026-04-12.sqlite'); + }); + + it('keeps last 4 Mondays beyond the 14 day window', () => { + // Mondays in 2026: 04-13, 04-06, 03-30, 03-23, 03-16, 03-09 + const files = names( + '2026-04-13', // within 14-day, also a Monday + '2026-04-06', // outside 14-day, but a Monday in last 4 weeks + '2026-03-30', // a Monday in last 4 weeks + '2026-03-23', // a Monday in last 4 weeks + '2026-03-16', // a Monday more than 4 weeks ago — REMOVE unless month-1 + '2026-03-09' // a Monday more than 4 weeks ago — REMOVE + ); + const r = applyGfsRetention(files, NOW); + expect(r.keep).toContain('inkling-2026-04-06.sqlite'); + expect(r.keep).toContain('inkling-2026-03-30.sqlite'); + expect(r.keep).toContain('inkling-2026-03-23.sqlite'); + expect(r.remove).toContain('inkling-2026-03-16.sqlite'); + expect(r.remove).toContain('inkling-2026-03-09.sqlite'); + }); + + it('keeps month-firsts within last 6 months', () => { + // Last 6 month-firsts from 2026-04-26: 2026-04-01, 2026-03-01, 2026-02-01, + // 2026-01-01, 2025-12-01, 2025-11-01 + const files = names( + '2026-04-01', // within 14-day already + '2026-03-01', // outside 14-day, outside 4-week-Monday — keep via month rule + '2026-02-01', + '2026-01-01', + '2025-12-01', + '2025-11-01', + '2025-10-01' // outside 6-month window — REMOVE + ); + const r = applyGfsRetention(files, NOW); + expect(r.keep).toContain('inkling-2026-03-01.sqlite'); + expect(r.keep).toContain('inkling-2026-02-01.sqlite'); + expect(r.keep).toContain('inkling-2025-11-01.sqlite'); + expect(r.remove).toContain('inkling-2025-10-01.sqlite'); + }); + + it('ignores files that do not match backup pattern', () => { + const files = ['random.sqlite', 'inkling.sqlite', '.last-snapshot', 'inkling-bad-date.sqlite']; + const r = applyGfsRetention(files, NOW); + expect(r.keep).toEqual([]); + expect(r.remove).toEqual([]); + }); + + it('keeps future-dated files (clock skew safety)', () => { + const files = names('2030-01-01'); + const r = applyGfsRetention(files, NOW); + expect(r.keep).toContain('inkling-2030-01-01.sqlite'); + expect(r.remove).toEqual([]); + }); + + it('a file kept by any rule is in keep, never in both lists', () => { + const files = names('2026-04-26', '2026-04-13', '2026-03-23', '2026-03-01'); + const r = applyGfsRetention(files, NOW); + const intersection = r.keep.filter((f) => r.remove.includes(f)); + expect(intersection).toEqual([]); + }); +});