diff --git a/coverage/summary.json b/coverage/summary.json index e124fb2..94bce2a 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-18T18:33:27.459Z", - "commit": "fa1a10c", + "generatedAt": "2026-05-18T18:55:54.185Z", + "commit": "d326270", "repo": { - "statements": 96.34, - "branches": 91.41, - "functions": 96.76, - "lines": 96.34, + "statements": 96.43, + "branches": 91.71, + "functions": 96.81, + "lines": 96.43, "counts": { - "lf": 4100, - "lh": 3950, - "brf": 768, - "brh": 702, - "fnf": 247, - "fnh": 239 + "lf": 4205, + "lh": 4055, + "brf": 808, + "brh": 741, + "fnf": 251, + "fnh": 243 } }, "byPackage": { @@ -59,17 +59,17 @@ } }, "@repo/core-shared": { - "statements": 97.75, - "branches": 95.54, - "functions": 91.58, - "lines": 97.75, + "statements": 97.98, + "branches": 95.79, + "functions": 91.92, + "lines": 97.98, "counts": { - "lf": 935, - "lh": 914, - "brf": 269, - "brh": 257, - "fnf": 95, - "fnh": 87 + "lf": 1040, + "lh": 1019, + "brf": 309, + "brh": 296, + "fnf": 99, + "fnh": 91 } }, "@repo/marketing-pages": { diff --git a/packages/core-shared/src/payload/index.ts b/packages/core-shared/src/payload/index.ts index c2190cd..070c6d5 100644 --- a/packages/core-shared/src/payload/index.ts +++ b/packages/core-shared/src/payload/index.ts @@ -14,3 +14,14 @@ export type { } from "./pii-types"; export { PAYLOAD_AUTH_PII_DEFAULTS } from "./pii-types"; export type { PurgeSchedule, CollectionRetention } from "./retention-types"; +export { + parseDurationMs, + scheduleDelayMs, + buildPurgeHandler, + registerRetentionPurgeJobs, +} from "./retention-purge/retention-purge.job"; +export type { + PayloadPurgeApi, + GetPayloadFn, + RetentionPurgeJobDeps, +} from "./retention-purge/retention-purge.job"; diff --git a/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts b/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts new file mode 100644 index 0000000..942a852 --- /dev/null +++ b/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts @@ -0,0 +1,608 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { SanitizedConfig } from "payload"; +import type { IJobQueue } from "@/jobs/job-queue.interface"; +import type { AuditLogProtocol } from "@/di/bind-protocols"; +import { + parseDurationMs, + scheduleDelayMs, + buildPurgeHandler, + registerRetentionPurgeJobs, + type PayloadPurgeApi, + type RetentionPurgeJobDeps, +} from "./retention-purge.job"; + +// ---- test helpers ---- + +type MockCollection = { + slug: string; + custom?: { retention?: Record }; + fields?: Array<{ name?: string; custom?: { pii?: unknown } }>; +}; + +function makeConfig(collections: MockCollection[]): SanitizedConfig { + return { collections } as unknown as SanitizedConfig; +} + +function makeQueue() { + const enqueue = vi.fn().mockResolvedValue({ jobId: "job-1" }); + const queue = { enqueue } as unknown as IJobQueue; + return { queue, enqueue }; +} + +function makePayloadApi( + docs: Array> = [], +): PayloadPurgeApi { + return { + find: vi.fn().mockResolvedValue({ docs }), + update: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + }; +} + +function makeAuditLog(): { + auditLog: AuditLogProtocol; + record: ReturnType; +} { + const record = vi.fn().mockResolvedValue(undefined); + return { auditLog: { record } as AuditLogProtocol, record }; +} + +// ---- parseDurationMs ---- + +describe("parseDurationMs", () => { + it("parses years: P2Y → 2 × 365 days", () => { + expect(parseDurationMs("P2Y")).toBe(2 * 365 * 86_400_000); + }); + + it("parses months: P1M → 30 days", () => { + expect(parseDurationMs("P1M")).toBe(30 * 86_400_000); + }); + + it("parses weeks: P1W → 7 days", () => { + expect(parseDurationMs("P1W")).toBe(7 * 86_400_000); + }); + + it("parses days: P30D → 30 days", () => { + expect(parseDurationMs("P30D")).toBe(30 * 86_400_000); + }); + + it("combines components: P1Y2M3D", () => { + expect(parseDurationMs("P1Y2M3D")).toBe( + 365 * 86_400_000 + 60 * 86_400_000 + 3 * 86_400_000, + ); + }); + + it("returns 0 for P0D", () => { + expect(parseDurationMs("P0D")).toBe(0); + }); + + it("returns 0 for unrecognised strings", () => { + expect(parseDurationMs("invalid")).toBe(0); + expect(parseDurationMs("")).toBe(0); + expect(parseDurationMs("PT2H")).toBe(0); + }); +}); + +// ---- scheduleDelayMs ---- + +describe("scheduleDelayMs", () => { + it("returns 1 day for 'daily'", () => { + expect(scheduleDelayMs("daily")).toBe(86_400_000); + }); + + it("returns 7 days for 'weekly'", () => { + expect(scheduleDelayMs("weekly")).toBe(7 * 86_400_000); + }); + + it("returns 30 days for 'monthly'", () => { + expect(scheduleDelayMs("monthly")).toBe(30 * 86_400_000); + }); + + it("falls back to 1 day for cron-style strings", () => { + expect(scheduleDelayMs("0 3 * * 0")).toBe(86_400_000); + }); +}); + +// ---- registerRetentionPurgeJobs ---- + +describe("registerRetentionPurgeJobs", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("enqueues one job per collection with a purgeSchedule", async () => { + const { queue, enqueue } = makeQueue(); + const config = makeConfig([ + { slug: "users", custom: { retention: { purgeSchedule: "daily" } } }, + { slug: "articles", custom: { retention: { purgeSchedule: "weekly" } } }, + ]); + await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() }); + expect(enqueue).toHaveBeenCalledTimes(2); + }); + + it("skips collections without a retention config", async () => { + const { queue, enqueue } = makeQueue(); + const config = makeConfig([{ slug: "media" }]); + await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() }); + expect(enqueue).not.toHaveBeenCalled(); + }); + + it("uses the correct taskSlug and runAt for each schedule type", async () => { + const { queue, enqueue } = makeQueue(); + const config = makeConfig([ + { slug: "users", custom: { retention: { purgeSchedule: "weekly" } } }, + ]); + await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() }); + expect(enqueue).toHaveBeenCalledWith( + "retention-purge--users", + {}, + { runAt: new Date("2026-01-08T00:00:00.000Z") }, + ); + }); + + it("schedules daily purge 1 day from now", async () => { + const { queue, enqueue } = makeQueue(); + const config = makeConfig([ + { slug: "sessions", custom: { retention: { purgeSchedule: "daily" } } }, + ]); + await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() }); + expect(enqueue).toHaveBeenCalledWith( + "retention-purge--sessions", + {}, + { runAt: new Date("2026-01-02T00:00:00.000Z") }, + ); + }); +}); + +// ---- buildPurgeHandler — input validation ---- + +describe("buildPurgeHandler — input validation", () => { + it("throws when the collection slug is not found in the config", () => { + const { queue } = makeQueue(); + const config = makeConfig([]); + expect(() => + buildPurgeHandler("missing", { queue, config, getPayload: vi.fn() }), + ).toThrow("collection not found: missing"); + }); + + it("throws when the collection has no retention config", () => { + const { queue } = makeQueue(); + const config = makeConfig([{ slug: "media" }]); + expect(() => + buildPurgeHandler("media", { queue, config, getPayload: vi.fn() }), + ).toThrow("no retention config on collection: media"); + }); +}); + +// ---- buildPurgeHandler — hard-delete branch ---- + +describe("buildPurgeHandler — hard-delete", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("queries by createdAt for from-creation trigger", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([]); + const config = makeConfig([ + { + slug: "users", + custom: { + retention: { + purgeSchedule: "daily", + activeRetention: { duration: "P2Y", trigger: "from-creation" }, + postDeletion: { + action: "hard-delete", + duration: "P30D", + trigger: "after-deletion", + }, + }, + }, + fields: [], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("users", deps)(); + + expect(payload.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + createdAt: { + less_than: new Date( + Date.now() - parseDurationMs("P2Y"), + ).toISOString(), + }, + }, + }), + ); + }); + + it("queries by updatedAt for from-last-access trigger", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([]); + const config = makeConfig([ + { + slug: "sessions", + custom: { + retention: { + purgeSchedule: "daily", + activeRetention: { duration: "P30D", trigger: "from-last-access" }, + postDeletion: { + action: "hard-delete", + duration: "P0D", + trigger: "after-deletion", + }, + }, + }, + fields: [], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("sessions", deps)(); + + expect(payload.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { updatedAt: { less_than: expect.any(String) } }, + }), + ); + expect(payload.find).not.toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ createdAt: expect.anything() }), + }), + ); + }); + + it("deletes each returned row", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([{ id: "row-1" }, { id: "row-2" }]); + const config = makeConfig([ + { + slug: "users", + custom: { + retention: { + purgeSchedule: "daily", + activeRetention: { duration: "P1Y", trigger: "from-creation" }, + postDeletion: { + action: "hard-delete", + duration: "P0D", + trigger: "after-deletion", + }, + }, + }, + fields: [], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("users", deps)(); + + expect(payload.delete).toHaveBeenCalledTimes(2); + expect(payload.delete).toHaveBeenCalledWith({ + collection: "users", + id: "row-1", + overrideAccess: true, + }); + expect(payload.delete).toHaveBeenCalledWith({ + collection: "users", + id: "row-2", + overrideAccess: true, + }); + expect(payload.update).not.toHaveBeenCalled(); + }); + + it("re-enqueues itself for the next purge cycle", async () => { + const { queue, enqueue } = makeQueue(); + const payload = makePayloadApi([]); + const config = makeConfig([ + { + slug: "users", + custom: { + retention: { + purgeSchedule: "daily", + activeRetention: { duration: "P1Y", trigger: "from-creation" }, + postDeletion: { + action: "hard-delete", + duration: "P0D", + trigger: "after-deletion", + }, + }, + }, + fields: [], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("users", deps)(); + + expect(enqueue).toHaveBeenCalledOnce(); + expect(enqueue).toHaveBeenCalledWith( + "retention-purge--users", + {}, + { runAt: new Date("2026-01-02T00:00:00.000Z") }, + ); + }); + + it("defaults to hard-delete when postDeletion is not declared", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([{ id: "row-x" }]); + const config = makeConfig([ + { + slug: "logs", + custom: { + retention: { + purgeSchedule: "daily", + activeRetention: { duration: "P1Y", trigger: "from-creation" }, + }, + }, + fields: [], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("logs", deps)(); + + expect(payload.delete).toHaveBeenCalledWith({ + collection: "logs", + id: "row-x", + overrideAccess: true, + }); + expect(payload.update).not.toHaveBeenCalled(); + }); +}); + +// ---- buildPurgeHandler — pseudonymize branch ---- + +describe("buildPurgeHandler — pseudonymize", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("nulls only PII-annotated fields for each matched row", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([{ id: "row-2" }]); + const config = makeConfig([ + { + slug: "contacts", + custom: { + retention: { + purgeSchedule: "monthly", + activeRetention: { duration: "P1Y", trigger: "from-creation" }, + postDeletion: { + action: "pseudonymize", + duration: "P30D", + trigger: "after-deletion", + }, + }, + }, + fields: [ + { name: "email", custom: { pii: { category: "contact-email" } } }, + { name: "phone", custom: { pii: { category: "contact-phone" } } }, + { name: "status" }, // no pii — must NOT be nulled + ], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("contacts", deps)(); + + expect(payload.update).toHaveBeenCalledWith({ + collection: "contacts", + id: "row-2", + data: { email: null, phone: null }, + overrideAccess: true, + }); + expect(payload.delete).not.toHaveBeenCalled(); + }); + + it("skips update when no PII fields are declared on the collection", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([{ id: "row-3" }]); + const config = makeConfig([ + { + slug: "tags", + custom: { + retention: { + purgeSchedule: "daily", + activeRetention: { duration: "P1Y", trigger: "from-creation" }, + postDeletion: { + action: "pseudonymize", + duration: "P0D", + trigger: "after-deletion", + }, + }, + }, + fields: [ + { name: "label" }, // no pii + ], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("tags", deps)(); + + expect(payload.update).not.toHaveBeenCalled(); + expect(payload.delete).not.toHaveBeenCalled(); + }); +}); + +// ---- buildPurgeHandler — audit emission ---- + +describe("buildPurgeHandler — audit emission", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("emits one audit record per processed row", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([{ id: "a" }, { id: "b" }]); + const { auditLog, record } = makeAuditLog(); + const config = makeConfig([ + { + slug: "users", + custom: { + retention: { + purgeSchedule: "daily", + activeRetention: { duration: "P1Y", trigger: "from-creation" }, + postDeletion: { + action: "hard-delete", + duration: "P0D", + trigger: "after-deletion", + }, + }, + }, + fields: [], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + auditLog, + }; + await buildPurgeHandler("users", deps)(); + + expect(record).toHaveBeenCalledTimes(2); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: "system", + actorType: "system", + action: "DELETE", + reason: "retention-policy", + outcome: "success", + from: { ipTruncated: "system", userAgent: "background-job" }, + }), + ); + }); + + it("includes resource type and id in the audit entry", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([{ id: "row-42" }]); + const { auditLog, record } = makeAuditLog(); + const config = makeConfig([ + { + slug: "orders", + custom: { + retention: { + purgeSchedule: "daily", + activeRetention: { duration: "P1Y", trigger: "from-creation" }, + postDeletion: { + action: "hard-delete", + duration: "P0D", + trigger: "after-deletion", + }, + }, + }, + fields: [], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + auditLog, + }; + await buildPurgeHandler("orders", deps)(); + + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + resource: { type: "orders", id: "row-42" }, + }), + ); + }); + + it("gracefully skips audit emission when auditLog is not provided", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([{ id: "x" }]); + const config = makeConfig([ + { + slug: "users", + custom: { + retention: { + purgeSchedule: "daily", + activeRetention: { duration: "P1Y", trigger: "from-creation" }, + postDeletion: { + action: "hard-delete", + duration: "P0D", + trigger: "after-deletion", + }, + }, + }, + fields: [], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + }; + await expect(buildPurgeHandler("users", deps)()).resolves.toBeUndefined(); + }); + + it("skips all processing and audit when activeRetention is not declared", async () => { + const { queue, enqueue } = makeQueue(); + const payload = makePayloadApi([{ id: "y" }]); + const { auditLog, record } = makeAuditLog(); + const config = makeConfig([ + { + slug: "logs", + custom: { retention: { purgeSchedule: "daily" } }, + fields: [], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + auditLog, + }; + await buildPurgeHandler("logs", deps)(); + + expect(payload.find).not.toHaveBeenCalled(); + expect(record).not.toHaveBeenCalled(); + // Still re-enqueues for the next cycle + expect(enqueue).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core-shared/src/payload/retention-purge/retention-purge.job.ts b/packages/core-shared/src/payload/retention-purge/retention-purge.job.ts new file mode 100644 index 0000000..5405fc4 --- /dev/null +++ b/packages/core-shared/src/payload/retention-purge/retention-purge.job.ts @@ -0,0 +1,200 @@ +import type { SanitizedConfig } from "payload"; +import type { IJobQueue } from "../../jobs/job-queue.interface"; +import type { AuditLogProtocol } from "../../di/bind-protocols"; + +/** + * Minimal Payload API surface needed by the retention purge job. + * Injected via getPayload for testability. + */ +export type PayloadPurgeApi = { + find(args: { + collection: string; + where: Record; + limit: number; + overrideAccess: true; + }): Promise<{ docs: Array> }>; + update(args: { + collection: string; + id: string | number; + data: Record; + overrideAccess: true; + }): Promise; + delete(args: { + collection: string; + id: string | number; + overrideAccess: true; + }): Promise; +}; + +export type GetPayloadFn = (args: { + config: SanitizedConfig; +}) => Promise; + +export type RetentionPurgeJobDeps = { + queue: IJobQueue; + config: SanitizedConfig; + getPayload: GetPayloadFn; + auditLog?: AuditLogProtocol; +}; + +const MS_PER_DAY = 86_400_000; + +/** + * Parse a subset of ISO 8601 duration notation (date components: Y, M, W, D) + * to milliseconds. Returns 0 for unrecognised patterns. + * Approximations: 1 year = 365 days, 1 month = 30 days. + */ +export function parseDurationMs(iso: string): number { + const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?$/.exec(iso); + if (!match) return 0; + const years = parseInt(match[1] ?? "0", 10); + const months = parseInt(match[2] ?? "0", 10); + const weeks = parseInt(match[3] ?? "0", 10); + const days = parseInt(match[4] ?? "0", 10); + return ( + years * 365 * MS_PER_DAY + + months * 30 * MS_PER_DAY + + weeks * 7 * MS_PER_DAY + + days * MS_PER_DAY + ); +} + +/** + * Convert a PurgeSchedule value to the delay in ms before the next run. + * Cron-style strings fall back to daily cadence; the Payload scheduler handles + * actual cron-aligned firing. + */ +export function scheduleDelayMs(schedule: string): number { + if (schedule === "weekly") return 7 * MS_PER_DAY; + if (schedule === "monthly") return 30 * MS_PER_DAY; + return MS_PER_DAY; // "daily" and cron fallback +} + +/** + * Build the purge handler for a single collection. The returned async function + * is intended to be registered as a Payload job task handler. + * + * Per run: + * 1. Query rows past their activeRetention period. + * 2. Apply postDeletion.action (pseudonymize | hard-delete). + * 3. Emit one audit entry per processed row (skipped when auditLog is absent). + * 4. Re-enqueue itself for the next purge cycle. + * + * `from-last-access` uses updatedAt as a proxy; a dedicated lastAccessedAt hook + * is deferred to Q2 per the PRD. + */ +export function buildPurgeHandler( + collectionSlug: string, + deps: RetentionPurgeJobDeps, +): () => Promise { + const { queue, config, getPayload, auditLog } = deps; + + const collection = config.collections.find((c) => c.slug === collectionSlug); + if (!collection) { + throw new Error(`retention-purge: collection not found: ${collectionSlug}`); + } + + const retention = collection.custom?.retention; + if (!retention) { + throw new Error( + `retention-purge: no retention config on collection: ${collectionSlug}`, + ); + } + + const taskSlug = `retention-purge--${collectionSlug}`; + + return async () => { + const payload = await getPayload({ config }); + const now = Date.now(); + + if (retention.activeRetention) { + const { duration, trigger } = retention.activeRetention; + const retentionMs = parseDurationMs(duration); + const cutoff = new Date(now - retentionMs).toISOString(); + const dateField = trigger === "from-creation" ? "createdAt" : "updatedAt"; + + const { docs } = await payload.find({ + collection: collectionSlug, + where: { [dateField]: { less_than: cutoff } }, + limit: 1000, + overrideAccess: true, + }); + + const action = retention.postDeletion?.action ?? "hard-delete"; + + for (const doc of docs) { + const id = doc["id"] as string | number; + + if (action === "pseudonymize") { + const piiFields: Record = {}; + for (const field of collection.fields) { + const f = field as { name?: string; custom?: { pii?: unknown } }; + if (f.name && f.custom?.pii) { + piiFields[f.name] = null; + } + } + if (Object.keys(piiFields).length > 0) { + await payload.update({ + collection: collectionSlug, + id, + data: piiFields, + overrideAccess: true, + }); + } + } else { + await payload.delete({ + collection: collectionSlug, + id, + overrideAccess: true, + }); + } + + if (auditLog) { + await auditLog.record({ + actorId: "system", + actorType: "system", + actorRoles: [], + action: "DELETE", + resource: { type: collectionSlug, id: String(id) }, + at: new Date(), + scope: { + feature: "core-shared", + environment: process.env["NODE_ENV"] ?? "production", + tenant: "default", + }, + reason: "retention-policy", + from: { ipTruncated: "system", userAgent: "background-job" }, + containsPii: false, + outcome: "success", + }); + } + } + } + + const delay = scheduleDelayMs(retention.purgeSchedule); + await queue.enqueue(taskSlug, {}, { runAt: new Date(now + delay) }); + }; +} + +/** + * Walk all Payload collections that declare `custom.retention.purgeSchedule` + * and schedule the first purge run for each via the provided IJobQueue. + * + * Call once at app startup (inside bindAll or equivalent). Idempotent per + * queue implementation — duplicate enqueues are the queue's responsibility. + */ +export async function registerRetentionPurgeJobs( + deps: RetentionPurgeJobDeps, +): Promise { + const { queue, config } = deps; + const now = Date.now(); + + for (const collection of config.collections) { + const retention = collection.custom?.retention; + if (!retention?.purgeSchedule) continue; + + const taskSlug = `retention-purge--${collection.slug}`; + const delay = scheduleDelayMs(retention.purgeSchedule); + await queue.enqueue(taskSlug, {}, { runAt: new Date(now + delay) }); + } +}