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) <noreply@anthropic.com>
This commit is contained in:
75
src/main/services/backupRotation.ts
Normal file
75
src/main/services/backupRotation.ts
Normal file
@@ -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 };
|
||||
}
|
||||
94
tests/unit/backupRotation.test.ts
Normal file
94
tests/unit/backupRotation.test.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user