user-data-rights
GDPR-compliant domain wiring in ~5 lines: r.useExtension(EXT_USER_DATA, …)
hooks any entity into the forget cron, export ZIP, and magic-link pipeline.
You write two hooks per entity — the framework runs the rest.
What it shows
Section titled “What it shows”A minimal notes domain with GDPR Art. 15+17+20 integrated without hand-written forget logic:
defineFeature("notes", (r) => { r.requires({ features: ["user-data-rights"] }); r.entity({ name: "note", ...noteEntity });
r.useExtension(EXT_USER_DATA, "note", { export: async (ctx) => ({ entity: "note", rows: await ctx.db .select() .from(notesTable) .where(and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId))), }), delete: async (ctx, strategy) => { const where = and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId)); if (strategy === "anonymize") { await ctx.db.update(notesTable).set({ authorId: null }).where(where); } else { await ctx.db.delete(notesTable).where(where); } }, });});What runs automatically
Section titled “What runs automatically”| Article | What happens | Trigger |
|---|---|---|
| Art. 15 + 20 | Notes snippet in export ZIP + magic-link email | runUserExport (via request-export) |
| Art. 17 | Notes deleted or anonymized after grace | runForgetCleanup cron |
| Art. 18 | Restriction blocks login while active | Auth middleware |
| Operator | Brute-force detection on magic-link endpoint | Edge rate-limit + audit log |
Strategy-aware delete
Section titled “Strategy-aware delete”retention.policyFor("note") resolves per entity:
| Profile | Strategy | Effect |
|---|---|---|
eu-dsgvo (default) | delete | Notes hard-deleted |
de-hr-dsgvo-hgb | anonymize for HR entities | authorId=null, row kept for multi-user refs |
The integration test pins both paths.
- User creates notes → rows stored with
authorId. request-exportqueues job →runUserExportbundles user + note rows.request-deletion→ grace period from compliance profile.runForgetCleanupcalls yourdeletehook with resolved strategy.
Needs a running Postgres and TEST_DATABASE_URL set (e.g. postgres://postgres:postgres@127.0.0.1:5432/postgres, see demo/.env.example).
bun test src/__tests__/feature.integration.test.tsStep-by-step proof:
- Alice creates 2 notes → export bundle includes note snippet
- Deletion + expired grace →
runForgetCleanupremoves all notes - Strategy
anonymize→authorId=null, title/body remain
Retention with files
Section titled “Retention with files”runRetentionCleanup is exported for app tests. A hardDelete entity with a file field loses
its bytes (and derivatives), its file_refs row and then the row itself; a storage error keeps
the row and reports file_delete_failed in skipped. src/retained-docs.ts holds the entity,
src/__tests__/retention-files.integration.test.ts drives the run with only public imports:
import { runRetentionCleanup } from "@cosmicdrift/kumiko-bundled-features/data-retention";import { createFileContext } from "@cosmicdrift/kumiko-framework/files";
const result = await runRetentionCleanup({ db: stack.db, registry: stack.registry, tenantId, preloadedTenantPreset: null, now: getTemporal() .Now.instant() .add({ hours: 31 * 24 }), // keepFor 30d + 1 day files: createFileContext(() => Promise.resolve(provider)), // the stack's storage provider});// result.hardDeleted === 1, bytes and file_refs row goneFull app vs this recipe
Section titled “Full app vs this recipe”| This recipe | user-data-rights-demo | |
|---|---|---|
| Scope | One hook pattern | Runnable app + todos + files |
| Boot | Integration test only | bun dev on port 4291 |
Dependencies
Section titled “Dependencies”import { createUserDataRightsFeature } from "@cosmicdrift/kumiko-bundled-features/user-data-rights";import { createUserDataRightsDefaultsFeature } from "@cosmicdrift/kumiko-bundled-features/user-data-rights-defaults";import { createDataRetentionFeature } from "@cosmicdrift/kumiko-bundled-features/data-retention";import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
export const features = [ createDataRetentionFeature(), createComplianceProfilesFeature(), createUserDataRightsFeature(), createUserDataRightsDefaultsFeature(), notesFeature,];Source code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// User-Data-Rights — recipe//// How an app domain integrates the GDPR pipeline: one// (export, delete) hook per entity via EXT_USER_DATA. Forget cron, export ZIP build// and magic link delivery come entirely from user-data-rights —// the app author writes only the two hooks per entity.//// Demo domain: minimal note service. A note has (id, tenantId, authorId,// title, body). Pins:// 1. The export hook delivers note rows as a JSON snippet into the export bundle.// 2. The delete hook removes note rows during the forget-cleanup cron.//// What is not in the recipe (see samples/apps/user-data-rights-demo// for a complete app):// - Strategy-aware delete (anonymize vs hardDelete)// - HTTP endpoints for create/list// - Compliance profile wiring + cron scheduling
import { deleteMany, deriveEntityTableMeta, selectMany, updateMany,} from "@cosmicdrift/kumiko-framework/db";import { createEntity, createTextField, defineFeature, EXT_USER_DATA, type UserDataDeleteHook, type UserDataExportHook,} from "@cosmicdrift/kumiko-framework/engine";
export const noteEntity = createEntity({ table: "store_notes", idType: "uuid", fields: { authorId: createTextField({ personal: "ref" }), title: createTextField({ personal: false, reason: "is_business_data", required: true, maxLength: 200, }), // Plaintext on purpose: store_notes is an unmanaged direct-write store, so the // PII executor never encrypts it. Art. 17 runs through the EXT_USER_DATA // hook below instead (deletes the author's rows, or unlinks them on anonymize). body: createTextField({ personal: false, reason: "covered_by_user_data_hook", maxLength: 4000, }), },});
// Plain EntityTableMeta, NOT a branded EntityTable: store_notes is a deliberate// unmanaged direct-write store (r.storeTable below), so the forget hook may// updateMany/deleteMany it directly — the meta carries no executor-only brand.export const notesTable = deriveEntityTableMeta("note", noteEntity, { source: "unmanaged" });
export const notesFeature = defineFeature("notes", (r) => { r.requires({ features: ["user-data-rights"] }); // store_notes is a direct-write store: the forget hook below `updateMany`/ // `deleteMany`s rows WITHOUT emitting lifecycle events. r.entity would make // it a rebuildable implicit projection whose replay finds zero note events // and wipes the table (or un-forgets anonymized rows) on the next rebuild // (#498). r.storeTable keeps the DDL, opts out of implicit rebuild. r.storeTable(notesTable, { reason: "read_side.notes_direct_write", });
const exportNotes: UserDataExportHook = async (ctx) => { const rows = await selectMany(ctx.db, notesTable, { tenantId: ctx.tenantId, authorId: ctx.userId, }); if (rows.length === 0) return null; return { entity: "note", rows: rows.map((row) => ({ id: String(row.id), title: row.title ?? "", body: row.body ?? "", })), }; };
const deleteNotes: UserDataDeleteHook = async (ctx, strategy) => { const where = { tenantId: ctx.tenantId, authorId: ctx.userId }; if (strategy === "anonymize") { await updateMany(ctx.db, notesTable, { authorId: null }, where); } else { await deleteMany(ctx.db, notesTable, where); } };
r.useExtension(EXT_USER_DATA, "note", { export: exportNotes, delete: deleteNotes, });});📄 On GitHub: samples/recipes/user-data-rights/src/feature.ts