From b083b173ea11f18b88626b4b068aa8a77f1f34c0 Mon Sep 17 00:00:00 2001
From: LeonSGP <154585401+LeonSGP43@users.noreply.github.com>
Date: Fri, 24 Jul 2026 11:12:43 -0700
Subject: [PATCH] fix(db): use calendar month retention for backups (#3718)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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
---
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.
---------
Co-authored-by: Andrew Aymeloglu
---
packages/db/src/backup-lib.test.ts | 39 ++++++++++++++++++++++++++++++
packages/db/src/backup-lib.ts | 10 ++++++--
2 files changed, 47 insertions(+), 2 deletions(-)
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[] = [];