@cosmicdrift/kumiko-bundled-features 0.278.0 → 0.280.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +8 -2
  3. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
  4. package/src/auth-mfa-user-data/hooks.ts +4 -6
  5. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
  6. package/src/custom-fields/wire-user-data-rights.ts +40 -24
  7. package/src/data-retention/resolve-for-tenant.ts +18 -6
  8. package/src/delivery/db/queries/preferences.ts +1 -0
  9. package/src/files-tenant-data/hooks.ts +3 -5
  10. package/src/folders/__tests__/folders.integration.test.ts +4 -3
  11. package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
  12. package/src/folders-user-data/hooks.ts +5 -10
  13. package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
  14. package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
  15. package/src/form-draft-user-data/hooks.ts +3 -6
  16. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
  17. package/src/notes-history-user-data/hooks.ts +3 -4
  18. package/src/personal-access-tokens/handlers/create.write.ts +1 -0
  19. package/src/template-resolver-user-data/hooks.ts +1 -2
  20. package/src/tenant-lifecycle/feature.ts +2 -0
  21. package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
  22. package/src/tenant-lifecycle/stages.ts +26 -2
  23. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
  24. package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
  25. package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
  26. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
  27. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
  28. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
  29. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
  30. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
  31. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
  32. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
  33. package/src/user-data-rights/feature.ts +4 -0
  34. package/src/user-data-rights/run-export-jobs.ts +16 -1
  35. package/src/user-data-rights/run-forget-cleanup.ts +44 -2
  36. package/src/user-data-rights/run-user-export.ts +43 -3
  37. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
  38. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
  39. package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
  40. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
  41. package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
  42. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
  43. package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
  44. package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
  45. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
  46. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
  47. package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
  48. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.278.0",
3
+ "version": "0.280.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -129,12 +129,12 @@
129
129
  "./workflow-runner": "./src/workflow-runner/index.ts"
130
130
  },
131
131
  "dependencies": {
132
- "@cosmicdrift/kumiko-dispatcher-live": "0.278.0",
133
- "@cosmicdrift/kumiko-framework": "0.278.0",
134
- "@cosmicdrift/kumiko-headless": "0.278.0",
135
- "@cosmicdrift/kumiko-renderer": "0.278.0",
136
- "@cosmicdrift/kumiko-renderer-web": "0.278.0",
137
- "@cosmicdrift/kumiko-types": "0.278.0",
132
+ "@cosmicdrift/kumiko-dispatcher-live": "0.280.0",
133
+ "@cosmicdrift/kumiko-framework": "0.280.0",
134
+ "@cosmicdrift/kumiko-headless": "0.280.0",
135
+ "@cosmicdrift/kumiko-renderer": "0.280.0",
136
+ "@cosmicdrift/kumiko-renderer-web": "0.280.0",
137
+ "@cosmicdrift/kumiko-types": "0.280.0",
138
138
  "@mollie/api-client": "^4.5.0",
139
139
  "@node-rs/argon2": "^2.0.2",
140
140
  "@types/mailparser": "^3.4.6",
@@ -163,7 +163,7 @@
163
163
  ],
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
- "@cosmicdrift/kumiko-locale-de": "0.278.0",
167
- "@cosmicdrift/kumiko-locale-es": "0.278.0"
166
+ "@cosmicdrift/kumiko-locale-de": "0.280.0",
167
+ "@cosmicdrift/kumiko-locale-es": "0.280.0"
168
168
  }
169
169
  }
@@ -14,6 +14,7 @@ import {
14
14
  InMemoryKmsAdapter,
15
15
  isPiiCiphertext,
16
16
  } from "@cosmicdrift/kumiko-framework/crypto";
17
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
17
18
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
19
  import {
19
20
  setupTestStack,
@@ -319,7 +320,7 @@ describe("auth flows with active KMS + blind index", () => {
319
320
  expect(isPiiCiphertext(rawInvitation["email"])).toBe(true);
320
321
 
321
322
  const result = await tenantInvitationExportHook({
322
- db: stack.db,
323
+ db: createTenantDb(stack.db, TENANT_A_ID, "tenant"),
323
324
  registry: stack.registry,
324
325
  tenantId: TENANT_A_ID,
325
326
  userId: bobId,
@@ -347,7 +348,12 @@ describe("auth flows with active KMS + blind index", () => {
347
348
  expect(isPiiCiphertext(rawBefore["invited_by"])).toBe(true);
348
349
 
349
350
  await tenantInvitationDeleteHook(
350
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A_ID, userId: aliceId },
351
+ {
352
+ db: createTenantDb(stack.db, TENANT_A_ID, "tenant"),
353
+ registry: stack.registry,
354
+ tenantId: TENANT_A_ID,
355
+ userId: aliceId,
356
+ },
351
357
  "delete",
352
358
  );
353
359
 
@@ -3,7 +3,7 @@
3
3
  // (not just leave it dangling for a future projection rebuild to resurrect).
4
4
 
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
- import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
6
+ import { configureEntityFieldEncryption, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
7
7
  import {
8
8
  createTestUser,
9
9
  setupTestStack,
@@ -77,7 +77,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
77
77
  const user = await enrollUser("export-1");
78
78
 
79
79
  const snippet = await userMfaExportHook({
80
- db: stack.db,
80
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
81
81
  registry: stack.registry,
82
82
  tenantId: user.tenantId,
83
83
  userId: user.id,
@@ -94,7 +94,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
94
94
  test("export returns null for a user who never enrolled", async () => {
95
95
  const user = createTestUser({ id: "never-enrolled", roles: ["User"] });
96
96
  const snippet = await userMfaExportHook({
97
- db: stack.db,
97
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
98
98
  registry: stack.registry,
99
99
  tenantId: user.tenantId,
100
100
  userId: user.id,
@@ -106,7 +106,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
106
106
  const user = await enrollUser("delete-1");
107
107
 
108
108
  const before = await userMfaExportHook({
109
- db: stack.db,
109
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
110
110
  registry: stack.registry,
111
111
  tenantId: user.tenantId,
112
112
  userId: user.id,
@@ -115,7 +115,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
115
115
 
116
116
  await userMfaDeleteHook(
117
117
  {
118
- db: stack.db,
118
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
119
119
  registry: stack.registry,
120
120
  tenantId: user.tenantId,
121
121
  userId: user.id,
@@ -124,7 +124,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
124
124
  );
125
125
 
126
126
  const after = await userMfaExportHook({
127
- db: stack.db,
127
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
128
128
  registry: stack.registry,
129
129
  tenantId: user.tenantId,
130
130
  userId: user.id,
@@ -7,8 +7,7 @@
7
7
  // replays via the event, a raw DELETE would be resurrected by a
8
8
  // projection rebuild).
9
9
 
10
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
11
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
10
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
12
11
  import {
13
12
  createSystemUser,
14
13
  type UserDataDeleteHook,
@@ -26,7 +25,7 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
26
25
  // tokenHash exclusion in user-data-rights-defaults). Just confirms
27
26
  // enrollment + when, which is what a data subject actually needs to see.
28
27
  export const userMfaExportHook: UserDataExportHook = async (ctx) => {
29
- const rows = await selectMany<{ id: string; enabledAt: unknown }>(ctx.db, userMfaTable, {
28
+ const rows = await ctx.db.selectMany<{ id: string; enabledAt: unknown }>(userMfaTable, {
30
29
  userId: ctx.userId,
31
30
  tenantId: ctx.tenantId,
32
31
  });
@@ -41,15 +40,14 @@ export const userMfaExportHook: UserDataExportHook = async (ctx) => {
41
40
  // secret can't be anonymized, and `personal: { of: "userId" }` already
42
41
  // means this row belongs to exactly one user (never shared tenant data).
43
42
  export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
44
- const rows = await selectMany<{ id: string }>(ctx.db, userMfaTable, {
43
+ const rows = await ctx.db.selectMany<{ id: string }>(userMfaTable, {
45
44
  userId: ctx.userId,
46
45
  tenantId: ctx.tenantId,
47
46
  });
48
47
  // skip: nothing enrolled for this user — no row to erase
49
48
  if (rows.length === 0) return;
50
49
  const systemUser = createSystemUser(ctx.tenantId);
51
- const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
52
50
  for (const row of rows) {
53
- assertErased(await executor.forget({ id: row.id }, systemUser, tdb), "user-mfa", row.id);
51
+ assertErased(await executor.forget({ id: row.id }, systemUser, ctx.db), "user-mfa", row.id);
54
52
  }
55
53
  };
@@ -71,8 +71,14 @@ const propertyTable = buildEntityTable("property", propertyEntity);
71
71
  // Host entity gets its own EXT_USER_DATA-registration too — that's the
72
72
  // canonical setup. The host's anonymize hook NULLS inserted_by_id; the
73
73
  // custom-fields wiring contributes export only (#972).
74
+ // fw#2914 — "property" is a managed r.entity table (EXECUTOR_ONLY-branded), so
75
+ // TenantDb's typed write methods reject it; these host hooks need raw SQL,
76
+ // still filtered by inserted_by_id + tenant_id.
77
+ const HOST_HOOK_REASON =
78
+ "fw#2914 test fixture: host export/delete against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by inserted_by_id + tenant_id";
79
+
74
80
  const hostExportHook: UserDataExportHook = async (ctx) => {
75
- const rows = await asRawClient(ctx.db).unsafe(
81
+ const rows = await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
76
82
  `SELECT id, name FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
77
83
  [ctx.userId, ctx.tenantId],
78
84
  );
@@ -86,13 +92,13 @@ const hostExportHook: UserDataExportHook = async (ctx) => {
86
92
 
87
93
  const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
88
94
  if (strategy === "delete") {
89
- await asRawClient(ctx.db).unsafe(
95
+ await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
90
96
  `DELETE FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
91
97
  [ctx.userId, ctx.tenantId],
92
98
  );
93
99
  } else {
94
100
  // anonymize: clear owner, keep row + customFields.
95
- await asRawClient(ctx.db).unsafe(
101
+ await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
96
102
  `UPDATE read_t15c_properties SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
97
103
  [ctx.userId, ctx.tenantId],
98
104
  );
@@ -111,6 +117,7 @@ const propertyFeature = defineFeature("property-t15c", (r) => {
111
117
  r.useExtension(EXT_USER_DATA, "property", {
112
118
  export: hostExportHook,
113
119
  delete: hostDeleteHook,
120
+ escapeHatch: { reason: HOST_HOOK_REASON },
114
121
  });
115
122
 
116
123
  const { executor } = createEntityExecutor("property", propertyEntity);
@@ -1,7 +1,7 @@
1
1
  // T1.5c — user-data-rights wiring for custom-fields.
2
2
 
3
- import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
4
- import type { UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type DbRunner, extractTableName } from "@cosmicdrift/kumiko-framework/db";
4
+ import type { UserDataExportSnippet, UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { EXT_USER_DATA, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { selectCustomFieldsHostRows } from "./db/queries/user-data-rights";
7
7
 
@@ -27,6 +27,41 @@ function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
27
27
  return { id: value.id, customFields: Object.fromEntries(Object.entries(cf)) };
28
28
  }
29
29
 
30
+ // fw#2914 — selectCustomFieldsHostRows runs raw SQL against a dynamic host
31
+ // table name (opts.entityTable varies per call site), so it can't go through
32
+ // TenantDb's typed method API (table param must be a known SchemaTable). The
33
+ // SQL text itself still filters `tenant_id = ctx.tenantId`, so this stays a
34
+ // single-tenant read — the escapeHatch is only for the raw-SQL mechanism, not
35
+ // for cross-tenant access.
36
+ const CUSTOM_FIELDS_EXPORT_REASON =
37
+ "custom-fields export reads a generic customFields column via raw SQL against a dynamic host table name, not expressible through TenantDb's typed method API; the query still filters tenant_id = ctx.tenantId";
38
+
39
+ async function exportCustomFields(
40
+ db: DbRunner,
41
+ ctx: UserDataHookCtx,
42
+ tableName: string,
43
+ opts: WireCustomFieldsUserDataRightsOptions,
44
+ ): Promise<UserDataExportSnippet | null> {
45
+ const rows = await selectCustomFieldsHostRows(
46
+ db,
47
+ tableName,
48
+ opts.userIdColumn,
49
+ ctx.userId,
50
+ ctx.tenantId,
51
+ );
52
+ const snippetRows: Array<{ id: string; customFields: Record<string, unknown> }> = [];
53
+ for (const raw of rows) {
54
+ const row = asCustomFieldsHostRow(raw);
55
+ if (!row) continue;
56
+ const customFields = row.customFields;
57
+ if (customFields && Object.keys(customFields).length > 0) {
58
+ snippetRows.push({ id: row.id, customFields });
59
+ }
60
+ }
61
+ if (snippetRows.length === 0) return null;
62
+ return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
63
+ }
64
+
30
65
  // Export-only wiring: custom fields hold supplemental business data, not PII
31
66
  // (#972) — there is nothing to redact on user-forget, only Art. 20 export.
32
67
  export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<string>>(
@@ -35,29 +70,10 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
35
70
  ): void {
36
71
  const tableName = extractTableName(opts.entityTable, "custom-fields/wire-user-data-rights");
37
72
 
38
- const exportHook: UserDataExportHook = async (ctx) => {
39
- const rows = await selectCustomFieldsHostRows(
40
- ctx.db,
41
- tableName,
42
- opts.userIdColumn,
43
- ctx.userId,
44
- ctx.tenantId,
45
- );
46
- const snippetRows: Array<{ id: string; customFields: Record<string, unknown> }> = [];
47
- for (const raw of rows) {
48
- const row = asCustomFieldsHostRow(raw);
49
- if (!row) continue;
50
- const customFields = row.customFields;
51
- if (customFields && Object.keys(customFields).length > 0) {
52
- snippetRows.push({ id: row.id, customFields });
53
- }
54
- }
55
- if (snippetRows.length === 0) return null;
56
- return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
57
- };
58
-
59
73
  // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
60
74
  r.useExtension(EXT_USER_DATA, opts.entityName, {
61
- export: exportHook,
75
+ export: async (ctx: UserDataHookCtx) =>
76
+ exportCustomFields(ctx.db.unsafeRaw(CUSTOM_FIELDS_EXPORT_REASON), ctx, tableName, opts),
77
+ escapeHatch: { reason: CUSTOM_FIELDS_EXPORT_REASON },
62
78
  });
63
79
  }
@@ -7,15 +7,22 @@
7
7
  // + `resolveRetentionPolicy`, also kein Drift-Risiko.
8
8
 
9
9
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
10
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
10
+ import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
11
11
  import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
12
  import { parseRetentionOverrideOrNull } from "./_internal/parse-override";
13
13
  import type { RetentionPresetKey } from "./presets";
14
14
  import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "./resolver";
15
15
  import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
16
16
 
17
+ function isTenantDb(db: DbRunner | TenantDb): db is TenantDb {
18
+ return typeof (db as Partial<TenantDb>).unsafeRaw === "function";
19
+ }
20
+
17
21
  export interface ResolveForTenantArgs {
18
- readonly db: DbRunner;
22
+ // fw#2914 — a EXT_USER_DATA hook's ctx.db is a TenantDb (method-form);
23
+ // the cleanup cron's own db is still a raw DbRunner. Both are valid here —
24
+ // this lookup is a plain tenant-scoped read either way.
25
+ readonly db: DbRunner | TenantDb;
19
26
  readonly registry: Registry;
20
27
  readonly tenantId: TenantId;
21
28
  readonly entityName: string;
@@ -43,10 +50,15 @@ export async function resolveRetentionPolicyForTenant(
43
50
  const overrideRow =
44
51
  args.preloadedOverride !== undefined
45
52
  ? args.preloadedOverride
46
- : ((await fetchOne(args.db, tenantRetentionOverrideTable, {
47
- tenantId: args.tenantId,
48
- entityName: args.entityName,
49
- })) as { config: string | null } | null); // @cast-boundary db-runner
53
+ : isTenantDb(args.db)
54
+ ? ((await args.db.fetchOne<{ config: string | null }>(tenantRetentionOverrideTable, {
55
+ tenantId: args.tenantId,
56
+ entityName: args.entityName,
57
+ })) ?? null)
58
+ : ((await fetchOne(args.db, tenantRetentionOverrideTable, {
59
+ tenantId: args.tenantId,
60
+ entityName: args.entityName,
61
+ })) as { config: string | null } | null); // @cast-boundary db-runner
50
62
 
51
63
  const tenantOverride = parseRetentionOverrideOrNull(
52
64
  overrideRow?.config ?? null,
@@ -8,6 +8,7 @@ export type NotificationPreferenceRow = {
8
8
  readonly enabled: boolean;
9
9
  };
10
10
 
11
+ // @wrapper-known semantic-alias
11
12
  export async function selectNotificationPreferences(
12
13
  db: DbConnection,
13
14
  tenantId: TenantId,
@@ -18,8 +18,7 @@
18
18
  // cross into another tenant's keys (the provider's own list() prefix
19
19
  // already scopes it).
20
20
 
21
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
22
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
21
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
23
22
  import {
24
23
  createSystemUser,
25
24
  type StorageProviderDestroyTenantHook,
@@ -34,13 +33,12 @@ import {
34
33
  const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
35
34
 
36
35
  export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
37
- const rows = await selectMany<{ id: string }>(ctx.db, fileRefsTable, {
36
+ const rows = await ctx.db.selectMany<{ id: string }>(fileRefsTable, {
38
37
  tenantId: ctx.tenantId,
39
38
  });
40
39
  const user = createSystemUser(ctx.tenantId);
41
- const db = createTenantDb(ctx.db, ctx.tenantId, "system");
42
40
  for (const row of rows) {
43
- const result = await crud.forget({ id: row.id }, user, db);
41
+ const result = await crud.forget({ id: row.id }, user, ctx.db);
44
42
  // Executor writes return {isSuccess:false} instead of throwing — a
45
43
  // discarded result would report this destroy stage "succeeded" while the
46
44
  // row (and its PII fileName) survives. Throw so the pipeline's
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
19
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
20
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
20
21
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
21
22
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
22
23
  import {
@@ -402,7 +403,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
402
403
  test("multi-user tenant: no-op, rows survive", async () => {
403
404
  await seedOneFolderWithAssignment();
404
405
  const ctx = {
405
- db: stack.db,
406
+ db: createTenantDb(stack.db, admin.tenantId, "tenant"),
406
407
  registry: stack.registry,
407
408
  tenantId: admin.tenantId,
408
409
  userId: admin.id,
@@ -417,7 +418,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
417
418
  test("anonymize strategy: no-op even on a single-user tenant", async () => {
418
419
  await seedOneFolderWithAssignment();
419
420
  const ctx = {
420
- db: stack.db,
421
+ db: createTenantDb(stack.db, admin.tenantId, "tenant"),
421
422
  registry: stack.registry,
422
423
  tenantId: admin.tenantId,
423
424
  userId: admin.id,
@@ -432,7 +433,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
432
433
  test("single-user tenant + delete: rows are purged", async () => {
433
434
  await seedOneFolderWithAssignment();
434
435
  const ctx = {
435
- db: stack.db,
436
+ db: createTenantDb(stack.db, admin.tenantId, "tenant"),
436
437
  registry: stack.registry,
437
438
  tenantId: admin.tenantId,
438
439
  userId: admin.id,
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
13
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
14
15
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
15
16
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
17
  import {
@@ -87,7 +88,7 @@ describe("folderAssignmentExportHook", () => {
87
88
  );
88
89
 
89
90
  const snippet = await folderAssignmentExportHook({
90
- db: stack.db,
91
+ db: createTenantDb(stack.db, admin.tenantId, "tenant"),
91
92
  registry: stack.registry,
92
93
  tenantId: admin.tenantId,
93
94
  userId: admin.id,
@@ -4,8 +4,7 @@
4
4
  // Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
5
5
  // (a folder name is tenant data, not per-user PII).
6
6
 
7
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
- import { createTenantDb, type EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
7
+ import type { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
9
8
  import {
10
9
  createEntityExecutor,
11
10
  createSystemUser,
@@ -27,9 +26,7 @@ const { table: folderAssignmentTable, executor: folderAssignmentExecutor } = cre
27
26
  // all tenant folders in-app, so bundling them into the user's export is no new
28
27
  // exposure — it gives the data subject the organisation of the loans they work with.
29
28
  export const folderExportHook: UserDataExportHook = async (ctx) => {
30
- const rows = await selectMany<Record<string, unknown>>(ctx.db, folderTable, {
31
- tenantId: ctx.tenantId,
32
- });
29
+ const rows = await ctx.db.selectMany(folderTable, { tenantId: ctx.tenantId });
33
30
  if (rows.length === 0) return null;
34
31
  return { entity: "folder", rows };
35
32
  };
@@ -38,7 +35,7 @@ export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
38
35
  // folderAssignmentEntity is softDelete: true — a cleared assignment
39
36
  // (isDeleted: true) is a removed folder membership, not something the GDPR
40
37
  // export should still surface as current data.
41
- const rows = await selectMany<Record<string, unknown>>(ctx.db, folderAssignmentTable, {
38
+ const rows = await ctx.db.selectMany(folderAssignmentTable, {
42
39
  tenantId: ctx.tenantId,
43
40
  isDeleted: false,
44
41
  });
@@ -65,11 +62,9 @@ function tenantScopedDelete(
65
62
  // eventless, so a projection rebuild resurrects the rows. Bounded — forget
66
63
  // only fires for single-user tenants.
67
64
  const systemUser = createSystemUser(ctx.tenantId);
68
- // The executor needs a TenantDb (loadById → db.fetchOne), not the raw ctx.db.
69
- const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
70
- const rows = await selectMany<{ id: string }>(ctx.db, table, { tenantId: ctx.tenantId });
65
+ const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
71
66
  for (const row of rows) {
72
- await executor.delete({ id: row.id }, systemUser, tdb);
67
+ await executor.delete({ id: row.id }, systemUser, ctx.db);
73
68
  }
74
69
  };
75
70
  }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
9
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
10
11
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
12
  import {
12
13
  createTestUser,
@@ -50,7 +51,12 @@ describe("formDraftDeleteHook — delete failure handling (#review-batch-fw-1)",
50
51
 
51
52
  await expect(
52
53
  formDraftDeleteHook(
53
- { db: stack.db, registry: stack.registry, tenantId: bob.tenantId, userId: bob.id },
54
+ {
55
+ db: createTenantDb(stack.db, bob.tenantId, "tenant"),
56
+ registry: stack.registry,
57
+ tenantId: bob.tenantId,
58
+ userId: bob.id,
59
+ },
54
60
  "delete",
55
61
  ),
56
62
  ).resolves.toBeUndefined();
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
8
9
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
9
10
  import {
10
11
  createTestUser,
@@ -52,7 +53,7 @@ describe("formDraftExportHook", () => {
52
53
  );
53
54
 
54
55
  const snippet = await formDraftExportHook({
55
- db: stack.db,
56
+ db: createTenantDb(stack.db, owner.tenantId, "tenant"),
56
57
  registry: stack.registry,
57
58
  tenantId: owner.tenantId,
58
59
  userId: owner.id,
@@ -88,7 +89,7 @@ describe("formDraftExportHook", () => {
88
89
  );
89
90
 
90
91
  const snippet = await formDraftExportHook({
91
- db: stack.db,
92
+ db: createTenantDb(stack.db, tenantATestTenantId, "tenant"),
92
93
  registry: stack.registry,
93
94
  tenantId: tenantATestTenantId,
94
95
  userId: sameIdTenantA.id,
@@ -101,7 +102,7 @@ describe("formDraftExportHook", () => {
101
102
  test("returns null when the user saved no drafts", async () => {
102
103
  const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
103
104
  const snippet = await formDraftExportHook({
104
- db: stack.db,
105
+ db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
105
106
  registry: stack.registry,
106
107
  tenantId: lurker.tenantId,
107
108
  userId: lurker.id,
@@ -126,7 +127,12 @@ describe("formDraftDeleteHook", () => {
126
127
  );
127
128
 
128
129
  await formDraftDeleteHook(
129
- { db: stack.db, registry: stack.registry, tenantId: alice.tenantId, userId: alice.id },
130
+ {
131
+ db: createTenantDb(stack.db, alice.tenantId, "tenant"),
132
+ registry: stack.registry,
133
+ tenantId: alice.tenantId,
134
+ userId: alice.id,
135
+ },
130
136
  "delete",
131
137
  );
132
138
 
@@ -138,7 +144,12 @@ describe("formDraftDeleteHook", () => {
138
144
  const lurker = createTestUser({ id: 13, roles: ["TenantMember"] });
139
145
  await expect(
140
146
  formDraftDeleteHook(
141
- { db: stack.db, registry: stack.registry, tenantId: lurker.tenantId, userId: lurker.id },
147
+ {
148
+ db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
149
+ registry: stack.registry,
150
+ tenantId: lurker.tenantId,
151
+ userId: lurker.id,
152
+ },
142
153
  "delete",
143
154
  ),
144
155
  ).resolves.toBeUndefined();
@@ -3,8 +3,6 @@
3
3
  // consumers without the user-data-rights pipeline don't pull a hard
4
4
  // dependency.
5
5
 
6
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
- import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
8
6
  import {
9
7
  createSystemUser,
10
8
  type UserDataDeleteHook,
@@ -13,7 +11,7 @@ import {
13
11
  import { formDraftExecutor, formDraftTable } from "../form-draft";
14
12
 
15
13
  export const formDraftExportHook: UserDataExportHook = async (ctx) => {
16
- const rows = await selectMany<Record<string, unknown>>(ctx.db, formDraftTable, {
14
+ const rows = await ctx.db.selectMany(formDraftTable, {
17
15
  tenantId: ctx.tenantId,
18
16
  ownerId: ctx.userId,
19
17
  });
@@ -37,15 +35,14 @@ export const formDraftExportHook: UserDataExportHook = async (ctx) => {
37
35
  // owners' drafts, tenant model doesn't matter here (unlike folders' single-
38
36
  // user gating: a form-draft row always has exactly one owner already).
39
37
  export const formDraftDeleteHook: UserDataDeleteHook = async (ctx) => {
40
- const rows = await selectMany<{ id: string }>(ctx.db, formDraftTable, {
38
+ const rows = await ctx.db.selectMany<{ id: string }>(formDraftTable, {
41
39
  tenantId: ctx.tenantId,
42
40
  ownerId: ctx.userId,
43
41
  });
44
42
  const systemUser = createSystemUser(ctx.tenantId);
45
- const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
46
43
  const failures: string[] = [];
47
44
  for (const row of rows) {
48
- const result = await formDraftExecutor.delete({ id: row.id }, systemUser, tdb);
45
+ const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db);
49
46
  if (!result.isSuccess) {
50
47
  failures.push(`${row.id}: ${result.error.message}`);
51
48
  }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
8
9
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
10
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
10
11
  import {
@@ -78,7 +79,7 @@ describe("noteEntryExportHook", () => {
78
79
  );
79
80
 
80
81
  const snippet = await noteEntryExportHook({
81
- db: stack.db,
82
+ db: createTenantDb(stack.db, author.tenantId, "tenant"),
82
83
  registry: stack.registry,
83
84
  tenantId: author.tenantId,
84
85
  userId: author.id,
@@ -95,7 +96,7 @@ describe("noteEntryExportHook", () => {
95
96
  test("returns null when the user authored no notes", async () => {
96
97
  const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
97
98
  const snippet = await noteEntryExportHook({
98
- db: stack.db,
99
+ db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
99
100
  registry: stack.registry,
100
101
  tenantId: lurker.tenantId,
101
102
  userId: lurker.id,
@@ -107,7 +108,12 @@ describe("noteEntryExportHook", () => {
107
108
  describe("noteEntryDeleteHook", () => {
108
109
  test("is a no-op — resolves without throwing", async () => {
109
110
  const result = await noteEntryDeleteHook(
110
- { db: stack.db, registry: stack.registry, tenantId: author.tenantId, userId: author.id },
111
+ {
112
+ db: createTenantDb(stack.db, author.tenantId, "tenant"),
113
+ registry: stack.registry,
114
+ tenantId: author.tenantId,
115
+ userId: author.id,
116
+ },
111
117
  "delete",
112
118
  );
113
119
  expect(result).toBeUndefined();
@@ -4,7 +4,6 @@
4
4
  // dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
5
5
  // export-only, erasure via crypto-shredding.
6
6
 
7
- import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
7
  import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
9
8
  import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
10
9
  import { resolveRetentionPolicyForTenant } from "../data-retention";
@@ -14,7 +13,7 @@ import { policyToStrategy } from "../user-data-rights";
14
13
  // note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
15
14
  // per-user content, so the export filters by authorId directly.
16
15
  export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
17
- const rows = await selectMany<Record<string, unknown>>(ctx.db, noteEntryTable, {
16
+ const rows = await ctx.db.selectMany(noteEntryTable, {
18
17
  authorId: ctx.userId,
19
18
  });
20
19
  if (rows.length === 0) return null;
@@ -55,7 +54,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
55
54
  // skip: no KMS adapter mounted — record-owned fields stay plaintext framework-wide, forget is a true no-op
56
55
  if (!kms) return;
57
56
 
58
- const mentions = await selectMany<{ noteId: string }>(ctx.db, noteMentionTable, {
57
+ const mentions = await ctx.db.selectMany<{ noteId: string }>(noteMentionTable, {
59
58
  subjectId: ctx.userId,
60
59
  });
61
60
  // skip: no note-mention rows for this user — nothing structurally reaches this user's data to shred
@@ -63,7 +62,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
63
62
 
64
63
  const noteIds = new Set(mentions.map((m) => m.noteId));
65
64
  for (const noteId of noteIds) {
66
- const note = await fetchOne<{ entityType: string }>(ctx.db, noteEntryTable, { id: noteId });
65
+ const note = await ctx.db.fetchOne<{ entityType: string }>(noteEntryTable, { id: noteId });
67
66
  // Defensive: append-only rows are never hard-deleted, so this shouldn't
68
67
  // happen — but a missing host means no retention policy to consult.
69
68
  if (!note) continue;