Retention runner
One erasure request, every store, one audit row, even when a target fails.
What it is
Retention runner is Caisson's CCPA/GDPR right-to-erasure module: `runErasure` fans one subject's erasure out across every registered store (object storage, cascade DB, orphan sweep), isolates each target's failure so one broken store never blocks the others, and writes exactly one reason-tagged audit row per run.
What ships in the module
Browser-safe entry point
Import @caisson/retention-runner/browser inside a client bundle for the request contract, the ErasureTarget port with all three reference drivers, the audit-sink port with its in-memory driver, and runErasure itself. The scheduling half stays on the main entry, which keeps the complete node-capable surface, and every browser-entry export is also on it.
Three reference erasure targets
createObjectStorageTarget, createCascadeDbTarget, and createOrphanSweepTarget each take an injected minimal client (purge / cascadeDelete / sweep), the real S3 or Postgres client is a documented seam, never a package dependency. No aws-sdk or pg import ships in retention-runner itself.
Per-target error isolation
eraseOne catches every target's throw into a TargetResult ({ target, ok, error? }) instead of letting it propagate. runErasure runs all targets and always returns a full result set, a failing object-storage purge doesn't stop the cascade DB delete from running.
One reason-tagged audit row per run
erasureReasonSchema is a closed Zod enum, auto_90d, ccpa_request, or operator_manual; an unrecognized reason fails parseStrict before any target runs. The row lands in retention_audit (migration 0001), and migration 0002 adds FORCE ROW LEVEL SECURITY scoped to app.current_account so one tenant's erasure history can't leak into another's query.
Recurring auto_90d sweep on @caisson/jobs
defineRetentionTask returns a TaskDefinition for @caisson/jobs; enqueueAutoSweep enqueues it under a singletonKey of `${tenantId}:${subjectId}` so a long-running erasure can't double-run for the same subject while distinct subjects still sweep in parallel.
Deterministic, testable runs
runErasure takes an injected now: () => number clock (defaults to Date.now) instead of calling the real clock inline, every test in run-erasure.test.ts pins a fixed timestamp and asserts the exact audit row written.
export async function runErasure(
request: ErasureRequest,
targets: ErasureTarget[],
sink: RetentionAuditSink,
now: () => number = Date.now,
): Promise<RetentionRunResult> {
const { subjectId, tenantId, reason } = parseStrict(
erasureRequestSchema,
request,
);
const results = await Promise.all(
targets.map((target) => eraseOne(target, subjectId, tenantId)),
);
const row: RetentionRunResult = {
subjectId,
tenantId,
reason,
results,
at: now(),
};
await sink.record(row);
return row;
}- parseStrict validates the request before any target runs, an unrecognized reason never gets partway through an erasure.
- Promise.all over eraseOne means every target attempts erasure independently, one target's throw doesn't cancel or block the others.