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:
@@ -4,9 +4,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||||||
// without a pseudonym salt (by design). Provide one for the whole suite.
|
// without a pseudonym salt (by design). Provide one for the whole suite.
|
||||||
process.env.AUDIT_PSEUDONYM_SALT ??= "test-salt-not-for-production";
|
process.env.AUDIT_PSEUDONYM_SALT ??= "test-salt-not-for-production";
|
||||||
|
|
||||||
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
|
// Hoisted so the payload mock and assertions share the same jobs.queue spy.
|
||||||
|
const { jobsQueueMock } = vi.hoisted(() => ({
|
||||||
|
jobsQueueMock: vi.fn(async () => ({ id: "job-1" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@repo/core-cms", () => ({
|
||||||
|
default: Promise.resolve({
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
{ slug: "pages", fields: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
vi.mock("payload", () => ({
|
vi.mock("payload", () => ({
|
||||||
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
|
getPayload: vi.fn(async () => ({ jobs: { queue: jobsQueueMock } })),
|
||||||
}));
|
}));
|
||||||
vi.mock("@repo/blog/di/bind-production", () => ({
|
vi.mock("@repo/blog/di/bind-production", () => ({
|
||||||
bindProductionBlog: vi.fn(),
|
bindProductionBlog: vi.fn(),
|
||||||
@@ -73,6 +98,17 @@ describe("bindAllProduction", () => {
|
|||||||
expect(bindProductionMedia).toHaveBeenCalledOnce();
|
expect(bindProductionMedia).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("registers retention purge jobs at production boot (A3)", async () => {
|
||||||
|
const { bindAllProduction } = await import("./bind-production");
|
||||||
|
await bindAllProduction();
|
||||||
|
|
||||||
|
// one enqueue per collection declaring custom.retention.purgeSchedule
|
||||||
|
expect(jobsQueueMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(jobsQueueMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ task: "retention-purge--users" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("is idempotent via bindAll — second call does not re-bind", async () => {
|
it("is idempotent via bindAll — second call does not re-bind", async () => {
|
||||||
vi.stubEnv("NODE_ENV", "production");
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
const { bindAll } = await import("./bind-production");
|
const { bindAll } = await import("./bind-production");
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
NoopRateLimit,
|
NoopRateLimit,
|
||||||
type RateLimitBudget,
|
type RateLimitBudget,
|
||||||
} from "@repo/core-shared/rate-limit";
|
} from "@repo/core-shared/rate-limit";
|
||||||
|
import {
|
||||||
|
registerRetentionPurgeJobs,
|
||||||
|
type GetPayloadFn,
|
||||||
|
} from "@repo/core-shared/payload";
|
||||||
import { bindAudit, type IAuditLog } from "@repo/core-audit";
|
import { bindAudit, type IAuditLog } from "@repo/core-audit";
|
||||||
import {
|
import {
|
||||||
bindProductionConsent,
|
bindProductionConsent,
|
||||||
@@ -192,6 +196,17 @@ export async function bindAllProduction(): Promise<void> {
|
|||||||
bindProductionMarketingPages(ctx);
|
bindProductionMarketingPages(ctx);
|
||||||
bindProductionNavigation(ctx);
|
bindProductionNavigation(ctx);
|
||||||
bindProductionMedia(ctx);
|
bindProductionMedia(ctx);
|
||||||
|
|
||||||
|
// Kick off the retention purge cycle (audit finding A3): enqueue the first
|
||||||
|
// `retention-purge--<slug>` job for every collection declaring a
|
||||||
|
// custom.retention.purgeSchedule. The task definitions live in the Payload
|
||||||
|
// config (core-cms jobs.tasks); each run re-enqueues the next cycle.
|
||||||
|
await registerRetentionPurgeJobs({
|
||||||
|
queue,
|
||||||
|
config: resolvedConfig,
|
||||||
|
getPayload: getPayload as unknown as GetPayloadFn,
|
||||||
|
auditLog,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"@payloadcms/richtext-lexical": "^3.14.0",
|
"@payloadcms/richtext-lexical": "^3.14.0",
|
||||||
"@repo/auth": "workspace:*",
|
"@repo/auth": "workspace:*",
|
||||||
"@repo/blog": "workspace:*",
|
"@repo/blog": "workspace:*",
|
||||||
|
"@repo/core-audit": "workspace:*",
|
||||||
|
"@repo/core-shared": "workspace:*",
|
||||||
"@repo/marketing-pages": "workspace:*",
|
"@repo/marketing-pages": "workspace:*",
|
||||||
"@repo/media": "workspace:*",
|
"@repo/media": "workspace:*",
|
||||||
"@repo/navigation": "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 () => {
|
it("registers all feature globals", async () => {
|
||||||
const resolved = await config;
|
const resolved = await config;
|
||||||
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
||||||
expect(slugs).toEqual(
|
expect(slugs).toEqual(expect.arrayContaining(["site-settings", "header"]));
|
||||||
expect.arrayContaining(["site-settings", "header"]),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
withRetentionTombstone,
|
||||||
|
buildRetentionPurgeTask,
|
||||||
|
} from "@repo/core-shared/payload";
|
||||||
import { users } from "@repo/auth/cms";
|
import { users } from "@repo/auth/cms";
|
||||||
import { articles } from "@repo/blog/cms";
|
import { articles } from "@repo/blog/cms";
|
||||||
import { media } from "@repo/media/cms";
|
import { media } from "@repo/media/cms";
|
||||||
@@ -13,9 +17,14 @@ import { header } from "@repo/navigation/cms";
|
|||||||
const filename = fileURLToPath(import.meta.url);
|
const filename = fileURLToPath(import.meta.url);
|
||||||
const dirname = path.dirname(filename);
|
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({
|
export default buildConfig({
|
||||||
editor: lexicalEditor(),
|
editor: lexicalEditor(),
|
||||||
collections: [users, articles, pages, media],
|
collections,
|
||||||
globals: [siteSettings, header],
|
globals: [siteSettings, header],
|
||||||
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
||||||
db: postgresAdapter({
|
db: postgresAdapter({
|
||||||
@@ -25,6 +34,14 @@ export default buildConfig({
|
|||||||
"postgresql://postgres:postgres@localhost:5433/template",
|
"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: {
|
typescript: {
|
||||||
outputFile: path.resolve(dirname, "generated-types.ts"),
|
outputFile: path.resolve(dirname, "generated-types.ts"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,6 +97,101 @@ describe("PayloadDataDelete", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stamps the deletedAt tombstone when the collection declares postDeletion retention (A2)", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [{ id: "alice", email: "a@ex.com" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||||
|
await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
deletedAt: expect.any(String),
|
||||||
|
processingRestrictedAt: expect.any(String),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT stamp deletedAt without a postDeletion retention policy", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [{ id: "alice", email: "a@ex.com" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||||
|
await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(updateData).not.toHaveProperty("deletedAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stamps deletedAt on owner rows of postDeletion collections", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "orders",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "userId", kind: "owner" },
|
||||||
|
pii: { shippingAddress: { exportable: true } },
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P90D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [{ id: "o1", userId: "alice", shippingAddress: "X" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||||
|
await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(updateData["deletedAt"]).toEqual(expect.any(String));
|
||||||
|
expect(updateData).not.toHaveProperty("processingRestrictedAt");
|
||||||
|
});
|
||||||
|
|
||||||
it("owner role: does NOT set processingRestrictedAt", async () => {
|
it("owner role: does NOT set processingRestrictedAt", async () => {
|
||||||
const config = makeMockConfig([
|
const config = makeMockConfig([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { SanitizedConfig } from "payload";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
||||||
|
import {
|
||||||
|
RETENTION_TOMBSTONE_FIELD,
|
||||||
|
hasPostDeletionPolicy,
|
||||||
|
} from "@repo/core-shared/payload";
|
||||||
import type { IDataDelete } from "./data-delete.interface";
|
import type { IDataDelete } from "./data-delete.interface";
|
||||||
import type {
|
import type {
|
||||||
DeletionMode,
|
DeletionMode,
|
||||||
@@ -142,6 +146,7 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
subjectId,
|
subjectId,
|
||||||
correlationId,
|
correlationId,
|
||||||
affected,
|
affected,
|
||||||
|
hasPostDeletionPolicy(collection),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.processReferenceRows(
|
await this.processReferenceRows(
|
||||||
@@ -175,6 +180,7 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
subjectId: string,
|
subjectId: string,
|
||||||
correlationId: string,
|
correlationId: string,
|
||||||
affected: DeletionAffected[],
|
affected: DeletionAffected[],
|
||||||
|
postDeletionPolicy: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const piiMeta = custom.pii ?? {};
|
const piiMeta = custom.pii ?? {};
|
||||||
const exportableFields = Object.entries(piiMeta)
|
const exportableFields = Object.entries(piiMeta)
|
||||||
@@ -183,10 +189,17 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
|
|
||||||
if (mode === "soft") {
|
if (mode === "soft") {
|
||||||
const kind = custom.subject?.kind;
|
const kind = custom.subject?.kind;
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
const extraData: Record<string, unknown> =
|
const extraData: Record<string, unknown> =
|
||||||
kind === "self"
|
kind === "self" ? { processingRestrictedAt: nowIso } : {};
|
||||||
? { processingRestrictedAt: new Date().toISOString() }
|
if (postDeletionPolicy) {
|
||||||
: {};
|
// Soft-delete tombstone (A2): collections with a
|
||||||
|
// custom.retention.postDeletion policy get stamped so the retention
|
||||||
|
// purge job can hard-delete/pseudonymize them once the grace period
|
||||||
|
// elapses. Kept separate from processingRestrictedAt — an Art. 18
|
||||||
|
// restriction alone must never trigger the purge.
|
||||||
|
extraData[RETENTION_TOMBSTONE_FIELD] = nowIso;
|
||||||
|
}
|
||||||
await softRedactOwnerRows(
|
await softRedactOwnerRows(
|
||||||
payload,
|
payload,
|
||||||
slug,
|
slug,
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ export {
|
|||||||
buildPurgeHandler,
|
buildPurgeHandler,
|
||||||
registerRetentionPurgeJobs,
|
registerRetentionPurgeJobs,
|
||||||
} from "./retention-purge/retention-purge.job";
|
} from "./retention-purge/retention-purge.job";
|
||||||
|
export {
|
||||||
|
RETENTION_TOMBSTONE_FIELD,
|
||||||
|
hasPostDeletionPolicy,
|
||||||
|
withRetentionTombstone,
|
||||||
|
} from "./retention-purge/tombstone";
|
||||||
|
export {
|
||||||
|
buildRetentionPurgeTask,
|
||||||
|
type RetentionPurgeTask,
|
||||||
|
} from "./retention-purge/task";
|
||||||
export type {
|
export type {
|
||||||
PayloadPurgeApi,
|
PayloadPurgeApi,
|
||||||
GetPayloadFn,
|
GetPayloadFn,
|
||||||
|
|||||||
@@ -377,6 +377,189 @@ describe("buildPurgeHandler — hard-delete", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- buildPurgeHandler — postDeletion grace purge (A2) ----
|
||||||
|
|
||||||
|
describe("buildPurgeHandler — postDeletion grace purge", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function postDeletionOnlyConfig(
|
||||||
|
action: "hard-delete" | "pseudonymize",
|
||||||
|
fields: MockCollection["fields"] = [],
|
||||||
|
) {
|
||||||
|
return makeConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
action,
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("queries soft-deleted rows by the deletedAt tombstone cutoff", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(payload.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: "users",
|
||||||
|
where: {
|
||||||
|
deletedAt: {
|
||||||
|
less_than: new Date(
|
||||||
|
Date.now() - parseDurationMs("P30D"),
|
||||||
|
).toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard-deletes rows whose tombstone is past the grace period (postDeletion-only collection)", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-11-01T00:00:00.000Z" }, // 61 days
|
||||||
|
{ id: "row-fresh", deletedAt: "2025-12-25T00:00:00.000Z" }, // 7 days
|
||||||
|
{ id: "row-live" }, // never soft-deleted
|
||||||
|
]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(payload.delete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(payload.delete).toHaveBeenCalledWith({
|
||||||
|
collection: "users",
|
||||||
|
id: "row-old",
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pseudonymizes PII fields when postDeletion.action is pseudonymize", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("pseudonymize", [
|
||||||
|
{ name: "email", custom: { pii: { category: "contact-email" } } },
|
||||||
|
{ name: "status" },
|
||||||
|
]),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(payload.update).toHaveBeenCalledWith({
|
||||||
|
collection: "users",
|
||||||
|
id: "row-old",
|
||||||
|
data: { email: null },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
expect(payload.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a retention-policy audit entry per purged row", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const { auditLog, record } = makeAuditLog();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
auditLog,
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(record).toHaveBeenCalledTimes(1);
|
||||||
|
expect(record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "DELETE",
|
||||||
|
reason: "retention-policy",
|
||||||
|
resource: { type: "users", id: "row-old" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still runs activeRetention alongside postDeletion", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
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)();
|
||||||
|
|
||||||
|
// one find per branch: createdAt (activeRetention) + deletedAt (postDeletion)
|
||||||
|
expect(payload.find).toHaveBeenCalledTimes(2);
|
||||||
|
// the fake returns the tombstoned row for both branches: the
|
||||||
|
// activeRetention branch deletes it by date, the postDeletion branch by
|
||||||
|
// tombstone — 2 delete calls for the same doc through different policies.
|
||||||
|
expect(payload.delete).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-enqueues the next cycle for postDeletion-only collections", async () => {
|
||||||
|
const { queue, enqueue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(enqueue).toHaveBeenCalledWith(
|
||||||
|
"retention-purge--users",
|
||||||
|
{},
|
||||||
|
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---- buildPurgeHandler — pseudonymize branch ----
|
// ---- buildPurgeHandler — pseudonymize branch ----
|
||||||
|
|
||||||
describe("buildPurgeHandler — pseudonymize", () => {
|
describe("buildPurgeHandler — pseudonymize", () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import type { IJobQueue } from "../../jobs/job-queue.interface";
|
import type { IJobQueue } from "../../jobs/job-queue.interface";
|
||||||
import type { AuditLogProtocol } from "../../di/bind-protocols";
|
import type { AuditLogProtocol } from "../../di/bind-protocols";
|
||||||
|
import { RETENTION_TOMBSTONE_FIELD } from "./tombstone";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal Payload API surface needed by the retention purge job.
|
* Minimal Payload API surface needed by the retention purge job.
|
||||||
@@ -70,13 +71,19 @@ export function scheduleDelayMs(schedule: string): number {
|
|||||||
return MS_PER_DAY; // "daily" and cron fallback
|
return MS_PER_DAY; // "daily" and cron fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RetentionActionName = "pseudonymize" | "hard-delete";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the purge handler for a single collection. The returned async function
|
* Build the purge handler for a single collection. The returned async function
|
||||||
* is intended to be registered as a Payload job task handler.
|
* is intended to be registered as a Payload job task handler.
|
||||||
*
|
*
|
||||||
* Per run:
|
* Per run:
|
||||||
* 1. Query rows past their activeRetention period.
|
* 1. activeRetention (when declared): query rows past the active period
|
||||||
* 2. Apply postDeletion.action (pseudonymize | hard-delete).
|
* (createdAt/updatedAt) and apply the retention action.
|
||||||
|
* 2. postDeletion (when declared, audit finding A2): query soft-deleted rows
|
||||||
|
* — tombstoned with `deletedAt` by the DSR soft-delete path — whose
|
||||||
|
* tombstone is older than postDeletion.duration, and apply
|
||||||
|
* postDeletion.action (pseudonymize | hard-delete).
|
||||||
* 3. Emit one audit entry per processed row (skipped when auditLog is absent).
|
* 3. Emit one audit entry per processed row (skipped when auditLog is absent).
|
||||||
* 4. Re-enqueue itself for the next purge cycle.
|
* 4. Re-enqueue itself for the next purge cycle.
|
||||||
*
|
*
|
||||||
@@ -103,26 +110,12 @@ export function buildPurgeHandler(
|
|||||||
|
|
||||||
const taskSlug = `retention-purge--${collectionSlug}`;
|
const taskSlug = `retention-purge--${collectionSlug}`;
|
||||||
|
|
||||||
return async () => {
|
async function applyAction(
|
||||||
const payload = await getPayload({ config });
|
payload: PayloadPurgeApi,
|
||||||
const now = Date.now();
|
doc: Record<string, unknown>,
|
||||||
|
action: RetentionActionName,
|
||||||
if (retention.activeRetention) {
|
reason: string,
|
||||||
const { duration, trigger } = retention.activeRetention;
|
): Promise<void> {
|
||||||
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;
|
const id = doc["id"] as string | number;
|
||||||
|
|
||||||
if (action === "pseudonymize") {
|
if (action === "pseudonymize") {
|
||||||
@@ -162,13 +155,66 @@ export function buildPurgeHandler(
|
|||||||
environment: process.env["NODE_ENV"] ?? "production",
|
environment: process.env["NODE_ENV"] ?? "production",
|
||||||
tenant: "default",
|
tenant: "default",
|
||||||
},
|
},
|
||||||
reason: "retention-policy",
|
reason,
|
||||||
from: { ipTruncated: "system", userAgent: "background-job" },
|
from: { ipTruncated: "system", userAgent: "background-job" },
|
||||||
containsPii: false,
|
containsPii: false,
|
||||||
outcome: "success",
|
outcome: "success",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
await applyAction(payload, doc, action, "retention-policy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retention.postDeletion) {
|
||||||
|
// Grace-period purge of soft-deleted rows (A2): the DSR soft delete
|
||||||
|
// stamps RETENTION_TOMBSTONE_FIELD; once the grace period elapses the
|
||||||
|
// declared action runs. Rows are re-checked client-side so a fake or
|
||||||
|
// permissive backend can never purge an un-tombstoned/unexpired row.
|
||||||
|
const { duration, action } = retention.postDeletion;
|
||||||
|
const cutoffMs = now - parseDurationMs(duration);
|
||||||
|
const cutoff = new Date(cutoffMs).toISOString();
|
||||||
|
|
||||||
|
const { docs } = await payload.find({
|
||||||
|
collection: collectionSlug,
|
||||||
|
where: { [RETENTION_TOMBSTONE_FIELD]: { less_than: cutoff } },
|
||||||
|
limit: 1000,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expired = docs.filter((doc) => {
|
||||||
|
const tombstone = doc[RETENTION_TOMBSTONE_FIELD];
|
||||||
|
if (typeof tombstone !== "string" || tombstone.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const tombstoneMs = Date.parse(tombstone);
|
||||||
|
return Number.isFinite(tombstoneMs) && tombstoneMs < cutoffMs;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const doc of expired) {
|
||||||
|
await applyAction(payload, doc, action, "retention-policy");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const delay = scheduleDelayMs(retention.purgeSchedule);
|
const delay = scheduleDelayMs(retention.purgeSchedule);
|
||||||
@@ -189,7 +235,7 @@ export async function registerRetentionPurgeJobs(
|
|||||||
const { queue, config } = deps;
|
const { queue, config } = deps;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
for (const collection of config.collections) {
|
for (const collection of config.collections ?? []) {
|
||||||
const retention = collection.custom?.retention;
|
const retention = collection.custom?.retention;
|
||||||
if (!retention?.purgeSchedule) continue;
|
if (!retention?.purgeSchedule) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import { buildRetentionPurgeTask } from "@/payload/retention-purge/task";
|
||||||
|
|
||||||
|
function makeFakePayload() {
|
||||||
|
const find = vi.fn().mockResolvedValue({ docs: [] });
|
||||||
|
const jobsQueue = vi.fn().mockResolvedValue({ id: "job-1" });
|
||||||
|
const payload = {
|
||||||
|
config: {
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
find,
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
jobs: { queue: jobsQueue },
|
||||||
|
} as unknown as Payload;
|
||||||
|
return { payload, find, jobsQueue };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildRetentionPurgeTask (A3)", () => {
|
||||||
|
it("uses the retention-purge--<slug> task slug", () => {
|
||||||
|
expect(buildRetentionPurgeTask("users").slug).toBe(
|
||||||
|
"retention-purge--users",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the purge against req.payload and re-enqueues the next cycle", async () => {
|
||||||
|
const { payload, find, jobsQueue } = makeFakePayload();
|
||||||
|
const task = buildRetentionPurgeTask("users");
|
||||||
|
|
||||||
|
const result = await task.handler({ req: { payload } });
|
||||||
|
|
||||||
|
expect(result).toEqual({ output: {} });
|
||||||
|
// postDeletion branch queried the tombstone field
|
||||||
|
expect(find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: "users",
|
||||||
|
where: { deletedAt: expect.anything() },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// self-re-enqueue went through the payload job queue
|
||||||
|
expect(jobsQueue).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ task: "retention-purge--users" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
packages/core-shared/src/payload/retention-purge/task.ts
Normal file
42
packages/core-shared/src/payload/retention-purge/task.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Payload } from "payload";
|
||||||
|
import { PayloadJobQueue } from "../../jobs/payload-job-queue";
|
||||||
|
import { buildPurgeHandler, type PayloadPurgeApi } from "./retention-purge.job";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal shape of a Payload job-task definition — enough for
|
||||||
|
* `payload.config.ts` `jobs.tasks` composition without dragging the full
|
||||||
|
* generated TaskConfig generics through core-shared.
|
||||||
|
*/
|
||||||
|
export type RetentionPurgeTask = {
|
||||||
|
slug: string;
|
||||||
|
handler: (args: { req: { payload: Payload } }) => Promise<{
|
||||||
|
output: Record<string, never>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Payload job-task definition for one collection's retention purge
|
||||||
|
* (audit finding A3): `registerRetentionPurgeJobs` enqueues
|
||||||
|
* `retention-purge--<slug>` tasks at boot, and this definition is what makes
|
||||||
|
* Payload able to RUN them. Everything the handler needs comes from the
|
||||||
|
* running instance on `req.payload` (config, local API, job queue for the
|
||||||
|
* self-re-enqueue), so the task can be declared at config-composition time
|
||||||
|
* with no bootstrapping order problems.
|
||||||
|
*/
|
||||||
|
export function buildRetentionPurgeTask(
|
||||||
|
collectionSlug: string,
|
||||||
|
): RetentionPurgeTask {
|
||||||
|
return {
|
||||||
|
slug: `retention-purge--${collectionSlug}`,
|
||||||
|
handler: async ({ req }) => {
|
||||||
|
const payload = req.payload;
|
||||||
|
const run = buildPurgeHandler(collectionSlug, {
|
||||||
|
queue: new PayloadJobQueue(payload),
|
||||||
|
config: payload.config,
|
||||||
|
getPayload: async () => payload as unknown as PayloadPurgeApi,
|
||||||
|
});
|
||||||
|
await run();
|
||||||
|
return { output: {} };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { CollectionConfig, Field } from "payload";
|
||||||
|
import type { CollectionRetention } from "../retention-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field name marking a row as soft-deleted.
|
||||||
|
*
|
||||||
|
* Written by the DSR soft-delete path (`PayloadDataDelete`, mode "soft") on
|
||||||
|
* rows the subject owns, and read by the retention purge job's
|
||||||
|
* `postDeletion` branch: rows whose tombstone is older than
|
||||||
|
* `postDeletion.duration` are purged with the declared action
|
||||||
|
* (hard-delete | pseudonymize).
|
||||||
|
*
|
||||||
|
* Deliberately distinct from `processingRestrictedAt` — an Art. 18
|
||||||
|
* processing restriction is NOT a deletion request and must never feed the
|
||||||
|
* grace-period purge.
|
||||||
|
*/
|
||||||
|
export const RETENTION_TOMBSTONE_FIELD = "deletedAt";
|
||||||
|
|
||||||
|
function collectionRetention(
|
||||||
|
collection: CollectionConfig,
|
||||||
|
): CollectionRetention | undefined {
|
||||||
|
return (collection.custom as { retention?: CollectionRetention } | undefined)
|
||||||
|
?.retention;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the collection declares a postDeletion grace-purge policy. */
|
||||||
|
export function hasPostDeletionPolicy(collection: {
|
||||||
|
custom?: Record<string, unknown>;
|
||||||
|
}): boolean {
|
||||||
|
return Boolean(
|
||||||
|
(collection.custom as { retention?: CollectionRetention } | undefined)
|
||||||
|
?.retention?.postDeletion,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a collection that declares `custom.retention.postDeletion` carries
|
||||||
|
* the soft-delete tombstone field so (a) the DSR soft delete can stamp it and
|
||||||
|
* (b) the purge job can query it. Collections without a postDeletion policy
|
||||||
|
* (or that already define the field) are returned unchanged.
|
||||||
|
*
|
||||||
|
* Apply at config-composition time (core-cms payload.config.ts).
|
||||||
|
*/
|
||||||
|
export function withRetentionTombstone(
|
||||||
|
collection: CollectionConfig,
|
||||||
|
): CollectionConfig {
|
||||||
|
if (!collectionRetention(collection)?.postDeletion) return collection;
|
||||||
|
|
||||||
|
const alreadyDeclared = collection.fields.some(
|
||||||
|
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||||
|
);
|
||||||
|
if (alreadyDeclared) return collection;
|
||||||
|
|
||||||
|
const tombstoneField: Field = {
|
||||||
|
name: RETENTION_TOMBSTONE_FIELD,
|
||||||
|
type: "date",
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
hidden: true,
|
||||||
|
description:
|
||||||
|
"Soft-delete tombstone (DSR Art. 17). Rows older than the " +
|
||||||
|
"postDeletion grace period are purged by the retention job.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...collection,
|
||||||
|
fields: [...collection.fields, tombstoneField],
|
||||||
|
};
|
||||||
|
}
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -627,6 +627,12 @@ importers:
|
|||||||
"@repo/blog":
|
"@repo/blog":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../blog
|
version: link:../blog
|
||||||
|
"@repo/core-audit":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core-audit
|
||||||
|
"@repo/core-shared":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core-shared
|
||||||
"@repo/marketing-pages":
|
"@repo/marketing-pages":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../marketing-pages
|
version: link:../marketing-pages
|
||||||
|
|||||||
Reference in New Issue
Block a user