feat(documents): daily purge for tombstoned documents past the undo grace

Delete in the vault is undo-able: DELETE tombstones, the restore endpoint
clears the tombstone, and this pg-boss job (daily 04:10 Europe/Berlin,
single-flight like every other cleanup) hard-deletes tombstones older than
30 days — returning the TOAST space the encrypted blob holds. Until then a
tombstoned row keeps counting against the quota, so deleted bytes are
never invisible backup weight. Staged facts cascade via the FK;
restore-after-purge answers 409 at the API.

Task: W1-T12.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 11:06:04 +02:00
1 parent 972c5fbf48
commit de9e528614
2 files changed
+80

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
/**
* Daily physical purge for soft-deleted document-vault rows.
*
* Delete in the vault is undo-able: the DELETE route tombstones
* (`deletedAt`), the UI's undo toast and the restore endpoint clear the
* tombstone, and THIS job hard-deletes tombstones older than the 30-day
* grace window — returning the TOAST space the encrypted blob holds (a
* tombstoned document keeps counting against the quota until purged, so
* "deleted" bytes are never invisible backup weight). Staged facts cascade
* via the FK. Restore-after-purge answers 409 at the API.
*/
import { type Job } from "pg-boss";
import { withBackgroundEvent } from "@/lib/logging/background";
import { getWorkerPrisma } from "@/lib/jobs/reminder/shared";
import type { PrismaClient } from "@/generated/prisma/client";
export const DOCUMENT_PURGE_QUEUE = "document-tombstone-purge";
/** Daily at 04:10 Europe/Berlin — after the med-notes backfill tick (04:05). */
export const DOCUMENT_PURGE_CRON = "10 4 * * *";
/** Days a tombstoned document stays restorable before the purge claims it. */
export const DOCUMENT_PURGE_GRACE_DAYS = 30;
const DAY_MS = 86_400_000;
export interface DocumentPurgePayload {
triggeredAt?: string;
}
/**
* Hard-delete tombstoned `inbound_documents` rows whose `deletedAt` is older
* than the grace horizon. Returns the number of rows purged. Rows inside the
* grace window and live rows are never touched.
*/
export async function purgeExpiredDocumentTombstones(
prisma: PrismaClient,
now: Date = new Date(),
): Promise<number> {
const cutoff = new Date(now.getTime() - DOCUMENT_PURGE_GRACE_DAYS * DAY_MS);
const { count } = await prisma.inboundDocument.deleteMany({
where: { deletedAt: { not: null, lt: cutoff } },
});
return count;
}
export async function handleDocumentPurge(jobs: Job<DocumentPurgePayload>[]) {
void jobs;
await withBackgroundEvent("job.document_tombstone_purge", async (evt) => {
const prisma = getWorkerPrisma();
try {
const purged = await purgeExpiredDocumentTombstones(prisma);
evt.setAction({ name: "documents.vault.purge" });
evt.addMeta("document_purge_deleted", purged);
} catch (err) {
evt.addWarning(`document-tombstone-purge failed: ${err}`);
}
});
}
@@ -124,6 +124,12 @@ import {
handleMedicationInventoryExpire,
handleIntakeAutoSkip,
} from "./medication-maintenance";
import {
DOCUMENT_PURGE_QUEUE,
DOCUMENT_PURGE_CRON,
handleDocumentPurge,
type DocumentPurgePayload,
} from "@/lib/jobs/document-purge";
const DATA_BACKUP_QUEUE = "data-backup";
@@ -287,6 +293,11 @@ const allQueues = [
// plaintext medication note; without this entry pg-boss never provisions the
// queue and both the boot enqueue and the cron silently no-op.
MED_NOTES_ENCRYPTION_BACKFILL_QUEUE,
// Document vault — daily physical purge for tombstones past the 30-day
// undo grace (returns the encrypted blob's TOAST space). Without this entry
// the daily schedule silently no-ops and "deleted" documents hold backup
// weight forever.
DOCUMENT_PURGE_QUEUE,
];
const schedules: ScheduleEntry[] = [
@@ -352,6 +363,9 @@ const schedules: ScheduleEntry[] = [
// fan out one per-user job per account still holding a plaintext medication
// note.
[MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, MED_NOTES_ENCRYPTION_BACKFILL_CRON],
// Document vault — daily 04:10 Europe/Berlin purge for tombstoned
// documents past the 30-day undo grace.
[DOCUMENT_PURGE_QUEUE, DOCUMENT_PURGE_CRON],
];
/**
@@ -448,6 +462,14 @@ export async function registerMaintenanceQueues(
{ localConcurrency: 1 },
handleMcpTokenCleanup,
);
// Document vault — daily purge of tombstoned documents past the 30-day
// undo grace. Single-flight like every other cleanup queue; the underlying
// deleteMany is idempotent so a duplicate tick is a no-op.
await boss.work<DocumentPurgePayload>(
DOCUMENT_PURGE_QUEUE,
{ localConcurrency: 1 },
handleDocumentPurge,
);
await boss.work<PrDetectionPayload>(
PR_DETECTION_QUEUE,
{ localConcurrency: PR_DETECTION_CONCURRENCY },