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:
@@ -18,6 +18,8 @@
|
||||
"@payloadcms/richtext-lexical": "^3.14.0",
|
||||
"@repo/auth": "workspace:*",
|
||||
"@repo/blog": "workspace:*",
|
||||
"@repo/core-audit": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@repo/marketing-pages": "workspace:*",
|
||||
"@repo/media": "workspace:*",
|
||||
"@repo/navigation": "workspace:*",
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user