@cosmicdrift/kumiko-bundled-features 0.277.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 (62) 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/cap-counter/__tests__/enforce-cap.test.ts +7 -10
  6. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +123 -6
  7. package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +135 -1
  8. package/src/cap-counter/book-cap-usage.ts +158 -0
  9. package/src/cap-counter/changes.json +16 -1
  10. package/src/cap-counter/enforce-cap.ts +5 -37
  11. package/src/cap-counter/handlers/increment.write.ts +7 -47
  12. package/src/cap-counter/handlers/mark-soft-warned.write.ts +6 -33
  13. package/src/cap-counter/index.ts +8 -0
  14. package/src/cap-counter/stock-cap-guard.ts +14 -19
  15. package/src/cap-counter/with-cap-enforcement.ts +9 -11
  16. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
  17. package/src/custom-fields/wire-user-data-rights.ts +40 -24
  18. package/src/data-retention/resolve-for-tenant.ts +18 -6
  19. package/src/delivery/db/queries/preferences.ts +1 -0
  20. package/src/files-tenant-data/hooks.ts +3 -5
  21. package/src/folders/__tests__/folders.integration.test.ts +4 -3
  22. package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
  23. package/src/folders-user-data/hooks.ts +5 -10
  24. package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
  25. package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
  26. package/src/form-draft-user-data/hooks.ts +3 -6
  27. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
  28. package/src/notes-history-user-data/hooks.ts +3 -4
  29. package/src/personal-access-tokens/handlers/create.write.ts +1 -0
  30. package/src/template-resolver-user-data/hooks.ts +1 -2
  31. package/src/tenant-lifecycle/feature.ts +2 -0
  32. package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
  33. package/src/tenant-lifecycle/stages.ts +26 -2
  34. package/src/tier-engine/__tests__/tier-resolver.integration.test.ts +138 -0
  35. package/src/tier-engine/changes.json +7 -0
  36. package/src/tier-engine/tier-resolver.ts +7 -6
  37. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
  38. package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
  39. package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
  40. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
  41. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
  42. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
  43. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
  44. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
  45. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
  46. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
  47. package/src/user-data-rights/feature.ts +4 -0
  48. package/src/user-data-rights/run-export-jobs.ts +16 -1
  49. package/src/user-data-rights/run-forget-cleanup.ts +44 -2
  50. package/src/user-data-rights/run-user-export.ts +43 -3
  51. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
  52. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
  53. package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
  54. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
  55. package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
  56. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
  57. package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
  58. package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
  59. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
  60. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
  61. package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
  62. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
@@ -1 +1,16 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.278.0",
4
+ "type": "improvement",
5
+ "title": "bookCapUsage/markCapSoftWarned/readRollingCapUsage — in-process calendar-cap booking bound to the caller's own tenant (fw#2854).",
6
+ "detail": "New helpers in `book-cap-usage.ts`: `bookCapUsage(ctx, options)` and `markCapSoftWarned(ctx, options)` run the calendar-counter create-or-update / soft-warn-flip logic in-process against `ctx.db`/`ctx.user`; `readRollingCapUsage(db, tenantId, options)` sums a rolling window without throwing and works as the `usage` callback for an existing rolling stream. `enforceCapAndMaybeNotify` and `withCapEnforcement` now call `bookCapUsage`/`markCapSoftWarned` in-process instead of dispatching the `SystemAdmin`-only `increment`/`mark-soft-warned` write-handlers through `ctx.write` — a plain `TenantAdmin` caller (no `SystemAdmin` role, no `escapeHatch`, no `ctx.writeAs` identity-switch) can now use calendar cap-enforcement end to end. `withRollingCapEnforcement` still books through the `SystemAdmin`-only `increment-rolling` write-handler via `ctx.write` — the framework's event-ownership rule rejects `unsafeAppendEvent` calls for a foreign feature made in-process, so rolling callers still need a `SystemAdmin` identity.",
7
+ "migration": "No action required for the call signatures — `enforceCapAndMaybeNotify`/`withCapEnforcement`/`withRollingCapEnforcement` keep their existing signatures and result shapes. An app that previously worked around the `SystemAdmin`-only calendar dispatch with its own `escapeHatch` + `ctx.writeAs(SystemAdmin, ...)` wrapper can remove that workaround for `withCapEnforcement`; `withRollingCapEnforcement` callers still need a `SystemAdmin` identity (role or `ctx.writeAs`)."
8
+ },
9
+ {
10
+ "version": "0.278.0",
11
+ "type": "breaking",
12
+ "title": "createStockCapGuard/checkStockCap/withStockCap resolve through the caller's TenantDb, not a raw DbRunner + explicit tenantId (fw#2854).",
13
+ "detail": "`createStockCapGuard`'s resolver is now `(db: TenantDb) => Promise<TCaps>` instead of `(db: DbRunner, tenantId: TenantId) => Promise<TCaps>`. `checkStockCap(db: TenantDb, spec)` drops the separate `tenantId` parameter — the tenant comes from `db.tenantId`, and the count runs through `TenantDb.count` instead of the raw `countWhere`. `StockCapSpec.table` is now typed `SchemaTable | EntityTableMeta` (matching `TenantDb.count`'s table parameter) instead of `Parameters<typeof countWhere>[1]`. `withStockCap` no longer declares an `escapeHatch` on the wrapped handler and no longer reads through `ctx.db.unsafeRaw(...)` — it calls `checkStockCap(ctx.db, spec)` directly, so a `TenantAdmin`-only handler with no `escapeHatch` can use it.",
14
+ "migration": "`createStockCapGuard(async (db, tenantId) => ...)` → `createStockCapGuard(async (db) => ...)`, reading `db.tenantId` instead of the removed second argument. A direct `checkStockCap(runner, tenantId, spec)` call → `checkStockCap(tenantDb, spec)` with a `TenantDb` built via `createTenantDb(runner, tenantId)` (or `ctx.db` inside a handler). If your wrapped handler relied on `withStockCap`'s auto-added `escapeHatch` for some other reason, declare it explicitly on the handler instead — `withStockCap` no longer adds one."
15
+ }
16
+ ]
@@ -1,12 +1,6 @@
1
1
  import { createEntityExecutor, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
- import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
- import { rollingCapAggregateId } from "./aggregate-id";
5
- import {
6
- CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
7
- CapCounterHandlers,
8
- ROLLING_INCREMENTED_EVENT_QN,
9
- } from "./constants";
3
+ import { markCapSoftWarned, readRollingCapUsage } from "./book-cap-usage";
10
4
  import { capCounterEntity } from "./entity";
11
5
 
12
6
  // Temporal globally provided by the framework's polyfill init
@@ -180,33 +174,11 @@ export async function enforceRollingCap(
180
174
  const softThreshold = options.limit * tolerance.soft;
181
175
  const hardThreshold = options.limit * tolerance.hard;
182
176
 
183
- const aggregateId = rollingCapAggregateId(ctx.user.tenantId, options.capName);
184
- const cutoff = Temporal.Now.instant().subtract({ hours: options.windowDays * 24 });
185
-
186
- // events_tenant_type_idx (tenant_id, aggregate_type, created_at)
187
- // covers the prefix; the additional aggregate_id eq narrows to the
188
- // single rolling-stream. Postgres can use the index even with the
189
- // aggregate_id filter applied as a residual.
190
- const rows = await ctx.db.selectMany<{ payload: { amount?: number } }>(eventsTable, {
191
- tenantId: ctx.user.tenantId,
192
- aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
193
- aggregateId,
194
- type: ROLLING_INCREMENTED_EVENT_QN,
195
- createdAt: { gte: cutoff },
177
+ const value = await readRollingCapUsage(ctx.db, ctx.user.tenantId, {
178
+ capName: options.capName,
179
+ windowDays: options.windowDays,
196
180
  });
197
181
 
198
- let value = 0;
199
- for (const row of rows) {
200
- // @cast-boundary engine-payload — events.payload is jsonb (typed as
201
- // unknown by drizzle's $type<Record<string,unknown>>); narrowing
202
- // the shape here is a deliberate read-side contract for the
203
- // rolling-incremented-event we authored.
204
- const payload = row["payload"] as { amount?: number };
205
- if (typeof payload.amount === "number") {
206
- value += payload.amount;
207
- }
208
- }
209
-
210
182
  if (value >= hardThreshold) {
211
183
  throw new CapExceededError(options.capName, options.limit, value, tolerance);
212
184
  }
@@ -335,11 +307,7 @@ export async function enforceCapAndMaybeNotify(
335
307
  tenantId: ctx.user.tenantId,
336
308
  });
337
309
  // Flip the soft-warned flag so the same period doesn't re-notify.
338
- // We're already inside a write-handler-context, so dispatching the
339
- // mark-soft-warned-handler in-line works via ctx.write (re-uses
340
- // the request user; the handler's own access-check enforces the
341
- // SystemAdmin role on the caller).
342
- await ctx.write(CapCounterHandlers.markSoftWarned, {
310
+ await markCapSoftWarned(ctx, {
343
311
  capName: options.capName,
344
312
  periodStartIso: options.periodStartIso,
345
313
  });
@@ -1,10 +1,6 @@
1
- import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- import { Temporal } from "temporal-polyfill";
1
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { z } from "zod";
4
- import { capCounterAggregateId } from "../aggregate-id";
5
- import { capCounterEntity } from "../entity";
6
-
7
- const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
3
+ import { bookCapUsage } from "../book-cap-usage";
8
4
 
9
5
  const incrementSchema = z.object({
10
6
  /** App-defined cap-name. e.g. "platform-mails", "ai-tokens-7day". */
@@ -47,46 +43,10 @@ export const incrementCapHandler: WriteHandlerDef = {
47
43
  access: { roles: ["SystemAdmin"] },
48
44
  handler: async (event, ctx) => {
49
45
  const payload = event.payload as IncrementPayload; // @cast-boundary engine-payload
50
- const aggregateId = capCounterAggregateId(
51
- event.user.tenantId,
52
- payload.capName,
53
- payload.periodStartIso,
54
- );
55
-
56
- // Read existing aggregate's projection-row to decide create vs update.
57
- // ctx.db is auto-tenant-scoped — id-lookup is unique per tenant.
58
- const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
59
-
60
- if (existing.length === 0) {
61
- return executor.create(
62
- {
63
- id: aggregateId,
64
- capName: payload.capName,
65
- value: payload.amount,
66
- periodStart: Temporal.Instant.from(payload.periodStartIso),
67
- lastSoftWarnedAt: null,
68
- },
69
- event.user,
70
- ctx.db,
71
- );
72
- }
73
-
74
- const currentRow = existing[0];
75
- if (!currentRow) {
76
- // Defensive — length-check above means this is unreachable. Throws
77
- // clearer than a possibly-null deref later.
78
- throw new Error("cap-counter:increment: row vanished between length-check and read");
79
- }
80
- const currentValue = currentRow["value"] as number; // @cast-boundary db-row
81
- const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
82
- return executor.update(
83
- {
84
- id: aggregateId,
85
- version: currentVersion,
86
- changes: { value: currentValue + payload.amount },
87
- },
88
- event.user,
89
- ctx.db,
90
- );
46
+ return bookCapUsage(ctx, {
47
+ capName: payload.capName,
48
+ periodStartIso: payload.periodStartIso,
49
+ amount: payload.amount,
50
+ });
91
51
  },
92
52
  };
@@ -1,10 +1,6 @@
1
- import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- import { Temporal } from "temporal-polyfill";
1
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { z } from "zod";
4
- import { capCounterAggregateId } from "../aggregate-id";
5
- import { capCounterEntity } from "../entity";
6
-
7
- const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
3
+ import { markCapSoftWarned } from "../book-cap-usage";
8
4
 
9
5
  // mark-soft-warned — sets lastSoftWarnedAt on the counter so subsequent
10
6
  // soft-cap-hits in the same period don't re-trigger notifications.
@@ -26,32 +22,9 @@ export const markSoftWarnedHandler: WriteHandlerDef = {
26
22
  access: { roles: ["SystemAdmin"] },
27
23
  handler: async (event, ctx) => {
28
24
  const payload = event.payload as z.infer<typeof markSoftWarnedSchema>; // @cast-boundary engine-payload
29
- const aggregateId = capCounterAggregateId(
30
- event.user.tenantId,
31
- payload.capName,
32
- payload.periodStartIso,
33
- );
34
-
35
- const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
36
- if (existing.length === 0) {
37
- throw new Error(
38
- `cap-counter: cannot mark-soft-warned, no counter found for tenant=${event.user.tenantId} cap=${payload.capName} period=${payload.periodStartIso}`,
39
- );
40
- }
41
- const row = existing[0];
42
- if (!row) {
43
- throw new Error("cap-counter:mark-soft-warned: row vanished between length-check and read");
44
- }
45
- const currentVersion = row["version"] as number; // @cast-boundary db-row
46
-
47
- return executor.update(
48
- {
49
- id: aggregateId,
50
- version: currentVersion,
51
- changes: { lastSoftWarnedAt: Temporal.Now.instant() },
52
- },
53
- event.user,
54
- ctx.db,
55
- );
25
+ return markCapSoftWarned(ctx, {
26
+ capName: payload.capName,
27
+ periodStartIso: payload.periodStartIso,
28
+ });
56
29
  },
57
30
  };
@@ -1,6 +1,14 @@
1
1
  // Public API of the cap-counter bundled-feature.
2
2
 
3
3
  export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id";
4
+ export {
5
+ type BookCapUsageOptions,
6
+ bookCapUsage,
7
+ type MarkCapSoftWarnedOptions,
8
+ markCapSoftWarned,
9
+ type ReadRollingCapUsageOptions,
10
+ readRollingCapUsage,
11
+ } from "./book-cap-usage";
4
12
  export {
5
13
  CAP_COUNTER_FEATURE,
6
14
  CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
@@ -3,8 +3,13 @@
3
3
  // only per-app variable was the Caps shape and how to resolve it for a
4
4
  // tenant, so both are now factory parameters.
5
5
 
6
- import { countWhere, type DbRunner, type WhereObject } from "@cosmicdrift/kumiko-framework/db";
7
- import type { TenantId, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
+ import type {
7
+ EntityTableMeta,
8
+ SchemaTable,
9
+ TenantDb,
10
+ WhereObject,
11
+ } from "@cosmicdrift/kumiko-framework/db";
12
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
8
13
  import {
9
14
  UnprocessableError,
10
15
  type WriteFailure,
@@ -13,7 +18,7 @@ import {
13
18
  import { enforceStockCap } from "./enforce-cap";
14
19
 
15
20
  export type StockCapSpec<TCaps> = {
16
- readonly table: Parameters<typeof countWhere>[1];
21
+ readonly table: SchemaTable | EntityTableMeta;
17
22
  readonly limit: (caps: TCaps) => number;
18
23
  readonly where?: WhereObject;
19
24
  readonly code: string;
@@ -22,24 +27,19 @@ export type StockCapSpec<TCaps> = {
22
27
  };
23
28
 
24
29
  export type StockCapGuard<TCaps> = {
25
- readonly checkStockCap: (
26
- db: DbRunner,
27
- tenantId: TenantId,
28
- spec: StockCapSpec<TCaps>,
29
- ) => Promise<WriteFailure | null>;
30
+ readonly checkStockCap: (db: TenantDb, spec: StockCapSpec<TCaps>) => Promise<WriteFailure | null>;
30
31
  readonly withStockCap: (handler: WriteHandlerDef, spec: StockCapSpec<TCaps>) => WriteHandlerDef;
31
32
  };
32
33
 
33
34
  export function createStockCapGuard<TCaps>(
34
- resolveTierCaps: (db: DbRunner, tenantId: TenantId) => Promise<TCaps>,
35
+ resolveTierCaps: (db: TenantDb) => Promise<TCaps>,
35
36
  ): StockCapGuard<TCaps> {
36
37
  async function checkStockCap(
37
- db: DbRunner,
38
- tenantId: TenantId,
38
+ db: TenantDb,
39
39
  spec: StockCapSpec<TCaps>,
40
40
  ): Promise<WriteFailure | null> {
41
- const caps = await resolveTierCaps(db, tenantId);
42
- const current = await countWhere(db, spec.table, { ...spec.where, tenantId });
41
+ const caps = await resolveTierCaps(db);
42
+ const current = await db.count(spec.table, { ...spec.where, tenantId: db.tenantId });
43
43
  const { state, limit } = enforceStockCap({
44
44
  current,
45
45
  limit: spec.limit(caps),
@@ -55,15 +55,10 @@ export function createStockCapGuard<TCaps>(
55
55
  }
56
56
 
57
57
  function withStockCap(handler: WriteHandlerDef, spec: StockCapSpec<TCaps>): WriteHandlerDef {
58
- const capReason =
59
- "counts the caller tenant's rows and resolves its tier caps through the raw-runner cap API";
60
58
  return {
61
59
  ...handler,
62
- escapeHatch: handler.escapeHatch
63
- ? { reason: `${handler.escapeHatch.reason} ${capReason}` }
64
- : { reason: capReason },
65
60
  handler: async (event, ctx) => {
66
- const failure = await checkStockCap(ctx.db.unsafeRaw(capReason), event.user.tenantId, spec);
61
+ const failure = await checkStockCap(ctx.db, spec);
67
62
  return failure ?? handler.handler(event, ctx);
68
63
  },
69
64
  };
@@ -1,5 +1,5 @@
1
1
  // withCapEnforcement / withRollingCapEnforcement — handler-wrapper die
2
- // pre-call enforceCap-And-Notify + post-call increment um den
2
+ // pre-call enforceCap-And-Notify + post-call booking um den
3
3
  // gewrappten Handler legen.
4
4
  //
5
5
  // **Warum Wrapper statt manuelle Calls im Handler:**
@@ -8,22 +8,20 @@
8
8
  // pre-call zu machen — beides ist atomic-mit-dem-Handler-zusammen.
9
9
  // Wrapper macht das Pattern explizit + co-located.
10
10
  //
11
- // **Atomicity-Vorbehalt:** Pre-enforce + Handler + Post-Increment
12
- // laufen in DREI getrennten Transaktionen (Dispatcher öffnet jede
13
- // ctx.write-call eine eigene). Bei einem Crash zwischen Handler-
14
- // Success und Post-Increment kommt der Counter unter — Tenant
15
- // kriegt 1-2 Mails extra. Akzeptabel weil Cap-Toleranzen (110/120%)
16
- // genau für solche Drift-Fälle gebaut sind.
11
+ // **Atomicity caveat:** calendar booking runs in-process via bookCapUsage
12
+ // (see book-cap-usage.ts). Rolling booking still dispatches the
13
+ // SystemAdmin-only increment-rolling handler (event ownership), so rolling
14
+ // callers need a SystemAdmin identity.
17
15
  //
18
- // **Kein automatic markSoftWarned:** das passiert in
19
- // enforceCapAndMaybeNotify drin (siehe enforce-cap.ts). Wrapper ruft
20
- // nur den Helper, der den write dispatched.
16
+ // No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
17
+ // (enforce-cap.ts).
21
18
 
22
19
  import type {
23
20
  HandlerContext,
24
21
  WriteEvent,
25
22
  WriteHandlerDef,
26
23
  } from "@cosmicdrift/kumiko-framework/engine";
24
+ import { bookCapUsage } from "./book-cap-usage";
27
25
  import { CapCounterHandlers } from "./constants";
28
26
  import {
29
27
  type CapToleranceProfileName,
@@ -103,7 +101,7 @@ export function withCapEnforcement(
103
101
  // Post-success increment. Skip on failure so a failed write
104
102
  // doesn't burn cap-quota. amount default 1.
105
103
  if (result.isSuccess) {
106
- await ctx.write(CapCounterHandlers.increment, {
104
+ await bookCapUsage(ctx, {
107
105
  capName: cap.capName,
108
106
  amount: cap.amount ?? 1,
109
107
  periodStartIso: cap.periodStartIso,
@@ -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
  }