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

@@ -10,11 +10,34 @@ describe("payloadConfig composition", () => {
);
});
it("adds the deletedAt tombstone to postDeletion collections (A2)", async () => {
const resolved = await config;
for (const slug of ["users", "articles", "media"]) {
const collection = resolved.collections?.find((c) => c.slug === slug);
const names =
collection?.fields.map((f) => (f as { name?: string }).name) ?? [];
expect(names, `collection ${slug}`).toContain("deletedAt");
}
});
it("registers a retention purge task per purgeSchedule collection (A3)", async () => {
const resolved = await config;
const taskSlugs =
(
resolved.jobs as { tasks?: Array<{ slug: string }> } | undefined
)?.tasks?.map((t) => t.slug) ?? [];
expect(taskSlugs).toEqual(
expect.arrayContaining([
"retention-purge--users",
"retention-purge--articles",
"retention-purge--media",
]),
);
});
it("registers all feature globals", async () => {
const resolved = await config;
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
expect(slugs).toEqual(
expect.arrayContaining(["site-settings", "header"]),
);
expect(slugs).toEqual(expect.arrayContaining(["site-settings", "header"]));
});
});

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"),
},