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:
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { CollectionConfig } from "payload";
|
||||
import {
|
||||
RETENTION_TOMBSTONE_FIELD,
|
||||
hasPostDeletionPolicy,
|
||||
withRetentionTombstone,
|
||||
} from "@/payload/retention-purge/tombstone";
|
||||
|
||||
function makeCollection(custom?: Record<string, unknown>): CollectionConfig {
|
||||
return {
|
||||
slug: "things",
|
||||
custom,
|
||||
fields: [{ name: "title", type: "text" }],
|
||||
} as CollectionConfig;
|
||||
}
|
||||
|
||||
const postDeletionRetention = {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
action: "hard-delete",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("hasPostDeletionPolicy", () => {
|
||||
it("is true when custom.retention.postDeletion is declared", () => {
|
||||
expect(hasPostDeletionPolicy(makeCollection(postDeletionRetention))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false without retention or postDeletion", () => {
|
||||
expect(hasPostDeletionPolicy(makeCollection())).toBe(false);
|
||||
expect(
|
||||
hasPostDeletionPolicy(
|
||||
makeCollection({ retention: { purgeSchedule: "daily" } }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withRetentionTombstone", () => {
|
||||
it("appends the deletedAt field to postDeletion collections", () => {
|
||||
const result = withRetentionTombstone(
|
||||
makeCollection(postDeletionRetention),
|
||||
);
|
||||
const names = result.fields.map((f) => (f as { name?: string }).name);
|
||||
expect(names).toContain(RETENTION_TOMBSTONE_FIELD);
|
||||
const tombstone = result.fields.find(
|
||||
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||
) as { type?: string; index?: boolean };
|
||||
expect(tombstone.type).toBe("date");
|
||||
expect(tombstone.index).toBe(true);
|
||||
});
|
||||
|
||||
it("returns collections without a postDeletion policy unchanged", () => {
|
||||
const collection = makeCollection();
|
||||
expect(withRetentionTombstone(collection)).toBe(collection);
|
||||
});
|
||||
|
||||
it("does not duplicate an already-declared tombstone field", () => {
|
||||
const collection = {
|
||||
...makeCollection(postDeletionRetention),
|
||||
fields: [{ name: RETENTION_TOMBSTONE_FIELD, type: "date" }],
|
||||
} as CollectionConfig;
|
||||
const result = withRetentionTombstone(collection);
|
||||
expect(
|
||||
result.fields.filter(
|
||||
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not mutate the input collection", () => {
|
||||
const collection = makeCollection(postDeletionRetention);
|
||||
const before = collection.fields.length;
|
||||
withRetentionTombstone(collection);
|
||||
expect(collection.fields.length).toBe(before);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user