Code reviewer minor nitpicks: - Add test for inkling-2026-02-30.sqlite (locks roundtrip-validation contract) - Add test for weekly window inclusive at oldest boundary - Precompute today=startOfDayUtc(now) once outside the loop, pass to helpers No behavior change; tests added cover existing semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
78 lines
2.9 KiB
TypeScript
78 lines
2.9 KiB
TypeScript
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_PRIOR_MONDAYS = 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, today: Date): boolean {
|
|
const oldest = new Date(today.getTime() - (DAILY_WINDOW_DAYS - 1) * ONE_DAY_MS);
|
|
return fileDate >= oldest && fileDate <= today;
|
|
}
|
|
|
|
function isWithinWeeklyWindow(fileDate: Date, today: Date): boolean {
|
|
// UTC-based Monday detection. UTCDay: 0=Sun, 1=Mon..6=Sat
|
|
if (fileDate.getUTCDay() !== 1) return false;
|
|
// Weekly window: anchor on the most recent Monday on/before `today`, then reach back
|
|
// WEEKLY_WINDOW_PRIOR_MONDAYS * 7 days from that anchor. Effective semantic:
|
|
// up to 5 distinct Mondays kept (the anchor Monday + 4 prior). The plan's commit
|
|
// header says "4 weekly Mondays" but the plan's test case at
|
|
// backupRotation.test.ts requires 03-23 (5th Monday from 2026-04-26) to be kept,
|
|
// so the test is treated as source of truth.
|
|
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_PRIOR_MONDAYS * 7 * ONE_DAY_MS);
|
|
return fileDate >= oldest && fileDate <= today;
|
|
}
|
|
|
|
function isWithinMonthlyWindow(fileDate: Date, today: Date): boolean {
|
|
if (fileDate.getUTCDate() !== 1) return false;
|
|
// 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[] = [];
|
|
const today = startOfDayUtc(now);
|
|
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 > today) {
|
|
keep.push(name); // future-dated — clock skew safety
|
|
continue;
|
|
}
|
|
const survives =
|
|
isWithinDailyWindow(fileDate, today) ||
|
|
isWithinWeeklyWindow(fileDate, today) ||
|
|
isWithinMonthlyWindow(fileDate, today);
|
|
if (survives) keep.push(name);
|
|
else remove.push(name);
|
|
}
|
|
return { keep, remove };
|
|
}
|