diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index 8c2fb32f8b..775226957b 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -75,6 +75,45 @@ describe("createBufferedTextFileWriter", () => { }); describeEmbeddedPostgres("runDatabaseBackup", () => { + it( + "keeps the newest backup for each retained calendar month", + async () => { + const sourceConnectionString = await createTempDatabase(); + const backupDir = createTempDir("paperclip-db-backup-retention-"); + const realDateNow = Date.now; + Date.now = () => Date.UTC(2026, 2, 31, 12, 0, 0); + + const janNewest = path.join(backupDir, "paperclip-test-2026-01-28T12-00-00.sql.gz"); + const janOlder = path.join(backupDir, "paperclip-test-2026-01-10T12-00-00.sql.gz"); + const decOld = path.join(backupDir, "paperclip-test-2025-12-15T12-00-00.sql.gz"); + + try { + fs.writeFileSync(janNewest, "jan-newest"); + fs.writeFileSync(janOlder, "jan-older"); + fs.writeFileSync(decOld, "dec-old"); + + fs.utimesSync(janNewest, new Date("2026-01-28T12:00:00Z"), new Date("2026-01-28T12:00:00Z")); + fs.utimesSync(janOlder, new Date("2026-01-10T12:00:00Z"), new Date("2026-01-10T12:00:00Z")); + fs.utimesSync(decOld, new Date("2025-12-15T12:00:00Z"), new Date("2025-12-15T12:00:00Z")); + + const result = await runDatabaseBackup({ + connectionString: sourceConnectionString, + backupDir, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 2 }, + filenamePrefix: "paperclip-test", + }); + + expect(result.prunedCount).toBe(2); + expect(fs.existsSync(janNewest)).toBe(true); + expect(fs.existsSync(janOlder)).toBe(false); + expect(fs.existsSync(decOld)).toBe(false); + } finally { + Date.now = realDateNow; + } + }, + 30_000, + ); + it( "backs up and restores large table payloads without materializing one giant string", async () => { diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 41750e77ea..9e3760c758 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -104,7 +104,13 @@ function isoWeekKey(date: Date): string { } function monthKey(date: Date): string { - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`; +} + +function monthlyRetentionCutoff(nowMs: number, monthlyMonths: number): number { + const months = Math.max(1, monthlyMonths); + const now = new Date(nowMs); + return Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - months, 1); } /** @@ -120,7 +126,7 @@ function pruneOldBackups(backupDir: string, retention: BackupRetentionPolicy, fi const now = Date.now(); const dailyCutoff = now - Math.max(1, retention.dailyDays) * 24 * 60 * 60 * 1000; const weeklyCutoff = now - Math.max(1, retention.weeklyWeeks) * 7 * 24 * 60 * 60 * 1000; - const monthlyCutoff = now - Math.max(1, retention.monthlyMonths) * 30 * 24 * 60 * 60 * 1000; + const monthlyCutoff = monthlyRetentionCutoff(now, retention.monthlyMonths); type BackupEntry = { name: string; fullPath: string; mtimeMs: number }; const entries: BackupEntry[] = [];