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:
@@ -25,6 +25,15 @@ export {
|
||||
buildPurgeHandler,
|
||||
registerRetentionPurgeJobs,
|
||||
} 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 {
|
||||
PayloadPurgeApi,
|
||||
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 ----
|
||||
|
||||
describe("buildPurgeHandler — pseudonymize", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { IJobQueue } from "../../jobs/job-queue.interface";
|
||||
import type { AuditLogProtocol } from "../../di/bind-protocols";
|
||||
import { RETENTION_TOMBSTONE_FIELD } from "./tombstone";
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
type RetentionActionName = "pseudonymize" | "hard-delete";
|
||||
|
||||
/**
|
||||
* Build the purge handler for a single collection. The returned async function
|
||||
* is intended to be registered as a Payload job task handler.
|
||||
*
|
||||
* Per run:
|
||||
* 1. Query rows past their activeRetention period.
|
||||
* 2. Apply postDeletion.action (pseudonymize | hard-delete).
|
||||
* 1. activeRetention (when declared): query rows past the active period
|
||||
* (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).
|
||||
* 4. Re-enqueue itself for the next purge cycle.
|
||||
*
|
||||
@@ -103,6 +110,59 @@ export function buildPurgeHandler(
|
||||
|
||||
const taskSlug = `retention-purge--${collectionSlug}`;
|
||||
|
||||
async function applyAction(
|
||||
payload: PayloadPurgeApi,
|
||||
doc: Record<string, unknown>,
|
||||
action: RetentionActionName,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const id = doc["id"] as string | number;
|
||||
|
||||
if (action === "pseudonymize") {
|
||||
const piiFields: Record<string, null> = {};
|
||||
for (const field of collection.fields) {
|
||||
const f = field as { name?: string; custom?: { pii?: unknown } };
|
||||
if (f.name && f.custom?.pii) {
|
||||
piiFields[f.name] = null;
|
||||
}
|
||||
}
|
||||
if (Object.keys(piiFields).length > 0) {
|
||||
await payload.update({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
data: piiFields,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await payload.delete({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
await auditLog.record({
|
||||
actorId: "system",
|
||||
actorType: "system",
|
||||
actorRoles: [],
|
||||
action: "DELETE",
|
||||
resource: { type: collectionSlug, id: String(id) },
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "core-shared",
|
||||
environment: process.env["NODE_ENV"] ?? "production",
|
||||
tenant: "default",
|
||||
},
|
||||
reason,
|
||||
from: { ipTruncated: "system", userAgent: "background-job" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return async () => {
|
||||
const payload = await getPayload({ config });
|
||||
const now = Date.now();
|
||||
@@ -123,51 +183,37 @@ export function buildPurgeHandler(
|
||||
const action = retention.postDeletion?.action ?? "hard-delete";
|
||||
|
||||
for (const doc of docs) {
|
||||
const id = doc["id"] as string | number;
|
||||
await applyAction(payload, doc, action, "retention-policy");
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "pseudonymize") {
|
||||
const piiFields: Record<string, null> = {};
|
||||
for (const field of collection.fields) {
|
||||
const f = field as { name?: string; custom?: { pii?: unknown } };
|
||||
if (f.name && f.custom?.pii) {
|
||||
piiFields[f.name] = null;
|
||||
}
|
||||
}
|
||||
if (Object.keys(piiFields).length > 0) {
|
||||
await payload.update({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
data: piiFields,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await payload.delete({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
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();
|
||||
|
||||
if (auditLog) {
|
||||
await auditLog.record({
|
||||
actorId: "system",
|
||||
actorType: "system",
|
||||
actorRoles: [],
|
||||
action: "DELETE",
|
||||
resource: { type: collectionSlug, id: String(id) },
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "core-shared",
|
||||
environment: process.env["NODE_ENV"] ?? "production",
|
||||
tenant: "default",
|
||||
},
|
||||
reason: "retention-policy",
|
||||
from: { ipTruncated: "system", userAgent: "background-job" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
});
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +235,7 @@ export async function registerRetentionPurgeJobs(
|
||||
const { queue, config } = deps;
|
||||
const now = Date.now();
|
||||
|
||||
for (const collection of config.collections) {
|
||||
for (const collection of config.collections ?? []) {
|
||||
const retention = collection.custom?.retention;
|
||||
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],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user