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:
altair823
2026-04-26 02:07:09 +09:00
parent 7973ea5046
commit 5e8e652ee0
2 changed files with 169 additions and 0 deletions

View 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 };
}