@cosmicdrift/kumiko-framework 0.367.0 → 0.369.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 (240) hide show
  1. package/AGENTS.md +1 -7
  2. package/README.md +1 -1
  3. package/agents/examples/access-control.md +2 -2
  4. package/agents/examples/anonymous-access-multitenant.md +2 -2
  5. package/agents/examples/anonymous-access.md +3 -3
  6. package/agents/examples/apex-landing.md +1 -1
  7. package/agents/examples/apex-surface-auth.md +6 -7
  8. package/agents/examples/api-worker-split.md +7 -8
  9. package/agents/examples/auth-claims.md +2 -1
  10. package/agents/examples/auth-foundation-providers.md +3 -3
  11. package/agents/examples/auth-mfa.md +3 -4
  12. package/agents/examples/basic-entity.md +3 -4
  13. package/agents/examples/chat-channels.md +4 -4
  14. package/agents/examples/compliance-profiles-demo.md +2 -3
  15. package/agents/examples/cross-feature-events.md +4 -4
  16. package/agents/examples/crypto-shredding-hr.md +14 -9
  17. package/agents/examples/currencies-global.md +3 -4
  18. package/agents/examples/currencies-per-tenant.md +3 -4
  19. package/agents/examples/custom-fields-basic.md +2 -3
  20. package/agents/examples/custom-handlers.md +3 -5
  21. package/agents/examples/dashboard-stats.md +2 -3
  22. package/agents/examples/delivery-notifications.md +5 -6
  23. package/agents/examples/e2e-generator.md +1 -1
  24. package/agents/examples/embedded-entity-form.md +2 -3
  25. package/agents/examples/embedded.md +2 -3
  26. package/agents/examples/encrypted-tenant-config.md +5 -6
  27. package/agents/examples/error-contract.md +3 -4
  28. package/agents/examples/event-sourcing.md +19 -23
  29. package/agents/examples/feature-toggles.md +4 -4
  30. package/agents/examples/field-access.md +3 -4
  31. package/agents/examples/files-post-processing.md +12 -13
  32. package/agents/examples/golden-path.md +208 -0
  33. package/agents/examples/idempotency.md +2 -2
  34. package/agents/examples/inbound-mail-triage.md +7 -7
  35. package/agents/examples/lane-routing.md +3 -3
  36. package/agents/examples/ledger-banking.md +2 -3
  37. package/agents/examples/ledger-invoicing.md +2 -3
  38. package/agents/examples/legal-pages.md +1 -1
  39. package/agents/examples/lifecycle-hooks.md +3 -3
  40. package/agents/examples/managed-config.md +3 -4
  41. package/agents/examples/managed-pages.md +2 -2
  42. package/agents/examples/notes-history-basic.md +4 -5
  43. package/agents/examples/ownership.md +3 -5
  44. package/agents/examples/pdf-audit-export.md +2 -3
  45. package/agents/examples/pipeline-basics.md +2 -2
  46. package/agents/examples/public-share-token.md +3 -3
  47. package/agents/examples/rate-limiting.md +2 -1
  48. package/agents/examples/realtime-sse.md +2 -3
  49. package/agents/examples/record-detail-layout.md +1 -1
  50. package/agents/examples/reference-data.md +3 -3
  51. package/agents/examples/relations.md +2 -2
  52. package/agents/examples/screens-nav.md +1 -1
  53. package/agents/examples/search.md +2 -2
  54. package/agents/examples/secrets-demo.md +8 -8
  55. package/agents/examples/security-baseline.md +2 -2
  56. package/agents/examples/session-revocation.md +7 -8
  57. package/agents/examples/state-machine.md +181 -165
  58. package/agents/examples/tags-basic.md +2 -3
  59. package/agents/examples/tenant-isolation.md +2 -3
  60. package/agents/examples/tenant-settings.md +2 -2
  61. package/agents/examples/tier-admin.md +3 -4
  62. package/agents/examples/timezones.md +3 -3
  63. package/agents/examples/user-data-rights.md +12 -8
  64. package/agents/examples/user-profile.md +3 -3
  65. package/agents/examples/webhook-step.md +2 -3
  66. package/agents/examples/wizard-form.md +4 -5
  67. package/agents/examples/workflow-engine.md +7 -3
  68. package/agents/examples/writeform-section.md +1 -1
  69. package/agents/examples.md +1 -0
  70. package/agents/features.md +4 -4
  71. package/dist/consumer-cli.js +2 -3
  72. package/dist/crypto/index.d.ts +1 -1
  73. package/dist/crypto/index.js +1 -1
  74. package/dist/crypto/owner-transition-rekey.js +2 -0
  75. package/dist/crypto/pg-kms-adapter.js +1 -1
  76. package/dist/crypto/subject-resolver.d.ts +2 -0
  77. package/dist/crypto/subject-resolver.js +30 -15
  78. package/dist/db/bun-db-deprecated.d.ts +10 -0
  79. package/dist/{bun-db/index.js → db/bun-db-deprecated.js} +4 -3
  80. package/dist/db/config-seed.js +1 -1
  81. package/dist/db/connection.d.ts +23 -5
  82. package/dist/db/connection.js +10 -5
  83. package/dist/db/dialect.js +1 -1
  84. package/dist/db/entity-field-encryption.d.ts +5 -0
  85. package/dist/db/entity-field-encryption.js +29 -0
  86. package/dist/db/entity-table-meta.js +6 -5
  87. package/dist/db/event-store-executor-read.js +26 -5
  88. package/dist/db/index.d.ts +4 -4
  89. package/dist/db/index.js +1 -1
  90. package/dist/db/migrate-runner.js +1 -1
  91. package/dist/db/queries/backfill-pii.js +16 -9
  92. package/dist/db/queries/ddl.js +1 -1
  93. package/dist/db/queries/event-store.d.ts +2 -0
  94. package/dist/db/queries/event-store.js +26 -0
  95. package/dist/db/queries/test-stack.js +1 -1
  96. package/dist/db/query.d.ts +105 -1
  97. package/dist/db/query.js +1450 -3
  98. package/dist/{stack/table-helpers.d.ts → db/table-push.d.ts} +5 -15
  99. package/dist/{stack/table-helpers.js → db/table-push.js} +6 -46
  100. package/dist/db/tenant-db.d.ts +11 -0
  101. package/dist/db/tenant-db.js +51 -12
  102. package/dist/derivatives/derivatives-context.d.ts +1 -1
  103. package/dist/derivatives/derivatives-context.js +1 -1
  104. package/dist/engine/boot-validator/gdpr-storage.js +3 -3
  105. package/dist/engine/boot-validator/pii-retention.js +3 -3
  106. package/dist/engine/boot-validator/record-owned.js +3 -3
  107. package/dist/engine/boot-validator/screens.js +37 -1
  108. package/dist/engine/bridge-stub.d.ts +4 -0
  109. package/dist/engine/bridge-stub.js +62 -0
  110. package/dist/engine/build-config-feature-schema.js +197 -65
  111. package/dist/engine/event-helpers.d.ts +2 -1
  112. package/dist/engine/event-helpers.js +1 -0
  113. package/dist/engine/extension-selector-plugins.d.ts +3 -0
  114. package/dist/engine/extension-selector-plugins.js +14 -0
  115. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  116. package/dist/engine/feature-config-events-jobs.js +3 -2
  117. package/dist/engine/index.d.ts +1 -0
  118. package/dist/engine/index.js +1 -0
  119. package/dist/es-ops/operations-schema.js +1 -1
  120. package/dist/event-store/admin-api.js +1 -1
  121. package/dist/event-store/archive.js +1 -1
  122. package/dist/event-store/errors.d.ts +2 -1
  123. package/dist/event-store/errors.js +6 -2
  124. package/dist/event-store/event-store.js +17 -9
  125. package/dist/event-store/events-schema.js +1 -1
  126. package/dist/event-store/rebuild-dead-letter.js +3 -3
  127. package/dist/event-store/snapshot.js +1 -1
  128. package/dist/event-store/upcaster-dead-letter.js +3 -3
  129. package/dist/files/create-file-ref-from-bytes.js +1 -1
  130. package/dist/files/file-routes.js +1 -1
  131. package/dist/i18n/settings-hub-keys.js +1 -2
  132. package/dist/internal/stack-support.d.ts +12 -0
  133. package/dist/internal/stack-support.js +12 -0
  134. package/dist/internal/testing.d.ts +4 -0
  135. package/dist/internal/testing.js +5 -0
  136. package/dist/jobs/job-runner.js +23 -11
  137. package/dist/migrations/pending-rebuilds.js +1 -1
  138. package/dist/migrations/pii-event-backfill.js +2 -2
  139. package/dist/pipeline/dispatch-shared.js +43 -23
  140. package/dist/pipeline/dispatch-write.js +6 -2
  141. package/dist/pipeline/dispatcher-utils.js +6 -2
  142. package/dist/pipeline/event-consumer-state.js +1 -1
  143. package/dist/pipeline/projection-rebuild.d.ts +1 -0
  144. package/dist/pipeline/projection-rebuild.js +1 -1
  145. package/dist/pipeline/projection-state.js +1 -1
  146. package/dist/pipeline/system-hooks.js +1 -1
  147. package/dist/schema-cli.js +3 -3
  148. package/dist/search/purge-subject.js +20 -10
  149. package/dist/seeding/index.d.ts +1 -0
  150. package/dist/seeding/index.js +1 -0
  151. package/dist/stack/index.d.ts +10 -9
  152. package/dist/stack/index.js +11 -19
  153. package/dist/testing/e2e-generator.d.ts +5 -49
  154. package/dist/testing/e2e-generator.js +5 -382
  155. package/dist/testing/handler-context.d.ts +5 -4
  156. package/dist/testing/handler-context.js +5 -69
  157. package/dist/testing/index.d.ts +10 -26
  158. package/dist/testing/index.js +10 -34
  159. package/dist/testing/real-providers.d.ts +5 -3
  160. package/dist/testing/real-providers.js +5 -21
  161. package/dist/upgrade-cli.js +6 -4
  162. package/package.json +37 -9
  163. package/src/changes.json +64 -0
  164. package/src/scripts/codemod/README.md +43 -0
  165. package/src/scripts/codemod/codemod-support.ts +276 -0
  166. package/src/scripts/codemod/db-factory.ts +810 -0
  167. package/src/scripts/codemod/feature-factory.ts +429 -0
  168. package/src/scripts/codemod/s3-imports.ts +274 -0
  169. package/src/scripts/codemod/tenant-db-options.ts +454 -0
  170. package/src/scripts/codemod/testing-move-targets.ts +161 -0
  171. package/src/scripts/codemod/testing-move.ts +520 -0
  172. package/dist/bun-db/connection.d.ts +0 -17
  173. package/dist/bun-db/connection.js +0 -40
  174. package/dist/bun-db/index.d.ts +0 -4
  175. package/dist/bun-db/query.d.ts +0 -106
  176. package/dist/bun-db/query.js +0 -1457
  177. package/dist/db/api.d.ts +0 -19
  178. package/dist/db/api.js +0 -23
  179. package/dist/db/bun-provider.d.ts +0 -2
  180. package/dist/db/bun-provider.js +0 -28
  181. package/dist/db/postgres-provider.d.ts +0 -2
  182. package/dist/db/postgres-provider.js +0 -21
  183. package/dist/db/query-api.d.ts +0 -2
  184. package/dist/db/query-api.js +0 -6
  185. package/dist/stack/db.d.ts +0 -22
  186. package/dist/stack/db.js +0 -67
  187. package/dist/stack/drain-event-consumers.d.ts +0 -4
  188. package/dist/stack/drain-event-consumers.js +0 -51
  189. package/dist/stack/drain-jobs.d.ts +0 -22
  190. package/dist/stack/drain-jobs.js +0 -141
  191. package/dist/stack/event-collector.d.ts +0 -9
  192. package/dist/stack/event-collector.js +0 -12
  193. package/dist/stack/push-entity-projection-tables.d.ts +0 -24
  194. package/dist/stack/push-entity-projection-tables.js +0 -74
  195. package/dist/stack/redis.d.ts +0 -17
  196. package/dist/stack/redis.js +0 -52
  197. package/dist/stack/request-helper.d.ts +0 -35
  198. package/dist/stack/request-helper.js +0 -144
  199. package/dist/stack/test-stack.d.ts +0 -192
  200. package/dist/stack/test-stack.js +0 -429
  201. package/dist/testing/access-assertions.d.ts +0 -2
  202. package/dist/testing/access-assertions.js +0 -13
  203. package/dist/testing/assertions.d.ts +0 -10
  204. package/dist/testing/assertions.js +0 -22
  205. package/dist/testing/boot-validator-fixture.d.ts +0 -3
  206. package/dist/testing/boot-validator-fixture.js +0 -101
  207. package/dist/testing/closed-connection-error.d.ts +0 -7
  208. package/dist/testing/closed-connection-error.js +0 -46
  209. package/dist/testing/db-cleanup.d.ts +0 -9
  210. package/dist/testing/db-cleanup.js +0 -31
  211. package/dist/testing/expect-error.d.ts +0 -2
  212. package/dist/testing/expect-error.js +0 -22
  213. package/dist/testing/file-provider-contract.d.ts +0 -2
  214. package/dist/testing/file-provider-contract.js +0 -102
  215. package/dist/testing/http-cookies.d.ts +0 -7
  216. package/dist/testing/http-cookies.js +0 -47
  217. package/dist/testing/late-bound.d.ts +0 -9
  218. package/dist/testing/late-bound.js +0 -31
  219. package/dist/testing/manual-clock.d.ts +0 -14
  220. package/dist/testing/manual-clock.js +0 -12
  221. package/dist/testing/multipart-helper.d.ts +0 -22
  222. package/dist/testing/multipart-helper.js +0 -77
  223. package/dist/testing/mutable-master-key-provider.d.ts +0 -7
  224. package/dist/testing/mutable-master-key-provider.js +0 -35
  225. package/dist/testing/observability-recorder.d.ts +0 -9
  226. package/dist/testing/observability-recorder.js +0 -29
  227. package/dist/testing/seed.d.ts +0 -9
  228. package/dist/testing/seed.js +0 -21
  229. package/dist/testing/shared-entities.d.ts +0 -286
  230. package/dist/testing/shared-entities.js +0 -56
  231. package/dist/testing/utils.d.ts +0 -1
  232. package/dist/testing/utils.js +0 -1
  233. package/dist/testing/wait-for.d.ts +0 -20
  234. package/dist/testing/wait-for.js +0 -47
  235. package/dist/testing/without-ambient-temporal.d.ts +0 -5
  236. package/dist/testing/without-ambient-temporal.js +0 -17
  237. package/dist/testing/zip-fixture.d.ts +0 -1
  238. package/dist/testing/zip-fixture.js +0 -33
  239. /package/dist/{stack/test-users.d.ts → seeding/seed-users.d.ts} +0 -0
  240. /package/dist/{stack/test-users.js → seeding/seed-users.js} +0 -0
@@ -1,13 +1,13 @@
1
- import type { DbConnection } from "../db/connection.js";
2
- import type { ColumnMeta, EntityTableMeta, IndexMeta } from "../db/entity-table-meta.js";
3
- import type { EventDispatcher } from "../pipeline/index.js";
1
+ import type { EntityDefinition } from "../engine/types/index.js";
2
+ import type { DbConnection } from "./connection.js";
3
+ import type { ColumnMeta, EntityTableMeta, IndexMeta } from "./entity-table-meta.js";
4
4
  /**
5
5
  * Bypass: creates an entity-table directly without going through the
6
6
  * full registry. Reserved for framework-internal meta-tables and
7
7
  * test setup — apps declare data via `r.entity(...)`.
8
8
  */
9
- export declare function unsafeCreateEntityTable(db: DbConnection, entity: import("../engine/types/index.js").EntityDefinition, entityName?: string): Promise<void>;
10
- export declare function unsafeEnsureEntityTable(db: DbConnection, entity: import("../engine/types/index.js").EntityDefinition, entityName?: string): Promise<boolean>;
9
+ export declare function unsafeCreateEntityTable(db: DbConnection, entity: EntityDefinition, entityName?: string): Promise<void>;
10
+ export declare function unsafeEnsureEntityTable(db: DbConnection, entity: EntityDefinition, entityName?: string): Promise<boolean>;
11
11
  export declare function tableToMeta(table: unknown): EntityTableMeta;
12
12
  /** Applies ALTER TABLE ADD COLUMN for exactly the given columns. */
13
13
  export declare function addMissingColumns(db: DbConnection, tableName: string, columns: readonly ColumnMeta[]): Promise<void>;
@@ -22,13 +22,3 @@ export declare function addMissingIndexes(db: DbConnection, tableName: string, i
22
22
  * entities go through `kumiko schema apply` (committed SQL files).
23
23
  */
24
24
  export declare function unsafePushTables(db: DbConnection, tables: Record<string, unknown>, prevTables?: Record<string, unknown>): Promise<void>;
25
- /**
26
- * Wipes event store + framework-state + the given feature read-models in
27
- * one TRUNCATE, then re-registers the event-consumer state rows. Used in
28
- * test beforeEach-hooks to return the stack to a clean slate without
29
- * rebuilding it.
30
- */
31
- export declare function resetEventStore(stack: {
32
- db: unknown;
33
- eventDispatcher?: EventDispatcher;
34
- }, extraTables?: readonly (unknown | string)[]): Promise<void>;
@@ -1,21 +1,9 @@
1
- import { pgTypeToSqlType } from "../db/dialect.js";
2
- import { alterTableAddColumn, createIndexIfNotExists, executeDdlStatement, } from "../db/queries/ddl.js";
3
- import { truncateTablesRestartIdentity } from "../db/queries/test-stack.js";
4
- import { renderTableDdl } from "../db/render-ddl.js";
5
- import { tableExists } from "../db/schema-inspection.js";
6
- import { buildEntityTable, toTableName } from "../db/table-builder.js";
7
- import { asEntityTableMeta, schemaTableName } from "../db/table-introspection.js";
8
- function tableNameOf(table) {
9
- if (typeof table !== "object" || table === null) {
10
- throw new Error("table-helpers: table is not a SchemaTable object");
11
- }
12
- const symbolName = schemaTableName(table);
13
- if (symbolName !== undefined)
14
- return symbolName;
15
- if ("tableName" in table && typeof table.tableName === "string")
16
- return table.tableName;
17
- throw new Error("table-helpers: table has no name");
18
- }
1
+ import { pgTypeToSqlType } from "./dialect.js";
2
+ import { alterTableAddColumn, createIndexIfNotExists, executeDdlStatement } from "./queries/ddl.js";
3
+ import { renderTableDdl } from "./render-ddl.js";
4
+ import { tableExists } from "./schema-inspection.js";
5
+ import { buildEntityTable, toTableName } from "./table-builder.js";
6
+ import { asEntityTableMeta } from "./table-introspection.js";
19
7
  /**
20
8
  * Bypass: creates an entity-table directly without going through the
21
9
  * full registry. Reserved for framework-internal meta-tables and
@@ -96,31 +84,3 @@ export async function unsafePushTables(db, tables, prevTables) {
96
84
  function renderColumnType(col) {
97
85
  return pgTypeToSqlType(col.pgType);
98
86
  }
99
- /**
100
- * Wipes event store + framework-state + the given feature read-models in
101
- * one TRUNCATE, then re-registers the event-consumer state rows. Used in
102
- * test beforeEach-hooks to return the stack to a clean slate without
103
- * rebuilding it.
104
- */
105
- export async function resetEventStore(stack, extraTables = []) {
106
- const frameworkTables = [
107
- "kumiko_events",
108
- "kumiko_event_consumers",
109
- "kumiko_archived_streams",
110
- "kumiko_snapshots",
111
- "kumiko_projections",
112
- ];
113
- const extraNames = extraTables.map((t) => (typeof t === "string" ? t : tableNameOf(t)));
114
- const truncateAndReregister = async () => {
115
- await truncateTablesRestartIdentity(stack.db, [...frameworkTables, ...extraNames]);
116
- await stack.eventDispatcher?.ensureRegistered();
117
- };
118
- // The dispatcher's own timer/LISTEN must not start a pass between the
119
- // TRUNCATE and the consumer re-registration, so they are paused for the whole reset.
120
- if (stack.eventDispatcher) {
121
- await stack.eventDispatcher.withBackgroundPassesPaused(truncateAndReregister);
122
- }
123
- else {
124
- await truncateAndReregister();
125
- }
126
- }
@@ -37,6 +37,17 @@ export declare function acknowledgeConventionCrossTenant(tenantDb: TenantDb, rea
37
37
  }): TenantDb;
38
38
  export declare function reportConventionCrossTenantUse(acknowledged: TenantDb, target?: EscapeHatchTarget): void;
39
39
  export declare function runInOwnTransaction<T>(tenantDb: TenantDb, fn: (txDb: TenantDb) => Promise<T>): Promise<T>;
40
+ export type CreateTenantDbOptions = {
41
+ readonly db: DbRunner;
42
+ readonly tenantId: TenantId;
43
+ readonly mode?: TenantDbMode;
44
+ readonly tracer?: Tracer;
45
+ readonly meter?: Meter;
46
+ readonly signal?: AbortSignal;
47
+ readonly grants?: TenantDbGrants;
48
+ };
49
+ export declare function createTenantDb(options: CreateTenantDbOptions): TenantDb;
50
+ /** @deprecated Positional form, use createTenantDb({ db, tenantId, ... }). Run `bun kumiko-upgrade --apply` to rewrite call sites. Removed in the first release after 2026-10-29. */
40
51
  export declare function createTenantDb(db: DbRunner, tenantId: TenantId, mode?: TenantDbMode, tracer?: Tracer, meter?: Meter, signal?: AbortSignal, grants?: TenantDbGrants): TenantDb;
41
52
  export declare function createProjectingTenantDb(db: DbRunner, tenantId: TenantId, projectionRegistry: Registry | undefined): TenantDb;
42
53
  export { asRawClient };
@@ -225,7 +225,7 @@ export function createSystemDbView(db, dbOutsideTransaction, report = fallbackEs
225
225
  // same connection/transaction. For code that must run caller-supplied callbacks (e.g. config
226
226
  // validators) without handing them a cross-tenant view.
227
227
  export function scopeTenantDbToTenant(db, tenantId) {
228
- return createTenantDb(tenantDbRunner(db), tenantId, "tenant");
228
+ return createTenantDb({ db: tenantDbRunner(db), tenantId, mode: "tenant" });
229
229
  }
230
230
  // @cast-boundary tenant-db-row
231
231
  export function castTenantRows(rows) {
@@ -311,7 +311,26 @@ export async function runInOwnTransaction(tenantDb, fn) {
311
311
  }
312
312
  return rebind(fn);
313
313
  }
314
- export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, signal, grants) {
314
+ // Runners (postgres-js Sql/tx handles) are functions and RawDbClient mocks carry no own
315
+ // `tenantId`/`db`, so a runner is never mistaken for the options object.
316
+ function isCreateTenantDbOptions(value) {
317
+ return (typeof value === "object" &&
318
+ value !== null &&
319
+ Object.hasOwn(value, "tenantId") &&
320
+ Object.hasOwn(value, "db"));
321
+ }
322
+ export function createTenantDb(dbOrOptions, tenantId, mode, tracer, meter, signal, grants) {
323
+ if (isCreateTenantDbOptions(dbOrOptions))
324
+ return buildTenantDb(dbOrOptions);
325
+ return buildTenantDb({ db: dbOrOptions, tenantId, mode, tracer, meter, signal, grants });
326
+ }
327
+ // Untyped JS callers or an `as` cast can still hand in undefined; a scoped handle
328
+ // without a tenant would bind `tenant_id = NULL` instead of failing loudly.
329
+ function buildTenantDb(options) {
330
+ const { db, tenantId, mode = "tenant", tracer, meter, signal, grants } = options;
331
+ if (tenantId === undefined) {
332
+ throw new InternalError({ message: "createTenantDb: tenantId is required" });
333
+ }
315
334
  if (meter)
316
335
  registerStandardMetrics(meter);
317
336
  const report = grants?.report ?? fallbackEscapeHatchReporter(tenantId);
@@ -585,16 +604,32 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
585
604
  },
586
605
  };
587
606
  declaredUnsafeRawRunners.set(tenantDb, grantedUnsafeRawRunner);
588
- unsafeRawRebinders.set(tenantDb, (grant) => createTenantDb(db, tenantId, mode, tracer, meter, signal, {
589
- ...rebindGrants,
590
- unsafeRaw: grant,
591
- // db.global() writes are write-handler-only: a rebind (a hook's own grant) must not inherit them.
592
- globalWrites: undefined,
607
+ unsafeRawRebinders.set(tenantDb, (grant) => createTenantDb({
608
+ db,
609
+ tenantId,
610
+ mode,
611
+ tracer,
612
+ meter,
613
+ signal,
614
+ grants: {
615
+ ...rebindGrants,
616
+ unsafeRaw: grant,
617
+ // db.global() writes are write-handler-only: a rebind (a hook's own grant) must not inherit them.
618
+ globalWrites: undefined,
619
+ },
593
620
  }));
594
621
  crossTenantRebinders.set(tenantDb, (reason, deferReport) => {
595
622
  if (!deferReport)
596
623
  report("acknowledge-cross-tenant", reason);
597
- const acknowledged = createTenantDb(db, tenantId, "system", tracer, meter, signal, rebindGrants);
624
+ const acknowledged = createTenantDb({
625
+ db,
626
+ tenantId,
627
+ mode: "system",
628
+ tracer,
629
+ meter,
630
+ signal,
631
+ grants: rebindGrants,
632
+ });
598
633
  crossTenantUseReporters.set(acknowledged, (target) => report("acknowledge-cross-tenant", reason, target));
599
634
  return acknowledged;
600
635
  });
@@ -607,7 +642,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
607
642
  }
608
643
  // Carries a runner-bound gate (grants.personalDataGate unset, resolved via the fallback above) forward explicitly, since the fresh tx-handle isn't itself registered in runnerPersonalDataGates.
609
644
  const txGrants = personalDataGate ? { ...rebindGrants, personalDataGate } : rebindGrants;
610
- return runInNewTransaction(db, (tx) => fn(createTenantDb(tx, tenantId, mode, tracer, meter, signal, txGrants)));
645
+ return runInNewTransaction(db, (tx) => fn(createTenantDb({ db: tx, tenantId, mode, tracer, meter, signal, grants: txGrants })));
611
646
  });
612
647
  bindTenantDbRunner(tenantDb, db);
613
648
  if (grants?.writesDeniedReason !== undefined) {
@@ -619,9 +654,13 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
619
654
  bindProjectionRegistry(tenantDb, projectionRegistry);
620
655
  return tenantDb;
621
656
  }
622
- // Tenant-mode TenantDb whose writes reach the registry's custom projections. The positional
623
- // undefineds of createTenantDb would hide a moved grants parameter from the type checker.
657
+ // Tenant-mode TenantDb whose writes reach the registry's custom projections.
624
658
  export function createProjectingTenantDb(db, tenantId, projectionRegistry) {
625
- return createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, projectionRegistry ? { projectionRegistry } : undefined);
659
+ return createTenantDb({
660
+ db,
661
+ tenantId,
662
+ mode: "tenant",
663
+ grants: projectionRegistry ? { projectionRegistry } : undefined,
664
+ });
626
665
  }
627
666
  export { asRawClient };
@@ -1,5 +1,5 @@
1
1
  import type { DerivativeRendererPlugin, DerivativesContext } from "@cosmicdrift/kumiko-types/derivatives-types";
2
- import { type AnyDb } from "../bun-db/query.js";
2
+ import { type AnyDb } from "../db/query.js";
3
3
  import type { Registry, TenantId } from "../engine/types/index.js";
4
4
  import type { FileContext } from "../files/file-handle.js";
5
5
  export type DerivativesContextDeps = {
@@ -1,4 +1,4 @@
1
- import { fetchOne } from "../bun-db/query.js";
1
+ import { fetchOne } from "../db/query.js";
2
2
  import { EXT_DERIVATIVE_OVERLAY_RESOLVER, EXT_DERIVATIVE_RENDERER, } from "../engine/extension-names.js";
3
3
  import { InternalError, NotFoundError } from "../errors/index.js";
4
4
  import { fileRefsTable } from "../files/file-ref-table.js";
@@ -1,7 +1,7 @@
1
1
  // Providers whose bytes do not survive a process restart. Only "inmemory"
2
2
  // today; extend if another ephemeral bundled provider lands.
3
3
  const EPHEMERAL_PROVIDERS = new Set(["inmemory"]);
4
- // Env vars file-provider-s3-env reads (see files-provider-s3/env-helper).
4
+ // Env vars createFileProviderS3Feature({ fromEnv: true }) reads (see file-provider-s3/lib/env-helper).
5
5
  // Missing any → createS3ProviderFromEnv throws on the first file op.
6
6
  const S3_ENV_REQUIRED = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"];
7
7
  // Cross-feature GDPR storage guard (V1). Catches the failure class we shipped
@@ -31,7 +31,7 @@ export function validateGdprStoragePersistence(features) {
31
31
  const persistent = [...registeredProviders].filter((p) => !EPHEMERAL_PROVIDERS.has(p));
32
32
  if (persistent.length === 0) {
33
33
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
34
- console.warn("[kumiko:boot] user-data-rights is mounted but no persistent file provider is — GDPR exports use an ephemeral/in-memory store and are LOST on restart (the download then 500s). Mount file-provider-s3 or file-provider-s3-env and select it via the file-foundation provider config.");
34
+ console.warn("[kumiko:boot] user-data-rights is mounted but no persistent file provider is — GDPR exports use an ephemeral/in-memory store and are LOST on restart (the download then 500s). Mount createFileProviderS3Feature() or createFileProviderS3Feature({ fromEnv: true }) and select it via the file-foundation provider config.");
35
35
  // skip: ephemeral store already warned; the s3-env env-var check below is moot
36
36
  return;
37
37
  }
@@ -42,7 +42,7 @@ export function validateGdprStoragePersistence(features) {
42
42
  const missing = S3_ENV_REQUIRED.filter((v) => !process.env[v]);
43
43
  if (missing.length > 0) {
44
44
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
45
- console.warn(`[kumiko:boot] file-provider-s3-env is the GDPR file store but these env vars are unset: ${missing.join(", ")}. The provider throws on the first export (inside the cron), not here — set them so GDPR storage works.`);
45
+ console.warn(`[kumiko:boot] createFileProviderS3Feature({ fromEnv: true }) (provider "s3-env") is the GDPR file store but these env vars are unset: ${missing.join(", ")}. The provider throws on the first export (inside the cron), not here — set them so GDPR storage works.`);
46
46
  }
47
47
  }
48
48
  }
@@ -204,11 +204,11 @@ export function validatePiiAndRetention(feature) {
204
204
  throw new Error(`[Feature ${feature.name}] personal.of "${ownerName}" on entity "${entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`);
205
205
  }
206
206
  const { whenAbsent } = annot.userOwned;
207
- if (whenAbsent !== undefined && whenAbsent !== "tenant") {
208
- throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares personal.whenAbsent "${String(whenAbsent)}" — entity fields only support whenAbsent: "tenant" (no plaintext fallback for stored PII). Remove whenAbsent or set it to "tenant".`);
207
+ if (whenAbsent !== undefined && whenAbsent !== "tenant" && whenAbsent !== "self") {
208
+ throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares personal.whenAbsent "${String(whenAbsent)}" — entity fields only support whenAbsent: "tenant" or "self" (no plaintext fallback for stored PII). Remove whenAbsent or set it to "tenant" (tenant key) or "self" (the row's own record key).`);
209
209
  }
210
210
  if (ownerField.required !== true && whenAbsent === undefined) {
211
- throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" is personal: { of: "${ownerName}" }, but "${ownerName}" is not required — rows without an owner would fail to encrypt (SubjectResolutionError, HTTP 500) on update, delete and forget. Declare { personal: { of: "${ownerName}", whenAbsent: "tenant" } } to encrypt those rows under the tenant key, or make "${ownerName}" required: true. Entity fields have no plaintext fallback (fw#3654).`);
211
+ throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" is personal: { of: "${ownerName}" }, but "${ownerName}" is not required — rows without an owner would fail to encrypt (SubjectResolutionError, HTTP 500) on update, delete and forget. Declare { personal: { of: "${ownerName}", whenAbsent: "tenant" } } to encrypt those rows under the tenant key, whenAbsent: "self" to encrypt them under the row's own record key (shreddable per record), or make "${ownerName}" required: true. Entity fields have no plaintext fallback (fw#3654).`);
212
212
  }
213
213
  // Soft-Warning wenn das reference-target nicht offensichtlich user
214
214
  // ist — custom subject-entities (HR-Mitarbeiter, Patient) sind
@@ -1,4 +1,4 @@
1
- // A recordOwned field is encrypted under `record:<entity>:<id>` (kms-adapter-types.ts)
1
+ // A recordOwned field (or one with whenAbsent: "self") is encrypted under `record:<entity>:<id>` (kms-adapter-types.ts)
2
2
  // and forgetSubject's subjectIdSchema (crypto/kms-adapter.ts) requires that id
3
3
  // to be a UUID — an entity with idType: "serial" would encrypt the field but
4
4
  // could never satisfy a forget-subject request for it.
@@ -8,9 +8,9 @@ export function validateRecordOwnedSubjects(feature) {
8
8
  continue;
9
9
  for (const [fieldName, field] of Object.entries(entity.fields)) {
10
10
  const annot = field; // @cast-boundary schema-walk
11
- if (annot.recordOwned !== true)
11
+ if (annot.recordOwned !== true && annot.userOwned?.whenAbsent !== "self")
12
12
  continue;
13
- throw new Error(`[Feature ${feature.name}] entity "${entityName}" field "${fieldName}" is recordOwned but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field would be encrypted but could never be shredded. Set idType: "uuid" on entity "${entityName}", or give field "${fieldName}" a different personal stance (personal: "self" / "tenant" / { of: "<field>" }).`);
13
+ throw new Error(`[Feature ${feature.name}] entity "${entityName}" field "${fieldName}" is recordOwned (or whenAbsent: "self") but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field would be encrypted but could never be shredded. Set idType: "uuid" on entity "${entityName}", or give field "${fieldName}" a different personal stance (personal: "self" / "tenant" / { of: "<field>" }).`);
14
14
  }
15
15
  }
16
16
  }
@@ -7,6 +7,7 @@ import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
7
7
  import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } from "@cosmicdrift/kumiko-types/fields";
8
8
  import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
9
9
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
10
+ import { isListFilterAllowed } from "../../db/entity-field-encryption.js";
10
11
  import { rowMetaFieldNames } from "../../db/table-builder.js";
11
12
  import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
12
13
  import { isEncryptedAtRest } from "../config-helpers.js";
@@ -230,7 +231,7 @@ function validateWizardLayout(featureName, screenId, screenType, layout, feature
230
231
  if (!featureMap.has("form-draft")) {
231
232
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but the ` +
232
233
  `bundled feature "form-draft" is not mounted — every resume would silently lose its ` +
233
- `values. Add formDraftFeature() from @cosmicdrift/kumiko-bundled-features to the app's ` +
234
+ `values. Add createFormDraftFeature() from @cosmicdrift/kumiko-bundled-features to the app's ` +
234
235
  `feature list.`);
235
236
  }
236
237
  }
@@ -550,6 +551,25 @@ function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNa
550
551
  function isWriteOnlyField(fdef) {
551
552
  return fdef?.writeOnly === true; // @cast-boundary schema-walk
552
553
  }
554
+ // A secret on a configEdit form keeps its value out of the client like an encrypted
555
+ // config key does, and the secrets store only exists per tenant.
556
+ function validateConfigEditSecretField(featureName, screen, fieldName, secretQn, featureMap) {
557
+ const where = `[Feature ${featureName}] Screen "${screen.id}" (configEdit) field "${fieldName}"`;
558
+ if (screen.configKeys[fieldName] !== undefined) {
559
+ throw new Error(`${where} is mapped in both configKeys and secretKeys — keep one.`);
560
+ }
561
+ if (screen.scope !== "tenant") {
562
+ throw new Error(`${where} maps secret "${secretQn}" on a ${screen.scope}-scope screen — secrets are tenant-scoped.`);
563
+ }
564
+ const fdef = screen.fields[fieldName];
565
+ if (fdef?.type !== "text" || !isWriteOnlyField(fdef)) {
566
+ throw new Error(`${where} maps secret "${secretQn}" but is no writeOnly text field — a stored secret never goes back to the client.`);
567
+ }
568
+ const declared = [...featureMap.values()].some((feature) => Object.values(feature.secretKeys ?? {}).some((def) => def.qualifiedName === secretQn));
569
+ if (!declared) {
570
+ throw new Error(`${where} → secret "${secretQn}" is not declared via r.secret() in any feature.`);
571
+ }
572
+ }
553
573
  // Inline forms have no stored value to hide or keep: writeOnly there would
554
574
  // render a "set / keep" affordance with nothing behind it.
555
575
  function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fdef) {
@@ -1476,9 +1496,19 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1476
1496
  }
1477
1497
  }
1478
1498
  validateWizardLayout(feature.name, screenId, "configEdit", screen.layout, featureMap);
1499
+ for (const fname of Object.keys(screen.secretKeys ?? {})) {
1500
+ if (!fieldNames.has(fname)) {
1501
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) secretKeys names unknown field "${fname}".`);
1502
+ }
1503
+ }
1479
1504
  // configKeys: jeder fieldName muss einen Mapping-Eintrag haben,
1480
1505
  // jeder qualifizierte Key muss in der Registry existieren.
1481
1506
  for (const fname of fieldNames) {
1507
+ const secretQn = screen.secretKeys?.[fname];
1508
+ if (secretQn !== undefined) {
1509
+ validateConfigEditSecretField(feature.name, screen, fname, secretQn, featureMap);
1510
+ continue;
1511
+ }
1482
1512
  const qualified = screen.configKeys[fname];
1483
1513
  if (qualified === undefined) {
1484
1514
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has ` +
@@ -1641,6 +1671,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1641
1671
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityList) filter references unknown ` +
1642
1672
  `field "${filterField}". Known fields: ${[...fieldNames].sort().join(", ")}`);
1643
1673
  }
1674
+ if (!isListFilterAllowed(entityDef, filterField, screen.filter)) {
1675
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityList) filter references field ` +
1676
+ `"${filterField}" which is encrypted, personal or secret, so the list handler rejects ` +
1677
+ `this filter at runtime. Only op "eq" works on a personal field with find: "exact" or ` +
1678
+ `"fuzzy"; otherwise drop the filter or filter on another field.`);
1679
+ }
1644
1680
  const fieldDef = entityDef.fields[filterField];
1645
1681
  if (fieldDef !== undefined && !isFieldFilterable(fieldDef)) {
1646
1682
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityList) filter references field ` +
@@ -0,0 +1,4 @@
1
+ import type { HandlerContext, SessionUser } from "./types/index.js";
2
+ export declare function bridgeStub(opts?: {
3
+ readonly user?: SessionUser;
4
+ }): Pick<HandlerContext, "query" | "queryAs" | "write" | "writeAs" | "appendEvent" | "unsafeAppendEvent" | "tryAppendEvent" | "fetchForWriting" | "loadAggregate" | "archiveStream" | "restoreStream" | "isStreamArchived" | "snapshotAggregate" | "loadAggregateWithSnapshot" | "queryProjection" | "resolveAuthClaims" | "resolveActiveMembership" | "queryAsMember" | "hasFeature" | "metrics" | "metricsFor" | "tracer" | "tz" | "locale" | "user">;
@@ -0,0 +1,62 @@
1
+ import { DEFAULT_LOCALE } from "../i18n/request-locale.js";
2
+ import { createNoopMetricsHandle, getFallbackTracer } from "../observability/index.js";
3
+ import { createTzContext } from "../time/index.js";
4
+ import { createAnonymousUser } from "./system-user.js";
5
+ // Test/service helper: cross-feature bridge methods that throw on use.
6
+ //
7
+ // Production code always receives a full HandlerContext from the Dispatcher's
8
+ // buildHandlerContext (with real query/write closures). Some internal services
9
+ // and tests construct a mini-context manually (typically just `{ db, registry }`)
10
+ // to invoke a single handler. Those call sites don't use ctx.query/write —
11
+ // the stubs make the TypeScript shape match while still failing loudly if
12
+ // anything downstream accidentally reaches for them.
13
+ //
14
+ // Use: `{ db, registry, ...bridgeStub() }`
15
+ const notAvailable = (what) => async () => {
16
+ throw new Error(`ctx.${what} not available in this context — use the dispatcher, not a stubbed handler context`);
17
+ };
18
+ // Noop observability — hand back the shared fallback tracer so ctx.tracer has
19
+ // a valid Tracer shape. No allocations per call.
20
+ const noopTracer = getFallbackTracer();
21
+ export function bridgeStub(opts) {
22
+ // ctx.user is a convenience alias for event.user (see HandlerContext
23
+ // docs). Caller code expects the field; bridgeStub hands back a stub with
24
+ // anonymous default values when no user is passed explicitly. Test code
25
+ // that cares about identity passes its own SessionUser here and gets it
26
+ // back on ctx.
27
+ const stubUser = opts?.user ??
28
+ createAnonymousUser("00000000-0000-0000-0000-000000000000"); // @cast-boundary engine-bridge
29
+ return {
30
+ user: stubUser,
31
+ query: notAvailable("query"), // @cast-boundary engine-bridge
32
+ queryAs: notAvailable("queryAs"),
33
+ write: notAvailable("write"),
34
+ writeAs: notAvailable("writeAs"),
35
+ appendEvent: notAvailable("appendEvent"),
36
+ unsafeAppendEvent: notAvailable("unsafeAppendEvent"),
37
+ tryAppendEvent: notAvailable("tryAppendEvent"),
38
+ fetchForWriting: notAvailable("fetchForWriting"),
39
+ loadAggregate: notAvailable("loadAggregate"),
40
+ archiveStream: notAvailable("archiveStream"),
41
+ restoreStream: notAvailable("restoreStream"),
42
+ isStreamArchived: notAvailable("isStreamArchived"),
43
+ snapshotAggregate: notAvailable("snapshotAggregate"),
44
+ loadAggregateWithSnapshot: notAvailable("loadAggregateWithSnapshot"),
45
+ queryProjection: notAvailable("queryProjection"),
46
+ resolveAuthClaims: notAvailable("resolveAuthClaims"),
47
+ resolveActiveMembership: notAvailable("resolveActiveMembership"),
48
+ queryAsMember: notAvailable("queryAsMember"),
49
+ // Stub defaults to always-enabled — matches the dispatcher's behaviour
50
+ // when no effectiveFeatures resolver is wired (tests without toggles).
51
+ hasFeature: async () => true,
52
+ metrics: createNoopMetricsHandle(),
53
+ metricsFor: () => createNoopMetricsHandle(),
54
+ tracer: noopTracer,
55
+ // Real TzContext, not notAvailable — test code uses ctx.tz routinely,
56
+ // it isn't a "bridge" concept. Defaults to UTC.
57
+ tz: createTzContext(),
58
+ // Same reasoning as tz above — ctx.locale is always-present, not a
59
+ // bridge method. Defaults to DEFAULT_LOCALE.
60
+ locale: DEFAULT_LOCALE,
61
+ };
62
+ }