fix(db): use calendar month retention for backups (#3718)
## Thinking Path > - Paperclip includes built-in database backup and retention behavior as part of its operational reliability surface. > - The retention implementation lives in `packages/db/src/backup-lib.ts`. > - Monthly pruning is intended to keep the newest backup per retained calendar month. > - The current cutoff uses a fixed 30-day approximation, which deletes January backups too early when the current month has 31 days. > - This pull request switches the monthly cutoff to calendar-month boundaries instead of a fixed day multiplier. > - The benefit is that monthly retention now matches the documented calendar-month behavior and does not prune valid backups prematurely. ## Linked Issues or Issue Description Fixes #3713 Two other pull requests implemented the same fix and have already been closed as duplicates of this one: - #3798 — same author's later take. Anchors the cutoff to the 1st correctly, but mutates the date in local time and leaves `monthKey` on local time, and unit-tests the helper in isolation rather than end to end. - #4031 — decrements the month without anchoring to the 1st, so partial-month drift and a `setMonth` day-overflow edge case remain. No tests. ## What Changed - Replaced the fixed `30 * 24h` monthly retention cutoff with a calendar-month cutoff anchored to the first day of the earliest retained month. - Added a regression test that freezes `Date.now()` at March 31 and proves the newest January backup is retained when `monthlyMonths=2`. - Kept the rest of the pruning behavior unchanged: daily and weekly tiers still use their existing windows and bucket selection rules. ## Verification - `pnpm --filter @paperclipai/db exec vitest run src/backup-lib.test.ts` - `pnpm --filter @paperclipai/db exec tsc --noEmit` - Note: `pnpm --filter @paperclipai/db typecheck` hits an environment-specific `check:migrations` runtime failure on this host (`Cannot find module ./cjs/index.cjs from ` via Bun), so I used plain `tsc --noEmit` to validate the code changes themselves. ## Risks - Low risk. This only changes the monthly retention cutoff calculation. - The pruning buckets are still selected the same way; the fix only widens the retained month window to align with calendar-month semantics. ## Model Used - OpenAI Codex GPT-5 coding agent with terminal tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- <sub>Edited by commitperclip during triage: added the **Linked Issues** section (`Fixes #3713`) and the duplicate-PR search line the PR template requires. The duplicate search was performed by the triage pipeline, which grouped this PR with #3798 and #4031 and selected this one as the canonical fix. Everything else is the author's original description.</sub> --------- Co-authored-by: Andrew Aymeloglu <aaymeloglu@gmail.com>
This commit is contained in:
parent
7e40ed8c43
commit
b083b173ea
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
Loading…
Reference in New Issue