feat(core-shared): grace-purge soft-deleted rows + boot registration

The retention purge job gated its whole body on activeRetention while
every collection declares only postDeletion, and no app ever called
registerRetentionPurgeJobs — retention was dead end to end (audit
findings A2 + A3). The DSR soft delete now stamps a deletedAt tombstone
on postDeletion collections (kept distinct from processingRestrictedAt
so an Art. 18 restriction never feeds the purge), the job grace-purges
tombstoned rows past postDeletion.duration with the declared action,
core-cms injects the tombstone field + Payload task definitions, and
bindAllProduction enqueues the first purge cycle at boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 18:02:43 +02:00
parent d09b3e2cdd
commit 413ac0273c
15 changed files with 755 additions and 54 deletions

View File

@@ -4,6 +4,10 @@ import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
withRetentionTombstone,
buildRetentionPurgeTask,
} from "@repo/core-shared/payload";
import { users } from "@repo/auth/cms";
import { articles } from "@repo/blog/cms";
import { media } from "@repo/media/cms";
@@ -13,9 +17,14 @@ import { header } from "@repo/navigation/cms";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
// Collections declaring custom.retention.postDeletion get the soft-delete
// tombstone field (`deletedAt`) so the DSR soft delete can stamp rows and the
// retention purge job can grace-purge them (audit finding A2).
const collections = [users, articles, pages, media].map(withRetentionTombstone);
export default buildConfig({
editor: lexicalEditor(),
collections: [users, articles, pages, media],
collections,
globals: [siteSettings, header],
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
db: postgresAdapter({
@@ -25,6 +34,14 @@ export default buildConfig({
"postgresql://postgres:postgres@localhost:5433/template",
},
}),
jobs: {
// Task definitions for the retention purge (audit finding A3):
// registerRetentionPurgeJobs (called from bindAllProduction) enqueues
// `retention-purge--<slug>` jobs; these definitions let Payload run them.
tasks: collections
.filter((c) => Boolean(c.custom?.retention?.purgeSchedule))
.map((c) => buildRetentionPurgeTask(c.slug)) as never,
},
typescript: {
outputFile: path.resolve(dirname, "generated-types.ts"),
},