@cosmicdrift/kumiko-framework 1.0.0 → 2.0.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 (424) hide show
  1. package/README.md +9 -38
  2. package/package.json +21 -2
  3. package/src/__tests__/anonymous-access.integration.test.ts +185 -0
  4. package/src/__tests__/consumer-cli.integration.test.ts +172 -0
  5. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  6. package/src/__tests__/full-stack.integration.test.ts +1 -1
  7. package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
  8. package/src/__tests__/schema-cli.integration.test.ts +50 -0
  9. package/src/__tests__/store-table.integration.test.ts +93 -0
  10. package/src/api/__tests__/api.test.ts +373 -0
  11. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  13. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
  14. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
  15. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
  16. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
  17. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  18. package/src/api/__tests__/batch.integration.test.ts +101 -11
  19. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  20. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  21. package/src/api/__tests__/jwt.test.ts +200 -1
  22. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
  23. package/src/api/__tests__/pat-scope.test.ts +36 -0
  24. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  25. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
  26. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  27. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  28. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  29. package/src/api/__tests__/sse-broker.test.ts +57 -0
  30. package/src/api/__tests__/sse-route.test.ts +4 -0
  31. package/src/api/api-constants.ts +11 -0
  32. package/src/api/auth-middleware.ts +248 -40
  33. package/src/api/auth-routes.ts +576 -95
  34. package/src/api/index.ts +13 -4
  35. package/src/api/jwt.ts +170 -11
  36. package/src/api/pat-scope.ts +14 -0
  37. package/src/api/pii-leak-guard.ts +47 -0
  38. package/src/api/request-context.ts +3 -0
  39. package/src/api/request-id-middleware.ts +2 -0
  40. package/src/api/routes.ts +169 -2
  41. package/src/api/server.ts +112 -16
  42. package/src/api/sse-broker.ts +39 -0
  43. package/src/bun-db/__tests__/PATTERN.md +0 -1
  44. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
  45. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  46. package/src/bun-db/__tests__/write-brand.test.ts +21 -0
  47. package/src/bun-db/connection.ts +3 -3
  48. package/src/bun-db/index.ts +2 -0
  49. package/src/bun-db/query.ts +105 -32
  50. package/src/consumer-cli.ts +134 -0
  51. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  52. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  53. package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
  54. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  55. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  56. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  57. package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
  58. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  59. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  60. package/src/crypto/blind-index.ts +118 -0
  61. package/src/crypto/event-pii.ts +70 -0
  62. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  63. package/src/crypto/index.ts +67 -0
  64. package/src/crypto/kms-adapter.ts +2 -0
  65. package/src/crypto/pg-kms-adapter.ts +295 -0
  66. package/src/crypto/pii-field-encryption.ts +248 -0
  67. package/src/crypto/request-kms-cache.ts +38 -0
  68. package/src/crypto/subject-resolver.ts +91 -0
  69. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
  70. package/src/db/__tests__/blind-index.integration.test.ts +248 -0
  71. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  72. package/src/db/__tests__/collect-table-metas.test.ts +10 -10
  73. package/src/db/__tests__/config-seed.integration.test.ts +13 -5
  74. package/src/db/__tests__/dialect-instant.test.ts +1 -4
  75. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  76. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  77. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  78. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
  79. package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
  80. package/src/db/__tests__/feature-table-sources.test.ts +34 -0
  81. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
  82. package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
  83. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  84. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  85. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  86. package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
  87. package/src/db/__tests__/rebuild-marker.test.ts +13 -3
  88. package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
  89. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  90. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  91. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  92. package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
  93. package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
  94. package/src/db/api.ts +2 -2
  95. package/src/db/apply-entity-event.ts +18 -14
  96. package/src/db/blind-index-cleanup.ts +55 -0
  97. package/src/db/bun-provider.ts +2 -2
  98. package/src/db/collect-table-metas.ts +6 -7
  99. package/src/db/config-seed.ts +9 -9
  100. package/src/db/connection.ts +7 -12
  101. package/src/db/cursor.ts +1 -18
  102. package/src/db/dialect.ts +20 -26
  103. package/src/db/entity-field-encryption.ts +81 -24
  104. package/src/db/entity-table-meta-types.ts +2 -0
  105. package/src/db/entity-table-meta.ts +63 -90
  106. package/src/db/event-store-executor-context.ts +404 -0
  107. package/src/db/event-store-executor-read.ts +275 -0
  108. package/src/db/event-store-executor-write.ts +644 -0
  109. package/src/db/event-store-executor.ts +28 -1155
  110. package/src/db/feature-table-sources.ts +6 -5
  111. package/src/db/index.ts +20 -3
  112. package/src/db/located-timestamp.ts +4 -0
  113. package/src/db/migrate-generator.ts +39 -6
  114. package/src/db/migrate-runner.ts +107 -11
  115. package/src/db/pg-error.ts +9 -1
  116. package/src/db/postgres-provider.ts +2 -2
  117. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  118. package/src/db/queries/backfill-pii.ts +277 -0
  119. package/src/db/queries/ddl.ts +45 -0
  120. package/src/db/queries/event-consumer.ts +35 -2
  121. package/src/db/queries/event-store.ts +126 -19
  122. package/src/db/queries/projection-rebuild.ts +22 -8
  123. package/src/db/queries/shadow-swap.ts +183 -0
  124. package/src/db/queries/test-stack.ts +4 -30
  125. package/src/db/query-api.ts +1 -0
  126. package/src/db/query.ts +1 -0
  127. package/src/db/rebuild-marker.ts +18 -2
  128. package/src/db/reference-data.ts +2 -3
  129. package/src/db/replay-migration-sql.ts +257 -0
  130. package/src/db/schema-inspection.ts +1 -1
  131. package/src/db/table-builder.ts +102 -71
  132. package/src/db/tenant-db.ts +15 -53
  133. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  134. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  135. package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
  136. package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
  137. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
  138. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
  139. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  140. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
  141. package/src/engine/__tests__/boot-validator.test.ts +219 -15
  142. package/src/engine/__tests__/build-app-schema.test.ts +82 -0
  143. package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
  144. package/src/engine/__tests__/build-target.test.ts +3 -11
  145. package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
  146. package/src/engine/__tests__/config-helpers.test.ts +16 -0
  147. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  148. package/src/engine/__tests__/define-roles.test.ts +21 -0
  149. package/src/engine/__tests__/engine.test.ts +163 -1
  150. package/src/engine/__tests__/entity-handlers.test.ts +80 -0
  151. package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
  152. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  153. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  154. package/src/engine/__tests__/factories-time.test.ts +2 -66
  155. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  156. package/src/engine/__tests__/feature-manifest.test.ts +31 -1
  157. package/src/engine/__tests__/field-access.test.ts +23 -1
  158. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  159. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  160. package/src/engine/__tests__/nav.test.ts +86 -1
  161. package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
  162. package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
  163. package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
  164. package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
  165. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
  166. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  167. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  168. package/src/engine/__tests__/registry.test.ts +144 -0
  169. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  170. package/src/engine/__tests__/screen.test.ts +147 -1
  171. package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
  172. package/src/engine/__tests__/store-table.test.ts +229 -0
  173. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  174. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
  175. package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
  176. package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
  177. package/src/engine/boot-validator/action-wiring.ts +149 -0
  178. package/src/engine/boot-validator/boot-check.ts +21 -0
  179. package/src/engine/boot-validator/config-deps.ts +40 -4
  180. package/src/engine/boot-validator/entity-handler.ts +20 -21
  181. package/src/engine/boot-validator/entity-list-screens.ts +88 -0
  182. package/src/engine/boot-validator/gdpr-storage.ts +0 -20
  183. package/src/engine/boot-validator/i18n-keys.ts +58 -4
  184. package/src/engine/boot-validator/index.ts +33 -12
  185. package/src/engine/boot-validator/nav.ts +125 -0
  186. package/src/engine/boot-validator/pii-retention.ts +95 -10
  187. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
  188. package/src/engine/boot-validator/workspaces.ts +68 -0
  189. package/src/engine/build-app-schema.ts +16 -0
  190. package/src/engine/build-config-feature-schema.ts +44 -7
  191. package/src/engine/codemod/pipeline-codemod.ts +5 -5
  192. package/src/engine/config-helpers.ts +15 -0
  193. package/src/engine/constants.ts +32 -6
  194. package/src/engine/create-app.ts +11 -0
  195. package/src/engine/define-feature.ts +95 -947
  196. package/src/engine/define-handler.ts +28 -94
  197. package/src/engine/define-workflow.ts +1 -1
  198. package/src/engine/effective-features.ts +12 -2
  199. package/src/engine/entity-handlers.ts +76 -11
  200. package/src/engine/extensions/tenant-data.ts +19 -0
  201. package/src/engine/extensions/user-data.ts +29 -2
  202. package/src/engine/factories.ts +8 -49
  203. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
  204. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  205. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  206. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  207. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  208. package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
  209. package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
  210. package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
  211. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
  212. package/src/engine/feature-ast/extractors/events.ts +322 -0
  213. package/src/engine/feature-ast/extractors/handlers.ts +234 -0
  214. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  215. package/src/engine/feature-ast/extractors/index.ts +34 -31
  216. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  217. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  218. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  219. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  220. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  221. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  222. package/src/engine/feature-ast/index.ts +2 -4
  223. package/src/engine/feature-ast/parse.ts +130 -23
  224. package/src/engine/feature-ast/patch.ts +39 -50
  225. package/src/engine/feature-ast/patcher.ts +37 -38
  226. package/src/engine/feature-ast/patterns.ts +50 -62
  227. package/src/engine/feature-ast/render.ts +67 -44
  228. package/src/engine/feature-builder-state.ts +168 -0
  229. package/src/engine/feature-config-events-jobs.ts +403 -0
  230. package/src/engine/feature-entity-handlers.ts +208 -0
  231. package/src/engine/feature-manifest.ts +2 -1
  232. package/src/engine/feature-ui-extensions.ts +500 -0
  233. package/src/engine/field-access.ts +13 -2
  234. package/src/engine/field-helpers.ts +31 -0
  235. package/src/engine/handler-helpers.ts +26 -0
  236. package/src/engine/hook-helpers.ts +16 -0
  237. package/src/engine/index.ts +23 -7
  238. package/src/engine/membership-roles.ts +13 -0
  239. package/src/engine/object-form.ts +27 -0
  240. package/src/engine/ownership.ts +26 -79
  241. package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
  242. package/src/engine/pattern-library/library.ts +44 -1152
  243. package/src/engine/pattern-library/mixed-schemas.ts +450 -0
  244. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  245. package/src/engine/pattern-library/shared-fields.ts +75 -0
  246. package/src/engine/pattern-library/static-schemas.ts +456 -0
  247. package/src/engine/pipeline.ts +6 -11
  248. package/src/engine/registry-facade.ts +362 -0
  249. package/src/engine/registry-ingest.ts +478 -0
  250. package/src/engine/registry-state.ts +388 -0
  251. package/src/engine/registry-validate.ts +642 -0
  252. package/src/engine/registry.ts +78 -1658
  253. package/src/engine/run-pipeline.ts +1 -1
  254. package/src/engine/schema-builder.ts +1 -0
  255. package/src/engine/screen-helpers.ts +54 -0
  256. package/src/engine/soft-delete-cleanup.ts +6 -2
  257. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  258. package/src/engine/steps/_duration-utils.ts +2 -0
  259. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  260. package/src/engine/tier-resolver-extension.ts +3 -2
  261. package/src/engine/types/config.ts +2 -482
  262. package/src/engine/types/define-handler.ts +2 -0
  263. package/src/engine/types/entity-handlers.ts +2 -0
  264. package/src/engine/types/event-type-map.ts +1 -37
  265. package/src/engine/types/feature.ts +2 -972
  266. package/src/engine/types/fields.ts +2 -675
  267. package/src/engine/types/handlers.ts +2 -774
  268. package/src/engine/types/hooks.ts +2 -184
  269. package/src/engine/types/http-route.ts +1 -54
  270. package/src/engine/types/identifiers.ts +1 -47
  271. package/src/engine/types/index.ts +86 -40
  272. package/src/engine/types/nav.ts +2 -63
  273. package/src/engine/types/ownership.ts +2 -0
  274. package/src/engine/types/projection.ts +2 -138
  275. package/src/engine/types/relations.ts +1 -51
  276. package/src/engine/types/screen.ts +2 -574
  277. package/src/engine/types/step.ts +2 -334
  278. package/src/engine/types/target-ref.ts +1 -21
  279. package/src/engine/types/tree-node.ts +1 -132
  280. package/src/engine/types/workspace.ts +2 -49
  281. package/src/engine/validate-projection-allowlist.ts +6 -6
  282. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
  283. package/src/entrypoint/index.ts +49 -6
  284. package/src/errors/classes.ts +22 -1
  285. package/src/errors/field-issue.ts +0 -3
  286. package/src/errors/index.ts +1 -1
  287. package/src/errors/write-error-info.ts +10 -22
  288. package/src/es-ops/README.md +1 -1
  289. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  290. package/src/es-ops/context.ts +4 -2
  291. package/src/es-ops/types.ts +8 -1
  292. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  293. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  294. package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
  295. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  296. package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
  297. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
  298. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  299. package/src/event-store/admin-api.ts +11 -4
  300. package/src/event-store/errors.ts +2 -35
  301. package/src/event-store/event-store.ts +64 -78
  302. package/src/event-store/events-schema.ts +11 -13
  303. package/src/event-store/index.ts +18 -3
  304. package/src/event-store/rebuild-dead-letter.ts +111 -0
  305. package/src/event-store/snapshot.ts +63 -40
  306. package/src/event-store/types.ts +2 -0
  307. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  308. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  309. package/src/files/__tests__/files.integration.test.ts +24 -0
  310. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  311. package/src/files/__tests__/local-provider.test.ts +31 -0
  312. package/src/files/__tests__/provider-resolver.test.ts +70 -0
  313. package/src/files/__tests__/write-stream.test.ts +13 -0
  314. package/src/files/file-handle.ts +2 -19
  315. package/src/files/file-ref-entity.ts +2 -2
  316. package/src/files/file-routes.ts +13 -0
  317. package/src/files/index.ts +1 -1
  318. package/src/files/local-provider.ts +27 -8
  319. package/src/files/provider-resolver.ts +31 -20
  320. package/src/files/types.ts +13 -55
  321. package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
  322. package/src/i18n/required-surface-keys.ts +120 -1
  323. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  324. package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
  325. package/src/jobs/job-runner.ts +82 -12
  326. package/src/logging/types.ts +1 -7
  327. package/src/migrations/pending-rebuilds.ts +1 -1
  328. package/src/observability/__tests__/observability.integration.test.ts +4 -1
  329. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  330. package/src/observability/index.ts +2 -0
  331. package/src/observability/noop-provider.ts +0 -9
  332. package/src/observability/recording-tracer.ts +0 -12
  333. package/src/observability/standard-metrics.ts +64 -2
  334. package/src/observability/types/index.ts +1 -29
  335. package/src/observability/types/metric.ts +1 -56
  336. package/src/observability/types/provider.ts +1 -32
  337. package/src/observability/types/span.ts +1 -64
  338. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  339. package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
  340. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  341. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  342. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
  343. package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
  344. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  345. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
  346. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  347. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  348. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
  349. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  350. package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
  351. package/src/pipeline/dispatch-batch.ts +187 -0
  352. package/src/pipeline/dispatch-query.ts +165 -0
  353. package/src/pipeline/dispatch-shared.ts +826 -0
  354. package/src/pipeline/dispatch-stream.ts +90 -0
  355. package/src/pipeline/dispatch-write.ts +451 -0
  356. package/src/pipeline/dispatcher-utils.ts +1 -1
  357. package/src/pipeline/dispatcher.ts +44 -1372
  358. package/src/pipeline/entity-cache.ts +2 -33
  359. package/src/pipeline/event-consumer-state.ts +30 -2
  360. package/src/pipeline/event-dispatcher-admin.ts +293 -0
  361. package/src/pipeline/event-dispatcher-delivery.ts +305 -0
  362. package/src/pipeline/event-dispatcher.ts +85 -542
  363. package/src/pipeline/index.ts +2 -0
  364. package/src/pipeline/msp-rebuild.ts +42 -3
  365. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  366. package/src/pipeline/projection-rebuild.ts +105 -3
  367. package/src/pipeline/system-hooks.ts +144 -1
  368. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  369. package/src/rate-limit/resolver.ts +16 -32
  370. package/src/schema-cli.ts +76 -16
  371. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
  372. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  373. package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
  374. package/src/search/index.ts +6 -0
  375. package/src/search/meilisearch-adapter.ts +13 -12
  376. package/src/search/reindex-entity.ts +177 -0
  377. package/src/search/types.ts +1 -39
  378. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  379. package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
  380. package/src/secrets/__tests__/envelope.test.ts +1 -1
  381. package/src/secrets/dek-cache.ts +26 -5
  382. package/src/secrets/envelope-cipher.ts +53 -0
  383. package/src/secrets/envelope.ts +5 -3
  384. package/src/secrets/index.ts +13 -1
  385. package/src/secrets/stored-envelope.ts +46 -0
  386. package/src/secrets/types.ts +2 -162
  387. package/src/stack/__tests__/event-collector.test.ts +42 -0
  388. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
  389. package/src/stack/db.ts +2 -1
  390. package/src/stack/push-entity-projection-tables.ts +4 -3
  391. package/src/stack/redis.ts +8 -0
  392. package/src/stack/request-helper.ts +38 -2
  393. package/src/stack/table-helpers.ts +6 -4
  394. package/src/stack/test-stack.ts +249 -131
  395. package/src/testing/__tests__/late-bound.test.ts +32 -0
  396. package/src/testing/__tests__/wait-for.test.ts +59 -0
  397. package/src/testing/boot-validator-fixture.ts +121 -0
  398. package/src/testing/e2e-generator.ts +8 -0
  399. package/src/testing/file-provider-contract.ts +104 -0
  400. package/src/testing/handler-context.ts +3 -1
  401. package/src/testing/index.ts +5 -0
  402. package/src/testing/late-bound.ts +5 -3
  403. package/src/testing/mutable-master-key-provider.ts +29 -3
  404. package/src/testing/wait-for.ts +3 -0
  405. package/src/testing/without-ambient-temporal.ts +14 -0
  406. package/src/time/__tests__/tz-dateline.test.ts +8 -10
  407. package/src/time/geo-tz.ts +1 -32
  408. package/src/time/index.ts +1 -0
  409. package/src/time/legacy-date.ts +18 -0
  410. package/src/time/polyfill.ts +21 -38
  411. package/src/time/tz-context.ts +44 -85
  412. package/src/ui-types/app-schema.ts +12 -0
  413. package/src/ui-types/index.ts +20 -7
  414. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  415. package/src/utils/safe-json.ts +13 -1
  416. package/src/__tests__/raw-table.integration.test.ts +0 -116
  417. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  418. package/src/db/__tests__/encryption.test.ts +0 -39
  419. package/src/db/encryption.ts +0 -39
  420. package/src/db/row-helpers.ts +0 -4
  421. package/src/engine/__tests__/raw-table.test.ts +0 -150
  422. package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
  423. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
  424. package/src/engine/feature-ast/extractors/round4.ts +0 -1366
@@ -1,118 +1,24 @@
1
- import { requestContext } from "../api/request-context";
2
- import type { DbConnection, DbRow, DbTx } from "../db/connection";
3
- import { selectRowForUpdateById } from "../db/queries/entity-read";
4
- import { asEntityTableMeta, selectMany, transaction } from "../db/query";
5
- import { buildEntityTable, toSnakeCase } from "../db/table-builder";
6
- import { createTenantDb } from "../db/tenant-db";
7
- import { hasAccess } from "../engine/access";
8
- import { checkWriteFieldRoles, filterReadFields } from "../engine/field-access";
9
- import { defineTransitions, guardTransition } from "../engine/state-machine";
1
+ import type { SseBroker } from "../api/sse-broker";
2
+ import type { buildEntityTable } from "../db/table-builder";
3
+ import type { defineTransitions } from "../engine/state-machine";
10
4
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
11
- import type {
12
- AggregateStreamHandle,
13
- AppContext,
14
- AppendEventArgs,
15
- AppendEventFn,
16
- AuthClaimsContext,
17
- DeleteContext,
18
- FetchForWritingArgs,
19
- HandlerContext,
20
- JobRunnerRef,
21
- Registry,
22
- SaveContext,
23
- SessionUser,
24
- WriteResult,
25
- } from "../engine/types";
26
- import { HookPhases } from "../engine/types";
27
- import type { TenantId } from "../engine/types/identifiers";
28
- import { createFileContext } from "../files/file-handle";
29
- import { createFallbackLogger } from "../logging/utils";
5
+ import type { AppContext, JobRunnerRef, Registry, SessionUser, WriteResult } from "../engine/types";
6
+ import { reraiseAsKumikoError } from "../errors";
7
+ import { getFallbackMeter, getFallbackTracer, registerStandardMetrics } from "../observability";
8
+ import { runBatch, unwrapSingle } from "./dispatch-batch";
9
+ import { executeQuery } from "./dispatch-query";
10
+ import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared";
11
+ import { resolveAuthClaimsFn } from "./dispatch-shared";
12
+ import { executeStream } from "./dispatch-stream";
13
+ import { type HandlerType, resolveType } from "./dispatcher-utils";
14
+ import type { IdempotencyGuard } from "./idempotency";
15
+ import type { LifecycleHooks } from "./lifecycle-pipeline";
30
16
 
31
17
  // Re-export for callers that reach for dispatcher-adjacent types (tests,
32
18
  // HTTP-layer stubs) — dispatch consumes these, grouping the type-surface
33
19
  // here keeps imports single-source.
34
20
  export type { WriteResult } from "../engine/types";
35
-
36
- import { runValidation } from "../engine/validation";
37
- import {
38
- AccessDeniedError,
39
- FeatureDisabledError,
40
- FrameworkReasons,
41
- InternalError,
42
- isKumikoError,
43
- NotFoundError,
44
- reraiseAsKumikoError,
45
- toWriteErrorInfo,
46
- ValidationError,
47
- VersionConflictError,
48
- validationErrorFromZod,
49
- type WriteErrorInfo,
50
- writeFailure,
51
- } from "../errors";
52
- import {
53
- archiveStream as archiveStreamHelper,
54
- isStreamArchived,
55
- restoreStream as restoreStreamHelper,
56
- } from "../event-store/archive";
57
- import {
58
- getStreamVersion,
59
- loadAggregate,
60
- loadAggregateAsOf,
61
- type StoredEvent,
62
- } from "../event-store/event-store";
63
- import {
64
- type LoadAggregateWithSnapshotResult,
65
- loadAggregateWithSnapshot,
66
- type SnapshotReducer,
67
- saveSnapshot,
68
- } from "../event-store/snapshot";
69
- import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster";
70
- import {
71
- createMetricsHandle,
72
- createNoopMetricsHandle,
73
- emitDispatcherError,
74
- emitDispatcherHandler,
75
- getFallbackMeter,
76
- getFallbackTracer,
77
- registerStandardMetrics,
78
- } from "../observability";
79
- import { buildBucketKey } from "../rate-limit";
80
- import { assertNoSecretLeak } from "../secrets";
81
- import { createTzContext } from "../time";
82
- import { parseJsonSafe } from "../utils/safe-json";
83
- import { appendDomainEventCore } from "./append-event-core";
84
- import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver";
85
- import {
86
- type AfterCommitHook,
87
- BatchRollback,
88
- describeShape,
89
- dispatcherSpanAttributes,
90
- extractNestedSpecs,
91
- type HandlerType,
92
- isFailedWriteResult,
93
- isLifecycleResult,
94
- isWriteResultShape,
95
- prefixValidationPath,
96
- resolveType,
97
- wrapToKumiko,
98
- } from "./dispatcher-utils";
99
- import type { IdempotencyGuard } from "./idempotency";
100
- import type { LifecycleHooks } from "./lifecycle-pipeline";
101
- import { runProjections } from "./projections-runner";
102
-
103
- export type BatchCommand = {
104
- readonly type: string;
105
- readonly payload: unknown;
106
- };
107
-
108
- export type BatchResult =
109
- | { readonly isSuccess: true; readonly results: readonly WriteResult[] }
110
- | {
111
- readonly isSuccess: false;
112
- readonly error: WriteErrorInfo;
113
- readonly failedIndex: number;
114
- readonly results: readonly WriteResult[];
115
- };
21
+ export type { BatchCommand, BatchResult } from "./dispatch-shared";
116
22
 
117
23
  export type DispatcherOptions = {
118
24
  idempotency?: IdempotencyGuard;
@@ -138,6 +44,10 @@ export type DispatcherOptions = {
138
44
  // event-skips and a confusing operator-UI — the framework cannot
139
45
  // enforce this contract, but the recipe-test pins the convention.
140
46
  effectiveFeatures?: EffectiveFeaturesResolver;
47
+ // In-memory SSE broker — dispatch-stream.ts subscribes to a stream's
48
+ // user-scoped access-invalidation channel on it. Absent in setups without
49
+ // SSE wired up (dispatch-stream then just skips the subscription).
50
+ sseBroker?: SseBroker;
141
51
  };
142
52
 
143
53
  export type Dispatcher = {
@@ -148,6 +58,10 @@ export type Dispatcher = {
148
58
  requestId?: string,
149
59
  ): Promise<WriteResult>;
150
60
  query(type: HandlerType, payload: unknown, user: SessionUser): Promise<unknown>;
61
+ // AsyncGenerator, not Promise — gates (feature/rate-limit/access/
62
+ // validation) fire on the consumer's first `.next()` pull, not on this
63
+ // call, since they live inside the underlying async function*.
64
+ stream(type: HandlerType, payload: unknown, user: SessionUser): AsyncGenerator<unknown>;
151
65
  command(type: HandlerType, payload: unknown, user: SessionUser): Promise<void>;
152
66
  // Atomic multi-command write: all commands run in a single DB transaction.
153
67
  // On any failure, the transaction rolls back and afterCommit hooks do NOT fire.
@@ -173,1291 +87,49 @@ export function createDispatcher(
173
87
  context: AppContext,
174
88
  options: DispatcherOptions = {},
175
89
  ): Dispatcher {
176
- const { idempotency, lifecycle, jobRunner, effectiveFeatures } = options;
177
-
178
- // Narrowing-helper: AppContext.db ist DbConnection|TenantDb|undefined. Die
179
- // dispatch-Pfade brauchen DbConnection (oder DbTx aus Caller-Scope) für
180
- // appendEvent/projection-writes; TenantDb-Branch wird hier ausgeschlossen.
181
- function resolveDbSource(tx: DbTx | undefined): DbConnection | DbTx | undefined {
182
- return tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
183
- }
90
+ const { idempotency, lifecycle, jobRunner, effectiveFeatures, sseBroker } = options;
184
91
 
185
92
  // Pre-build tables and transition maps for auto-guard (avoid per-request allocation)
186
93
  const tableCache = new Map<string, ReturnType<typeof buildEntityTable>>();
187
94
  const transitionCache = new Map<string, ReturnType<typeof defineTransitions>>();
188
95
 
189
- function getTable(entityName: string): ReturnType<typeof buildEntityTable> | undefined {
190
- if (tableCache.has(entityName)) return tableCache.get(entityName);
191
- const entity = registry.getEntity(entityName);
192
- if (!entity) return undefined;
193
- const table = buildEntityTable(entityName, entity, {
194
- relations: registry.getRelations(entityName),
195
- });
196
- tableCache.set(entityName, table);
197
- return table;
198
- }
199
-
200
- function getTransitions(args: {
201
- entityName: string;
202
- fieldName: string;
203
- map: Record<string, readonly string[]>;
204
- }): ReturnType<typeof defineTransitions> {
205
- // Scope by entity — `fieldName` alone collides across entities (e.g. both
206
- // `invoice.status` and `driverOrder.status` exist with different maps),
207
- // which would apply the wrong transition rules to whichever entity arrives
208
- // second.
209
- const key = `${args.entityName}:${args.fieldName}`;
210
- const cached = transitionCache.get(key);
211
- if (cached) return cached;
212
- const transitions = defineTransitions(args.map);
213
- transitionCache.set(key, transitions);
214
- return transitions;
215
- }
216
-
217
- // ctx.appendEvent — append a domain event onto a specific aggregate stream
218
- // in the current tx, then fire matching inline projections. Core logic
219
- // lives in appendDomainEventCore; this wrapper just locates dbSource +
220
- // stringifies the SessionUser id for the shared helper.
221
- async function appendDomainEvent(
222
- args: AppendEventArgs,
223
- user: SessionUser,
224
- tx: DbTx | undefined,
225
- callerFeature: string | undefined,
226
- ): Promise<void> {
227
- const dbSource = resolveDbSource(tx);
228
- if (!dbSource) {
229
- throw new InternalError({
230
- message: `ctx.appendEvent("${args.type}") requires a database connection — none is configured.`,
231
- });
232
- }
233
- await appendDomainEventCore(
234
- {
235
- registry,
236
- db: dbSource,
237
- tenantId: user.tenantId,
238
- userId: String(user.id),
239
- callSiteLabel: "ctx.appendEvent",
240
- callerFeature,
241
- },
242
- args,
243
- );
244
- }
245
-
246
- function buildHandlerContext(
247
- type: string,
248
- user: SessionUser,
249
- tx?: DbTx,
250
- afterCommitHooks?: AfterCommitHook[],
251
- includeDeleted?: boolean,
252
- ): HandlerContext {
253
- const isSystem = registry.isHandlerSystemScoped(type);
254
- // The outer dispatcher receives a DbConnection from the server/stack;
255
- // AppContext's `db` union also allows TenantDb (for downstream hook calls),
256
- // but at this point we're the root of the pipeline — cast is safe.
257
- const dbSource = resolveDbSource(tx);
258
- const reqCtx = requestContext.get();
259
- const db = dbSource
260
- ? createTenantDb(
261
- dbSource,
262
- user.tenantId,
263
- isSystem ? "system" : "tenant",
264
- context.tracer,
265
- context.meter,
266
- // Propagate the request's AbortSignal so every TenantDb query
267
- // throws when the client has disconnected — handlers with many
268
- // sequential queries skip the rest of the chain instead of
269
- // burning DB-CPU for results no one reads.
270
- reqCtx?.signal,
271
- )
272
- : undefined;
273
- const log = context.log?.child({
274
- handler: type,
275
- tenantId: user.tenantId,
276
- userId: user.id,
277
- ...(reqCtx && { requestId: reqCtx.requestId }),
278
- });
279
- const notify = context._notifyFactory ? context._notifyFactory(user, user.tenantId) : undefined;
280
- // Mirror notify: only built when the config feature wired its factory.
281
- const config =
282
- context._configAccessorFactory && db
283
- ? context._configAccessorFactory({
284
- user: { id: user.id, tenantId: user.tenantId },
285
- db,
286
- secrets: context.secrets,
287
- })
288
- : undefined;
289
- // ctx.files resolved per-tenant through file-foundation (lazy — the
290
- // provider is only resolved when a handle actually does I/O). Boot wires
291
- // _fileProviderResolver when a file-provider plugin is mounted; falls back
292
- // to a statically-injected context.files (tests).
293
- const fileResolver = context._fileProviderResolver;
294
- const files = fileResolver
295
- ? createFileContext(() => fileResolver(user.tenantId))
296
- : context.files;
297
-
298
- // Observability — feature-bound metrics handle, so ctx.metrics.inc("foo")
299
- // resolves to kumiko_<feature>_foo. Unknown feature falls back to noop
300
- // so legacy internal handlers don't crash.
301
- const tracer = context.tracer ?? getFallbackTracer();
302
- const meter = context.meter;
303
- const featureName = registry.getHandlerFeature(type);
304
- const metrics =
305
- meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
306
-
307
- // Cross-feature bridge. Queries and writes invoked through ctx.* share:
308
- // - the current transaction (tx) — nested writes roll back with the parent
309
- // - the current afterCommitHooks sink — deferred side-effects fire once
310
- // when the outermost transaction commits
311
- // `queryAs` / `writeAs` let a handler explicitly switch identity
312
- // (e.g. system-privileged lookups that bypass field-access read filters).
313
- const bridgeSink = afterCommitHooks ?? [];
314
- const bridge = {
315
- query: (targetType: string, payload: unknown) => executeQuery(targetType, payload, user, tx), // @wrapper-known semantic-alias
316
- queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
317
- executeQuery(targetType, payload, asUser, tx), // @wrapper-known semantic-alias
318
- write: async (targetType: string, payload: unknown) => {
319
- const res = await executeWrite(targetType, payload, user, tx, bridgeSink);
320
- return res;
321
- },
322
- writeAs: async (asUser: SessionUser, targetType: string, payload: unknown) => {
323
- const res = await executeWrite(targetType, payload, asUser, tx, bridgeSink);
324
- return res;
325
- },
326
- // Strict + unsafe share the same runtime — only the type-surface
327
- // differs. The strict signature is what's exposed to typed callers;
328
- // unsafe is the explicit escape-hatch for runtime-pluggable events.
329
- appendEvent: (async (args: AppendEventArgs) => {
330
- await appendDomainEvent(args, user, tx, registry.getHandlerFeature(type));
331
- }) as AppendEventFn, // @cast-boundary engine-bridge
332
- unsafeAppendEvent: async (args: AppendEventArgs) => {
333
- await appendDomainEvent(args, user, tx, registry.getHandlerFeature(type));
334
- },
335
- fetchForWriting: async (args: FetchForWritingArgs): Promise<AggregateStreamHandle> => {
336
- const dbSource = resolveDbSource(tx);
337
- if (!dbSource) {
338
- throw new InternalError({
339
- message: `ctx.fetchForWriting("${args.aggregateId}") requires a database connection — none is configured.`,
340
- });
341
- }
342
- // Stream-version authoritative (same policy as CRUD executor + Block 0).
343
- // A single SELECT MAX(version) is cheaper than loading the full stream
344
- // when the caller just wants to append — but most callers also want
345
- // the events (business-rule checks), so fetch both in parallel.
346
- const [storedEvents, fetchedVersion] = await Promise.all([
347
- loadAggregate(dbSource, args.aggregateId, user.tenantId),
348
- getStreamVersion(dbSource, args.aggregateId, user.tenantId),
349
- ]);
350
- const events = await upcastStoredEvents(storedEvents, registry.getEventUpcasters(), {
351
- db: dbSource,
352
- tenantId: user.tenantId,
353
- });
354
-
355
- // Optimistic concurrency: if the caller knows the version they
356
- // worked against (e.g. from a prior read-model row) and the stream
357
- // has moved on, fail fast before any downstream work.
358
- if (args.expectedVersion !== undefined && args.expectedVersion !== fetchedVersion) {
359
- throw new VersionConflictError({
360
- entityId: args.aggregateId,
361
- expectedVersion: args.expectedVersion,
362
- currentVersion: fetchedVersion,
363
- });
364
- }
365
-
366
- // Handle's internal version bumps on every appendOne so multiple
367
- // appends in a row stay in order without re-reading the DB.
368
- let handleVersion = fetchedVersion;
369
- const appendOne = async (appendArgs: {
370
- readonly type: string;
371
- readonly payload: unknown;
372
- }): Promise<void> => {
373
- await appendDomainEvent(
374
- {
375
- aggregateId: args.aggregateId,
376
- aggregateType: args.aggregateType,
377
- type: appendArgs.type,
378
- payload: appendArgs.payload,
379
- },
380
- user,
381
- tx,
382
- registry.getHandlerFeature(type),
383
- );
384
- handleVersion += 1;
385
- };
386
-
387
- return {
388
- events,
389
- get version() {
390
- return handleVersion;
391
- },
392
- appendOne,
393
- };
394
- },
395
- loadAggregate: async (
396
- aggregateId: string,
397
- loadOptions?: { readonly asOf?: Temporal.Instant },
398
- ): Promise<readonly StoredEvent[]> => {
399
- const dbSource = resolveDbSource(tx);
400
- if (!dbSource) {
401
- throw new InternalError({
402
- message: `ctx.loadAggregate("${aggregateId}") requires a database connection — none is configured.`,
403
- });
404
- }
405
- const events = loadOptions?.asOf
406
- ? await loadAggregateAsOf(dbSource, aggregateId, user.tenantId, loadOptions.asOf)
407
- : await loadAggregate(dbSource, aggregateId, user.tenantId);
408
- return upcastStoredEvents(events, registry.getEventUpcasters(), {
409
- db: dbSource,
410
- tenantId: user.tenantId,
411
- });
412
- },
413
- archiveStream: async (
414
- aggregateId: string,
415
- archiveArgs: { readonly aggregateType: string; readonly reason?: string },
416
- ): Promise<void> => {
417
- const dbSource = resolveDbSource(tx);
418
- if (!dbSource) {
419
- throw new InternalError({
420
- message: `ctx.archiveStream("${aggregateId}") requires a database connection — none is configured.`,
421
- });
422
- }
423
- await archiveStreamHelper(dbSource, {
424
- tenantId: user.tenantId,
425
- aggregateId,
426
- aggregateType: archiveArgs.aggregateType,
427
- archivedBy: user.id,
428
- reason: archiveArgs.reason,
429
- });
430
- },
431
- restoreStream: async (aggregateId: string): Promise<void> => {
432
- const dbSource = resolveDbSource(tx);
433
- if (!dbSource) {
434
- throw new InternalError({
435
- message: `ctx.restoreStream("${aggregateId}") requires a database connection — none is configured.`,
436
- });
437
- }
438
- await restoreStreamHelper(dbSource, user.tenantId, aggregateId);
439
- },
440
- isStreamArchived: async (aggregateId: string): Promise<boolean> => {
441
- const dbSource = resolveDbSource(tx);
442
- if (!dbSource) {
443
- throw new InternalError({
444
- message: `ctx.isStreamArchived("${aggregateId}") requires a database connection — none is configured.`,
445
- });
446
- }
447
- return isStreamArchived(dbSource, user.tenantId, aggregateId);
448
- },
449
- snapshotAggregate: async (snapshotArgs: {
450
- readonly aggregateId: string;
451
- readonly aggregateType: string;
452
- readonly version: number;
453
- readonly state: Record<string, unknown>;
454
- }): Promise<void> => {
455
- const dbSource = resolveDbSource(tx);
456
- if (!dbSource) {
457
- throw new InternalError({
458
- message: `ctx.snapshotAggregate("${snapshotArgs.aggregateId}") requires a database connection — none is configured.`,
459
- });
460
- }
461
- await saveSnapshot(dbSource, {
462
- aggregateId: snapshotArgs.aggregateId,
463
- tenantId: user.tenantId,
464
- aggregateType: snapshotArgs.aggregateType,
465
- version: snapshotArgs.version,
466
- state: snapshotArgs.state,
467
- });
468
- },
469
- loadAggregateWithSnapshot: async <TState extends Record<string, unknown>>(
470
- aggregateId: string,
471
- reducer: SnapshotReducer<TState>,
472
- initial: TState,
473
- ): Promise<LoadAggregateWithSnapshotResult<TState>> => {
474
- const dbSource = resolveDbSource(tx);
475
- if (!dbSource) {
476
- throw new InternalError({
477
- message: `ctx.loadAggregateWithSnapshot("${aggregateId}") requires a database connection — none is configured.`,
478
- });
479
- }
480
- // Upcaster-aware: pass an upcastEvent callback so loadAggregateWithSnapshot
481
- // walks every delta through the registered chain before invoking the
482
- // user's (sync) reducer. Async upcasters (DB-enrichment) are awaited
483
- // inside loadAggregateWithSnapshot — feature authors never see legacy
484
- // payload shapes regardless of which load path they chose.
485
- const upcasters = registry.getEventUpcasters();
486
- const upcastCtx = { db: dbSource, tenantId: user.tenantId };
487
- return loadAggregateWithSnapshot<TState>(
488
- dbSource,
489
- aggregateId,
490
- user.tenantId,
491
- reducer,
492
- initial,
493
- { upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx) }, // @wrapper-known semantic-alias
494
- );
495
- },
496
- queryProjection: async <T = Record<string, unknown>>(
497
- qualifiedName: string,
498
- queryOptions?: { readonly unsafeAllTenants?: boolean },
499
- ): Promise<readonly T[]> => {
500
- // queryProjection works against both single-stream and multi-stream
501
- // projections. MSPs without a table cannot be queried — those are
502
- // side-effect-only consumers (no state to read back).
503
- const singleProj = registry.getAllProjections().get(qualifiedName);
504
- const mspProj = registry.getAllMultiStreamProjections().get(qualifiedName);
505
- const projTable = singleProj?.table ?? mspProj?.table;
506
- if (!projTable) {
507
- const singleNames = [...registry.getAllProjections().keys()];
508
- const mspNames = [...registry.getAllMultiStreamProjections().keys()].filter(
509
- (n) => registry.getAllMultiStreamProjections().get(n)?.table,
510
- );
511
- const all = [...singleNames, ...mspNames];
512
- throw new InternalError({
513
- message:
514
- `ctx.queryProjection("${qualifiedName}") — projection not registered, or it is a ` +
515
- `table-less MSP (side-effect-only). Known queryable projections: ${all.join(", ") || "(none)"}`,
516
- });
517
- }
518
- const dbSource = resolveDbSource(tx);
519
- if (!dbSource) {
520
- throw new InternalError({
521
- message: `ctx.queryProjection("${qualifiedName}") requires a database connection — none is configured.`,
522
- });
523
- }
524
- // Introspect for a tenant_id column on the projection table. Auto-
525
- // filter keeps cross-tenant leaks out unless the handler explicitly
526
- // opts in. Works with any drizzle-table whose tenant column is named
527
- // tenantId on the JS side.
528
- const tenantCol = (projTable as Record<string, unknown>)["tenantId"];
529
- const where =
530
- tenantCol && !queryOptions?.unsafeAllTenants ? { tenantId: user.tenantId } : undefined;
531
- const rows = await selectMany<Record<string, unknown>>(dbSource, projTable, where);
532
- return rows as readonly T[]; // @cast-boundary engine-payload
533
- },
534
- // Thin pass-through: one resolve impl lives on the dispatcher, the
535
- // handler surface just forwards the call so both entry points (login
536
- // handler via ctx.resolveAuthClaims, switch-tenant route via
537
- // dispatcher.resolveAuthClaims) cannot drift.
538
- resolveAuthClaims: (claimsUser: SessionUser) => resolveAuthClaimsFn(claimsUser), // @wrapper-known semantic-alias
539
-
540
- // Feature-effective check for in-handler opt-in logic. Scope:
541
- // **current user's tenant** — for cross-tenant lookups (rare,
542
- // SysAdmin operations) read effectiveFeatures(otherTenantId) directly.
543
- // When the feature-toggles or tier-engine feature isn't wired (no
544
- // effectiveFeatures callback), always returns true — apps without
545
- // tier-cuts treat all features on.
546
- hasFeature: (featureName: string): boolean =>
547
- effectiveFeatures ? effectiveFeatures(user.tenantId).has(featureName) : true,
548
- };
549
-
550
- // Registry is always the dispatcher's registry — injecting it here lets
551
- // tests/callers pass `context` without `registry` and still get a valid
552
- // HandlerContext. The spread-then-assign order matters: anything in
553
- // `context` can be overridden, but we want the authoritative registry
554
- // from the dispatcher's own closure to win.
555
- // ctx.tz ist immer da. Tenant + User-Defaults kommen aus dem
556
- // SessionUser sobald die Felder existieren — bis dahin "UTC". Ein
557
- // app-injizierter GeoTzProvider (context.geoTzProvider) speist
558
- // ctx.tz.fromCoordinates / fromAddress.
559
- const tz = createTzContext(
560
- context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {},
561
- );
562
-
563
- return {
564
- ...context,
565
- registry,
566
- db,
567
- log,
568
- notify,
569
- ...(config && { config }),
570
- ...(files && { files }),
571
- tracer,
572
- metrics,
573
- tz,
574
- // Cancellation signal flows from the HTTP middleware via
575
- // requestContext. Conditional spread so non-HTTP entry-points
576
- // (jobs, dispatcher MSP-applies) don't get a phantom signal that
577
- // would always read aborted=false but feel meaningful.
578
- ...(reqCtx?.signal ? { signal: reqCtx.signal } : {}),
579
- // Propagate the feature-toggle resolver so the lifecycle pipeline,
580
- // MSP runner, and ctx.hasFeature all pull from the same source.
581
- ...(effectiveFeatures && { effectiveFeatures }),
582
- // ctx.user als Convenience-Alias auf event.user. Der typisch-
583
- // intuitive Pfad „der Context kennt seinen User" — ohne den
584
- // schreiben Handler `event.user.tenantId` und brechen sich die
585
- // Finger an typo-resistenten ctx.user-Patterns. Identisch zum
586
- // event.user-Wert; Identity-Switches nutzen weiterhin queryAs/writeAs.
587
- user,
588
- _userId: user.id,
589
- _tenantId: user.tenantId,
590
- _handlerType: type,
591
- ...(includeDeleted && { includeDeleted: true }),
592
- ...bridge,
593
- } as HandlerContext; // @cast-boundary engine-bridge
594
- }
595
-
596
96
  const dispatcherTracer = context.tracer ?? getFallbackTracer();
597
97
  const dispatcherMeter = context.meter ?? getFallbackMeter();
598
98
  // Ensure standard metrics exist on whatever meter we ended up with.
599
99
  // Idempotent: buildServer may have registered them already.
600
100
  registerStandardMetrics(dispatcherMeter);
601
101
 
602
- // Wrap handler execution in a dispatcher.handler span AND emit the standard
603
- // dispatcher metrics (duration + error counter). Errors are re-thrown so
604
- // control flow stays identical to the uninstrumented path.
605
- //
606
- // Writes are special-cased: executeWriteInner converts thrown handler errors
607
- // into a WriteResult with isSuccess=false (rather than letting them bubble).
608
- // We inspect the result to paint the dispatcher span + error counter on
609
- // those structural failures too — otherwise "handler threw" would only show
610
- // up when the caller forgot to use writeFailure().
611
- async function runHandlerInstrumented<T>(
612
- type: string,
613
- operation: "query" | "write",
614
- user: SessionUser,
615
- inner: () => Promise<T>,
616
- ): Promise<T> {
617
- const start = performance.now();
618
- // Outcome recorded inside the withSpan callback, emitted in finally so
619
- // success/failure/throw all hit a single metric-emit path.
620
- let success = true;
621
- let errorClass: string | undefined;
622
-
623
- try {
624
- return await dispatcherTracer.withSpan(
625
- "kumiko.dispatcher.handler",
626
- {
627
- attributes: dispatcherSpanAttributes(
628
- type,
629
- operation,
630
- user,
631
- registry.getHandlerFeature(type),
632
- ),
633
- },
634
- async (span) => {
635
- try {
636
- const result = await inner();
637
- if (operation === "write" && isFailedWriteResult(result)) {
638
- success = false;
639
- errorClass = result.error?.code ?? "UnknownError";
640
- span.setStatus("error", errorClass);
641
- }
642
- return result;
643
- } catch (error) {
644
- success = false;
645
- errorClass = error instanceof Error && error.name ? error.name : "UnknownError";
646
- throw error;
647
- }
648
- },
649
- );
650
- } finally {
651
- if (!success && errorClass) {
652
- emitDispatcherError(dispatcherMeter, { handler: type, errorClass });
653
- }
654
- emitDispatcherHandler(
655
- dispatcherMeter,
656
- { handler: type, success },
657
- (performance.now() - start) / 1000,
658
- );
659
- }
660
- }
661
-
662
- // L3 rate limit gate. Called by both query and write paths before
663
- // access-check. Reasoning:
664
- // - handler without rateLimit → no-op
665
- // - app booted without rateLimit resolver → InternalError so the
666
- // misconfig surfaces immediately, not on first 429
667
- // - bucket builder returns "skip" (e.g. ip-based but no client IP):
668
- // pass through. ip-modes are commonly used at L1/L2 middleware
669
- // where the IP comes from Hono directly; falling back to "skip"
670
- // here keeps non-HTTP entry-points (jobs, MSPs) functional.
671
- // Feature-toggle gate. Returns the error to fold into a WriteFailure in the
672
- // write path, or throws for the query path (where throws flow through the
673
- // same outer instrumentation wrapper as other dispatcher errors).
674
- //
675
- // When `effectiveFeatures` is not wired (tests, apps without feature-toggles
676
- // loaded), every handler is treated as enabled — the gate is a pure
677
- // pass-through in that common case.
678
- async function checkFeatureEnabled(
679
- qualifiedHandler: string,
680
- tenantId: TenantId,
681
- ): Promise<import("../errors").FeatureDisabledError | undefined> {
682
- if (!effectiveFeatures) return undefined;
683
- const owner = registry.getHandlerFeature(qualifiedHandler);
684
- // skip: handler without an owning feature cannot be toggled — shouldn't
685
- // happen for registry-built handlers, but guards against edge-case
686
- // runtime injections.
687
- if (!owner) return undefined;
688
- const set = effectiveFeatures(tenantId);
689
- if (set.has(owner)) return undefined;
690
- // Feature is off for the stored tier — give the live trial-gate a last
691
- // chance. Time-derived (tenant.inserted_at + window), so it can't live in
692
- // the boot-cached sync resolver; consulted only on this already-disabled
693
- // cold path, never on the hot enabled path.
694
- if (effectiveFeatures.trialGate && (await effectiveFeatures.trialGate(tenantId, owner))) {
695
- return undefined;
696
- }
697
- return new FeatureDisabledError(owner, qualifiedHandler);
698
- }
699
-
700
- async function ensureFeatureEnabled(qualifiedHandler: string, tenantId: TenantId): Promise<void> {
701
- const err = await checkFeatureEnabled(qualifiedHandler, tenantId);
702
- if (err) throw err;
703
- }
704
-
705
- async function enforceRateLimit(
706
- rateLimit: import("../engine/types").RateLimitOption | undefined,
707
- handlerName: string,
708
- user: SessionUser,
709
- ): Promise<void> {
710
- // skip: defence-in-depth — both call-sites already gate on
711
- // handler.rateLimit !== undefined, so this branch only fires
712
- // if a future caller forgets the inline check.
713
- if (!rateLimit) return;
714
- const reqCtx = requestContext.get();
715
- const bucket = buildBucketKey(rateLimit, {
716
- handlerName,
717
- user,
718
- ip: reqCtx?.ip,
719
- });
720
- // skip: ip-bucketed handler called from a non-HTTP entry point (job, seed,
721
- // MSP-apply) — no client IP to bucket on, nothing to enforce. Pass
722
- // through BEFORE requiring a resolver, so system/seed writes through
723
- // such a handler don't need a RateLimitResolver wired (the es-ops
724
- // seed dispatcher has none). L1/L2 middleware handle the HTTP-side
725
- // ip caps.
726
- if (bucket.kind === "skip") return;
727
- if (!context.rateLimit) {
728
- throw new InternalError({
729
- message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
730
- });
731
- }
732
- await context.rateLimit.enforce(bucket.key, {
733
- limit: rateLimit.limit,
734
- windowSeconds: rateLimit.windowSeconds,
735
- cost: rateLimit.cost,
736
- });
737
- }
738
-
739
- // Standalone query execution — used by the public dispatcher.query() and
740
- // by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
741
- // field-level read filters for the given user, logs the event.
742
- async function executeQuery(
743
- type: string,
744
- payload: unknown,
745
- user: SessionUser,
746
- tx?: DbTx,
747
- ): Promise<unknown> {
748
- return runHandlerInstrumented(type, "query", user, () =>
749
- executeQueryInner(type, payload, user, tx),
750
- );
751
- }
752
-
753
- async function executeQueryInner(
754
- type: string,
755
- payload: unknown,
756
- user: SessionUser,
757
- tx?: DbTx,
758
- ): Promise<unknown> {
759
- const handler = registry.getQueryHandler(type);
760
- if (!handler) throw new NotFoundError("handler", type);
761
-
762
- // Feature-toggle gate runs BEFORE rate-limit on purpose: calls to a
763
- // disabled feature must not consume the rate-limit quota — the call
764
- // never happened from the feature's perspective. Order is: lookup →
765
- // feature-gate → rate-limit → access → validation → handler.
766
- await ensureFeatureEnabled(type, user.tenantId);
767
-
768
- // Rate-limit gate runs BEFORE access-check on purpose: anonymous /
769
- // unauthorized callers must hit the cap too (otherwise the limit
770
- // would be a free probe-detector for valid credentials). The
771
- // resolver throws RateLimitError which the dispatcher's outer
772
- // wrapper turns into a 429 response. Inline-skip when the handler
773
- // didn't opt in — keeps the hot path zero-cost (no await on a
774
- // no-op promise).
775
- if (handler.rateLimit !== undefined) {
776
- await enforceRateLimit(handler.rateLimit, type, user);
777
- }
778
-
779
- // Default-deny: missing access rule is treated as "no one has access".
780
- // The registry boot-validator refuses to register handlers without one,
781
- // so in normal boots this branch shouldn't fire — the guard is belt-and-
782
- // suspenders in case a handler sneaks through (e.g. runtime injection).
783
- if (!hasAccess(user, handler.access)) {
784
- throw new AccessDeniedError({
785
- message: `access denied for ${type}`,
786
- details: { handler: type },
787
- });
788
- }
789
-
790
- const parsed = handler.schema.safeParse(payload);
791
- if (!parsed.success) {
792
- throw validationErrorFromZod(parsed.error);
793
- }
794
-
795
- // Trash opt-in rides the validated query payload: only the entity-list
796
- // schema (and custom query schemas that opt in) carries `includeDeleted`,
797
- // so other handlers never see the flag. Visibility filters still apply
798
- // downstream (see HandlerContext.includeDeleted) — safe from raw input.
799
- const includeDeleted =
800
- typeof parsed.data === "object" &&
801
- parsed.data !== null &&
802
- (parsed.data as Record<string, unknown>)["includeDeleted"] === true; // @cast-boundary validated-payload
803
- const handlerContext = buildHandlerContext(type, user, tx, undefined, includeDeleted);
804
- let result = await handler.handler({ type, payload: parsed.data, user }, handlerContext);
805
-
806
- // postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
807
- // and can merge custom-fields/computed-counts/tags/etc. Each hook is
808
- // responsible for its own field-access on values it adds (the filter
809
- // below only knows the entity's stammfields).
810
- //
811
- // Two firing-pfade kombiniert in dieser Reihenfolge:
812
- // 1. Handler-keyed hooks via r.hook("postQuery", "ns:query:list", fn)
813
- // — feuern nur für genau diesen handler
814
- // 2. Entity-keyed hooks via r.entityHook("postQuery", "property", fn)
815
- // — feuern für ALLE query-handlers des entity
816
- const entityName = registry.getHandlerEntity(type);
817
-
818
- // Handler-keyed postQuery hooks fire for any query (incl. entity-less
819
- // standalone queries like "ns:dashboard"). Entity-keyed hooks only apply
820
- // when the handler maps to an entity — so this block must NOT be gated on
821
- // entityName, or hooks on standalone queries register silently and never fire.
822
- const handlerHooks = registry.getPostQueryHooks(type);
823
- const entityHooks = entityName ? registry.getEntityPostQueryHooks(entityName) : [];
824
- const postQueryHooks = [...handlerHooks, ...entityHooks];
825
- if (postQueryHooks.length > 0 && result && typeof result === "object") {
826
- if (Array.isArray(result)) {
827
- let rows = result as Record<string, unknown>[]; // @cast-boundary engine-payload
828
- for (const hook of postQueryHooks) {
829
- const out = await hook({ entityName, rows }, handlerContext);
830
- rows = [...out.rows];
831
- }
832
- result = rows;
833
- } else if (Array.isArray((result as { rows?: unknown }).rows)) {
834
- // @cast-boundary engine-payload
835
- const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null };
836
- let rows = r.rows;
837
- for (const hook of postQueryHooks) {
838
- const out = await hook({ entityName, rows }, handlerContext);
839
- rows = [...out.rows];
840
- }
841
- result = { ...r, rows };
842
- } else {
843
- let rows: Record<string, unknown>[] = [result as Record<string, unknown>]; // @cast-boundary engine-payload
844
- for (const hook of postQueryHooks) {
845
- const out = await hook({ entityName, rows }, handlerContext);
846
- rows = [...out.rows];
847
- }
848
- // A single-object result carries exactly one row through the hook
849
- // pipeline. Returning 0 rows (effect lost) or ≥2 rows (extras
850
- // dropped) cannot be represented in the single-object response —
851
- // surface it instead of silently falling back / truncating.
852
- if (rows.length !== 1) {
853
- throw new Error(
854
- `postQuery hook on single-object result for "${type}" must return exactly one row, got ${rows.length}`,
855
- );
856
- }
857
- result = rows[0];
858
- }
859
- }
860
-
861
- // Field-level read filter — only applies to entity-bound results.
862
- const entity = entityName ? registry.getEntity(entityName) : undefined;
863
- if (entity && result && typeof result === "object") {
864
- if (Array.isArray(result)) {
865
- result = result.map((row: Record<string, unknown>) => filterReadFields(entity, row, user));
866
- } else {
867
- const resultAsDbRow = result as DbRow; // @cast-boundary engine-payload
868
- if (Array.isArray((resultAsDbRow as { rows?: unknown }).rows)) {
869
- // generic handler-result shape narrow
870
- const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null }; // @cast-boundary engine-payload
871
- result = {
872
- ...r,
873
- rows: r.rows.map((row) => filterReadFields(entity, row, user)),
874
- };
875
- } else {
876
- result = filterReadFields(entity, result as DbRow, user); // @cast-boundary engine-payload
877
- }
878
- }
879
- }
880
-
881
- // Response-guard: fail the request if a handler accidentally included
882
- // a Secret<> branded value in its return. Must run AFTER field-access
883
- // filtering so a legitimately stripped secret doesn't false-positive.
884
- assertNoSecretLeak(result);
885
- return result;
886
- }
887
-
888
- // Runs lifecycle hooks for a handler result. inTransaction hooks fire NOW
889
- // (they see the tx via ctx.db when batch/write opens a transaction).
890
- // afterCommit hooks are queued into `afterCommitHooks` for the caller to
891
- // flush after commit.
892
- async function runLifecycle(
893
- type: string,
894
- data: unknown,
895
- handlerContext: HandlerContext,
896
- afterCommitHooks: AfterCommitHook[],
897
- ): Promise<void> {
898
- if (!lifecycle) {
899
- handlerContext.log?.debug(`runLifecycle: skipping ${type} — no lifecycle pipeline`);
900
- return;
901
- }
902
- if (!isLifecycleResult(data)) {
903
- handlerContext.log?.debug(`runLifecycle: skipping ${type} — result is not a lifecycle kind`);
904
- return;
905
- }
906
- const result = data;
907
-
908
- // Projections run FIRST, inside the tx, before any user postSave/postDelete
909
- // hooks. If a projection apply() throws, the whole tx rolls back — the
910
- // event and the auto-projection row go with it. Running before the hooks
911
- // keeps projection state consistent with what the hooks observe.
912
- await runProjections(result, handlerContext);
913
-
914
- if (result.kind === "save") {
915
- await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
916
- afterCommitHooks.push(() =>
917
- lifecycle.runPostSave(type, result, handlerContext, HookPhases.afterCommit),
918
- );
919
- } else if (result.kind === "delete") {
920
- await lifecycle.runPreDelete(type, result, handlerContext);
921
- await lifecycle.runPostDelete(type, result, handlerContext, HookPhases.inTransaction);
922
- afterCommitHooks.push(() =>
923
- lifecycle.runPostDelete(type, result, handlerContext, HookPhases.afterCommit),
924
- );
925
- }
926
- }
927
-
928
- // Shared write pipeline: validates, executes handler, runs lifecycle + side effects.
929
- // Used by runBatch (which opens a transaction and flushes afterCommitHooks on commit).
930
- //
931
- // Contract:
932
- // - `tx` is the active Drizzle transaction handle (or undefined for the no-DB
933
- // fallback path used by tests without a Postgres connection).
934
- // - `afterCommitHooks` collects deferred side-effects that must only fire
935
- // after the transaction commits. The caller flushes them on commit, drops
936
- // them on rollback. executeWrite never fires them directly.
937
- async function executeWrite(
938
- type: string,
939
- payload: unknown,
940
- user: SessionUser,
941
- tx: DbTx | undefined,
942
- afterCommitHooks: AfterCommitHook[],
943
- ): Promise<WriteResult> {
944
- return runHandlerInstrumented(type, "write", user, () =>
945
- executeWriteInner(type, payload, user, tx, afterCommitHooks),
946
- );
947
- }
948
-
949
- // Nested-write orchestration (v1: depth=1, create-only, hasMany-only).
950
- //
951
- // When a parent `:create` handler's payload carries values under keys
952
- // declared as `hasMany` relations with `nestedWrite: true`, those values
953
- // are expanded into child writes: parent first (so its new id exists),
954
- // then each nested entry as a separate `<target>:create` write with the
955
- // foreign key set by the framework — never taken from the client. All of
956
- // this runs inside the caller's transaction, so a child failure rolls the
957
- // parent (and any earlier children) back together.
958
- //
959
- // This wrapper is what runBatch calls, not executeWrite. Single writes
960
- // (`dispatcher.write`) flow through runBatch as batch-of-one, so they get
961
- // nested-expansion too for free. A batch with N heterogeneous commands
962
- // can each independently carry nested-children — all still one TX.
963
- async function executeNestedWrite(
964
- type: string,
965
- payload: unknown,
966
- user: SessionUser,
967
- tx: DbTx | undefined,
968
- afterCommitHooks: AfterCommitHook[],
969
- ): Promise<WriteResult> {
970
- const nested = extractNestedSpecs(type, payload, registry);
971
- if (!nested) return executeWrite(type, payload, user, tx, afterCommitHooks);
972
-
973
- // Pre-flight client-shape checks. Merge non-array issues (collected up
974
- // front by extractNestedSpecs) with fk-injection issues into one error
975
- // so the client sees every problem in a single round-trip.
976
- //
977
- // Security rail: the client MUST NOT supply the foreign key on nested
978
- // items. The framework binds it from the parent's new id. Silent-overwrite
979
- // would mask an attempt to attach children to a different parent — fail
980
- // loud with a ValidationError carrying a client-mappable path.
981
- const issues: Array<{ path: string; code: string; i18nKey: string }> = [...nested.typeIssues];
982
- for (const spec of nested.specs) {
983
- for (let i = 0; i < spec.items.length; i++) {
984
- const item = spec.items[i];
985
- if (item && typeof item === "object" && spec.foreignKey in item) {
986
- issues.push({
987
- path: `${spec.key}.${i}.${spec.foreignKey}`,
988
- code: "unexpected_field",
989
- i18nKey: "errors.validation.unexpected_field",
990
- });
991
- }
992
- }
993
- }
994
- if (issues.length > 0) {
995
- return writeFailure(new ValidationError({ fields: issues }));
996
- }
997
-
998
- const parentResult = await executeWrite(type, nested.cleanPayload, user, tx, afterCommitHooks);
999
- if (!parentResult.isSuccess) return parentResult;
1000
-
1001
- // Handlers built on the CRUD executor return a SaveContext wrapper —
1002
- // `{ kind: "save", id, data: <row>, changes, previous, event, ... }`.
1003
- // The wrapper is load-bearing for batch-level hooks downstream (see
1004
- // flushBatchHooks), so we mutate in place: nested children land on the
1005
- // inner `data` (which mirrors the entity shape the client expects) while
1006
- // the wrapper keeps its SaveContext semantics intact for the lifecycle
1007
- // pipeline. For handlers that return a bare row (no wrapper), children
1008
- // land directly on that object.
1009
- //
1010
- // Hook-ordering note: per-entity postSave hooks already ran inside the
1011
- // parent's executeWrite call above — they never saw `tasks`, which is
1012
- // the right semantic (postSave gets the entity's own columns, not
1013
- // synthetic relation keys). A future postSaveBatch subscriber that
1014
- // enumerates columns generically WOULD see `tasks`; no such subscriber
1015
- // exists today. If you add one that iterates `Object.keys(save.data)`,
1016
- // filter by `entity.fields` membership to stay correct.
1017
- // handler-Result.data ist generic über alle Entity-Handler; nested-
1018
- // write inspiziert die shape strukturell.
1019
- const parentWrapper = parentResult.data as Record<string, unknown>; // @cast-boundary engine-payload
1020
- const parentRow = (parentWrapper["data"] ?? parentWrapper) as Record<string, unknown>; // @cast-boundary engine-payload
1021
- const parentId = parentRow["id"];
1022
- if (typeof parentId !== "string") {
1023
- return writeFailure(
1024
- new InternalError({
1025
- message: `nested-write: parent handler "${type}" returned no string "id" — cannot attach children`,
1026
- }),
1027
- );
1028
- }
1029
-
1030
- for (const spec of nested.specs) {
1031
- const subRows: Record<string, unknown>[] = [];
1032
- for (let i = 0; i < spec.items.length; i++) {
1033
- const rawItem = spec.items[i];
1034
- const itemObj = (rawItem ?? {}) as Record<string, unknown>; // @cast-boundary engine-payload
1035
- const subPayload = { ...itemObj, [spec.foreignKey]: parentId };
1036
- const subResult = await executeWrite(spec.subType, subPayload, user, tx, afterCommitHooks);
1037
- if (!subResult.isSuccess) {
1038
- return {
1039
- isSuccess: false,
1040
- error: prefixValidationPath(subResult.error, `${spec.key}.${i}`),
1041
- };
1042
- }
1043
- const subWrapper = subResult.data as Record<string, unknown>; // @cast-boundary engine-payload
1044
- const subRow = (subWrapper["data"] ?? subWrapper) as Record<string, unknown>; // @cast-boundary engine-payload
1045
- subRows.push(subRow);
1046
- }
1047
- parentRow[spec.key] = subRows;
1048
- }
1049
-
1050
- return parentResult;
1051
- }
1052
-
1053
- async function executeWriteInner(
1054
- type: string,
1055
- payload: unknown,
1056
- user: SessionUser,
1057
- tx: DbTx | undefined,
1058
- afterCommitHooks: AfterCommitHook[],
1059
- ): Promise<WriteResult> {
1060
- const handler = registry.getWriteHandler(type);
1061
- if (!handler) return writeFailure(new NotFoundError("handler", type));
1062
-
1063
- // Feature-toggle gate: disabled handlers must short-circuit before any
1064
- // rate-limit/access/validation work — see executeQueryInner comment.
1065
- const disabledErr = await checkFeatureEnabled(type, user.tenantId);
1066
- if (disabledErr) return writeFailure(disabledErr);
1067
-
1068
- // Rate-limit gate before access (same reasoning as in executeQueryInner).
1069
- // Throws RateLimitError; the outer wrapper turns it into a 429
1070
- // WriteFailure via toWriteErrorInfo. Inline-skip when no opt-in —
1071
- // hot path stays zero-cost.
1072
- if (handler.rateLimit !== undefined) {
1073
- try {
1074
- await enforceRateLimit(handler.rateLimit, type, user);
1075
- } catch (e) {
1076
- if (isKumikoError(e)) return writeFailure(e);
1077
- throw e;
1078
- }
1079
- }
1080
-
1081
- // Default-deny: missing access rule is treated as "no one has access".
1082
- // The registry boot-validator refuses to register handlers without one,
1083
- // so in normal boots this branch shouldn't fire — the guard is belt-and-
1084
- // suspenders in case a handler sneaks through (e.g. runtime injection).
1085
- if (!hasAccess(user, handler.access)) {
1086
- return writeFailure(
1087
- new AccessDeniedError({
1088
- message: `access denied for ${type}`,
1089
- details: { handler: type },
1090
- }),
1091
- );
1092
- }
1093
-
1094
- const parsed = handler.schema.safeParse(payload);
1095
- if (!parsed.success) {
1096
- return writeFailure(validationErrorFromZod(parsed.error));
1097
- }
1098
-
1099
- const hookErrors = runValidation(registry, type, parsed.data as DbRow); // @cast-boundary engine-payload
1100
- if (hookErrors) {
1101
- return writeFailure(
1102
- new ValidationError({
1103
- fields: hookErrors.map((e) => ({
1104
- path: e.field,
1105
- code: e.error,
1106
- i18nKey: `errors.validation.${e.error}`,
1107
- })),
1108
- }),
1109
- );
1110
- }
1111
-
1112
- // Field-level write access check
1113
- const entityName = registry.getHandlerEntity(type);
1114
- if (entityName) {
1115
- const entity = registry.getEntity(entityName);
1116
- if (entity) {
1117
- const fieldsToCheck = (parsed.data as DbRow)["changes"] as
1118
- | Record<string, unknown>
1119
- | undefined; // @cast-boundary engine-payload
1120
- const writePayload = fieldsToCheck ?? (parsed.data as DbRow); // @cast-boundary engine-payload
1121
- // Pre-handler check: role-only gate. Ownership-level row-match runs
1122
- // later in the executor where oldRow is loaded — that split lets
1123
- // updates with partial changes still pass the pre-handler check and
1124
- // get their full evaluation at save time.
1125
- const deniedField = checkWriteFieldRoles(entity, writePayload, user);
1126
- if (deniedField) {
1127
- return writeFailure(
1128
- new AccessDeniedError({
1129
- message: `field access denied: ${deniedField}`,
1130
- i18nKey: "errors.access.fieldDenied",
1131
- details: {
1132
- reason: FrameworkReasons.fieldAccessDenied,
1133
- field: deniedField,
1134
- handler: type,
1135
- },
1136
- }),
1137
- );
1138
- }
1139
- }
1140
- }
1141
-
1142
- const handlerContext = buildHandlerContext(type, user, tx, afterCommitHooks);
1143
-
1144
- // Auto transition guard: if entity has transitions and handler doesn't skip it
1145
- if (entityName && !handler.unsafeSkipTransitionGuard) {
1146
- const entity = registry.getEntity(entityName);
1147
- if (entity?.transitions && handlerContext.db) {
1148
- const parsedData = parsed.data as DbRow; // @cast-boundary engine-payload
1149
- const changes = (parsedData["changes"] as DbRow) ?? parsedData; // @cast-boundary engine-payload
1150
- const id = (parsedData["id"] as number) ?? undefined; // @cast-boundary engine-payload
1151
-
1152
- for (const [fieldName, transitionMap] of Object.entries(entity.transitions)) {
1153
- const newValue = changes[fieldName] as string | undefined; // @cast-boundary engine-bridge
1154
- if (!newValue || !id) continue;
1155
-
1156
- const table = getTable(entityName);
1157
- if (!table) continue;
1158
-
1159
- // SELECT FOR UPDATE inside the surrounding transaction — locks the
1160
- // row so a concurrent handler can't mutate `status` between our
1161
- // guard check and the handler's UPDATE. Without this lock the guard
1162
- // can false-pass; optimistic locking would catch it later, but with
1163
- // a less specific error. Falls back to a plain SELECT if no tx is
1164
- // active (tests without a DB connection).
1165
- const tableName = asEntityTableMeta(table)?.tableName ?? "";
1166
- const rows = tx
1167
- ? await selectRowForUpdateById(handlerContext.db, tableName, id)
1168
- : await selectMany(handlerContext.db, table, { id });
1169
- const row = rows[0];
1170
-
1171
- if (!row) continue;
1172
- // Skip guard for soft-deleted rows — they shouldn't be transitioning
1173
- // at all; a handler that wants to move a deleted row should use
1174
- // unsafeSkipTransitionGuard or restore first.
1175
- const rowAsRow = row as DbRow; // @cast-boundary engine-payload
1176
- const isDeleted = rowAsRow["isDeleted"] ?? rowAsRow["is_deleted"];
1177
- if (entity.softDelete && isDeleted === true) {
1178
- continue;
1179
- }
1180
- const currentValue =
1181
- ((row as DbRow)[fieldName] as string | undefined) ??
1182
- ((row as DbRow)[toSnakeCase(fieldName)] as string); // @cast-boundary engine-bridge
1183
- guardTransition(
1184
- getTransitions({ entityName, fieldName, map: transitionMap }),
1185
- currentValue,
1186
- newValue,
1187
- );
1188
- }
1189
- }
1190
- }
1191
-
1192
- // The handler itself plus the lifecycle pipeline run under the same
1193
- // try-wrapper: any KumikoError bubbles up as a typed WriteErrorInfo, any
1194
- // other throw gets wrapped in InternalError so the Prod contract holds
1195
- // ("unexpected throw → 500 with sanitized body"). We intentionally do NOT
1196
- // catch further out (runBatch still sees these as exceptions via
1197
- // writeFailure, not via a rethrow) so batches roll back naturally.
1198
- let result: WriteResult;
1199
- try {
1200
- result = await handler.handler({ type, payload: parsed.data, user }, handlerContext);
1201
- } catch (e) {
1202
- return writeFailure(wrapToKumiko(e));
1203
- }
1204
-
1205
- // Runtime shape-guard. The compile-time type WriteHandlerFn already
1206
- // requires `Promise<WriteResult>`, but custom handlers wired through
1207
- // r.writeHandler(name, schema, fn, opts) sometimes slip through with
1208
- // `Promise<{id: string}>` — TypeScript misses it under structural-
1209
- // widening, the dispatcher then reads .isSuccess on undefined and
1210
- // crashes obscure. Surface a clear actionable message instead.
1211
- if (!isWriteResultShape(result)) {
1212
- return writeFailure(
1213
- new InternalError({
1214
- message:
1215
- `Write handler "${type}" returned an invalid shape. Expected WriteResult ` +
1216
- `({ isSuccess: true, data: ... } or writeFailure(err)), got ${describeShape(result)}. ` +
1217
- `Use defineWriteHandler() or wrap the return as { isSuccess: true as const, data: ... }.`,
1218
- }),
1219
- );
1220
- }
1221
-
1222
- if (result.isSuccess) {
1223
- try {
1224
- await runLifecycle(type, result.data, handlerContext, afterCommitHooks);
1225
- } catch (e) {
1226
- return writeFailure(wrapToKumiko(e));
1227
- }
1228
-
1229
- // jobRunner has external side-effects (BullMQ enqueue) — must NOT
1230
- // fire for rolled-back writes. Defer to afterCommit.
1231
- if (jobRunner) {
1232
- const eventData = (parsed.data ?? {}) as DbRow; // @cast-boundary engine-payload
1233
- afterCommitHooks.push(() => jobRunner.handleEvent(type, eventData, user));
1234
- }
1235
- }
1236
-
1237
- // Response-guard: block Secret<> leaks in write responses (SaveContext
1238
- // data / previous / changes). Feature code that fed a plaintext through
1239
- // to the return payload fails here instead of hitting the client.
1240
- if (result.isSuccess) assertNoSecretLeak(result.data);
1241
- return result;
1242
- }
1243
-
1244
- // Core batch logic extracted so write() and command() can reuse it
1245
- // (a single write = batch of one, running in its own transaction).
1246
- async function runBatch(
1247
- commands: readonly BatchCommand[],
1248
- user: SessionUser,
1249
- requestId?: string,
1250
- ): Promise<BatchResult> {
1251
- if (commands.length === 0) {
1252
- return { isSuccess: true, results: [] };
1253
- }
1254
-
1255
- // Idempotency: if the same requestId has already been processed, return the
1256
- // cached result without re-executing. The cache holds the full BatchResult.
1257
- if (requestId && idempotency) {
1258
- const cached = await idempotency.check(requestId);
1259
- if (cached) {
1260
- const parsed = parseJsonSafe<BatchResult | null>(cached, null);
1261
- if (parsed) return parsed;
1262
- // corrupted cache entry — treat as miss, let the request re-run
1263
- }
1264
- }
1265
-
1266
- // Wrap return paths: cache the final result under requestId so retries get
1267
- // the same answer (both success and failure results are cached).
1268
- const finalize = async (result: BatchResult): Promise<BatchResult> => {
1269
- if (requestId && idempotency) {
1270
- await idempotency.store(requestId, result);
1271
- }
1272
- return result;
1273
- };
1274
-
1275
- const afterCommitHooks: AfterCommitHook[] = [];
1276
- const results: WriteResult[] = [];
1277
-
1278
- // Flush afterCommit hooks in parallel. Errors are logged, not rethrown:
1279
- // the writes are already committed, we can't undo them.
1280
- //
1281
- // Parallelisation is safe because afterCommit hooks are deferred side-
1282
- // effects (e.g. feature-level postSave hooks in afterCommit phase)
1283
- // that don't depend on each other — the in-transaction work already ran
1284
- // sequentially inside the lifecycle pipeline where ordering matters. If a
1285
- // future hook ever needs ordering, it should do its sequencing internally
1286
- // (one hook pushing multiple sub-calls) rather than relying on the
1287
- // flush-loop order.
1288
- const flushAfterCommit = async () => {
1289
- const logError = createFallbackLogger("dispatcher", context.log);
1290
- const outcomes = await Promise.allSettled(afterCommitHooks.map((hook) => hook()));
1291
- for (const outcome of outcomes) {
1292
- if (outcome.status === "rejected") {
1293
- const detail =
1294
- outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
1295
- logError.error("afterCommit hook failed", { error: detail });
1296
- }
1297
- }
1298
- };
1299
-
1300
- // Fires the batch-level system hooks with every successful save/delete
1301
- // context from this run. Called after flushAfterCommit so per-save hooks
1302
- // have all completed first; errors are isolated inside lifecycleHooks.
1303
- const flushBatchHooks = async () => {
1304
- try {
1305
- const saves: SaveContext[] = [];
1306
- const deletes: DeleteContext[] = [];
1307
- for (const r of results) {
1308
- if (!r.isSuccess) continue;
1309
- if (!isLifecycleResult(r.data)) continue;
1310
- if (r.data.kind === "save") saves.push(r.data);
1311
- else if (r.data.kind === "delete") deletes.push(r.data);
1312
- }
1313
- if (saves.length > 0 && lifecycle) await lifecycle.runPostSaveBatch(saves, context);
1314
- if (deletes.length > 0 && lifecycle) await lifecycle.runPostDeleteBatch(deletes, context);
1315
- } catch (e) {
1316
- // Batch hooks must never fail the batch — the commit already happened.
1317
- // Pass the raw error so the logger preserves stack + cause chain;
1318
- // collapsing to .message hides exactly what ops needs to debug.
1319
- const logError = createFallbackLogger("dispatcher", context.log);
1320
- logError.error("batch hook flush failed", { error: e });
1321
- }
1322
- };
1323
-
1324
- // batch() opens its own outer transaction — needs the top-level
1325
- // connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
1326
- const db = resolveDbSource(undefined) as DbConnection | undefined;
1327
- if (!db) {
1328
- // Without a DB connection there is no transaction to open. Fall back to
1329
- // sequential execution — useful for unit tests that don't touch the DB.
1330
- // Each command runs independently; a failure stops the batch.
1331
- for (let i = 0; i < commands.length; i++) {
1332
- const cmd = commands[i];
1333
- if (!cmd) continue;
1334
- const res = await executeNestedWrite(
1335
- cmd.type,
1336
- cmd.payload,
1337
- user,
1338
- undefined,
1339
- afterCommitHooks,
1340
- );
1341
- results.push(res);
1342
- if (!res.isSuccess) {
1343
- // No tx means no rollback — but we still drop afterCommit hooks,
1344
- // matching the semantic "failure = side-effects don't fire".
1345
- return finalize({ isSuccess: false, error: res.error, failedIndex: i, results });
1346
- }
1347
- }
1348
- await flushAfterCommit();
1349
- await flushBatchHooks();
1350
- return finalize({ isSuccess: true, results });
1351
- }
1352
-
1353
- try {
1354
- await transaction(db, async (tx) => {
1355
- for (let i = 0; i < commands.length; i++) {
1356
- const cmd = commands[i];
1357
- if (!cmd) continue;
1358
- const res = await executeNestedWrite(cmd.type, cmd.payload, user, tx, afterCommitHooks);
1359
- results.push(res);
1360
- if (!res.isSuccess) {
1361
- throw new BatchRollback(i, res.error);
1362
- }
1363
- }
1364
- });
1365
- } catch (e) {
1366
- if (e instanceof BatchRollback) {
1367
- return finalize({
1368
- isSuccess: false,
1369
- error: e.failureError,
1370
- failedIndex: e.failedIndex,
1371
- results,
1372
- });
1373
- }
1374
- return finalize({
1375
- isSuccess: false,
1376
- error: toWriteErrorInfo(wrapToKumiko(e)),
1377
- failedIndex: results.length,
1378
- results,
1379
- });
1380
- }
1381
-
1382
- // Commit succeeded — fire deferred side-effects.
1383
- await flushAfterCommit();
1384
- await flushBatchHooks();
1385
- return finalize({ isSuccess: true, results });
1386
- }
1387
-
1388
- // Unwrap a BatchResult into a single WriteResult for write()/command().
1389
- // Picks the last result if present (the failing one for failures, the only
1390
- // one for successful single writes). Falls back to a synthetic error if the
1391
- // batch didn't produce any results (unexpected).
1392
- function unwrapSingle(batchResult: BatchResult): WriteResult {
1393
- if (batchResult.isSuccess) {
1394
- return (
1395
- batchResult.results[0] ?? writeFailure(new InternalError({ message: "empty_batch_result" }))
1396
- );
1397
- }
1398
- return (
1399
- batchResult.results[batchResult.failedIndex] ?? {
1400
- isSuccess: false,
1401
- error: batchResult.error,
1402
- }
1403
- );
1404
- }
1405
-
1406
- // Build the per-hook context every auth-claims invocation gets. Claims
1407
- // hooks run OUTSIDE any request transaction (login is itself the root
1408
- // operation, not a nested call) and read-only — so the TenantDb is
1409
- // scoped as "tenant" and no tx is threaded through. Hooks that need
1410
- // cross-tenant lookups opt in explicitly via queryAs(systemUser, ...).
1411
- function buildAuthClaimsContext(user: SessionUser): AuthClaimsContext {
1412
- const dbSource = resolveDbSource(undefined);
1413
- if (!dbSource) {
1414
- throw new InternalError({
1415
- message:
1416
- "dispatcher.resolveAuthClaims requires a database connection — none is configured.",
1417
- });
1418
- }
1419
- const db = createTenantDb(dbSource, user.tenantId, "tenant", context.tracer, context.meter);
1420
- const configAccessor = context._configAccessorFactory
1421
- ? context._configAccessorFactory({
1422
- user: { id: user.id, tenantId: user.tenantId },
1423
- db,
1424
- secrets: context.secrets,
1425
- })
1426
- : undefined;
1427
- return {
1428
- db,
1429
- queryAs: (asUser: SessionUser, qn: string, payload: unknown) =>
1430
- executeQuery(qn, payload, asUser), // @wrapper-known semantic-alias
1431
- ...(configAccessor && { config: configAccessor }),
1432
- };
1433
- }
1434
-
1435
- async function resolveAuthClaimsFn(user: SessionUser): Promise<Record<string, unknown>> {
1436
- const hooks = registry.getAuthClaimsHooks();
1437
- if (hooks.length === 0) return {};
1438
- return runAuthClaimsResolver({
1439
- user,
1440
- hooks,
1441
- contextFactory: buildAuthClaimsContext,
1442
- ...(context.log && { log: context.log }),
1443
- });
1444
- }
102
+ const ctx: DispatchContext = {
103
+ registry,
104
+ appContext: context,
105
+ idempotency,
106
+ lifecycle,
107
+ jobRunner,
108
+ effectiveFeatures,
109
+ sseBroker,
110
+ tableCache,
111
+ transitionCache,
112
+ tracer: dispatcherTracer,
113
+ meter: dispatcherMeter,
114
+ };
1445
115
 
1446
116
  return {
1447
117
  async write(typeOrRef, payload, user, requestId?) {
1448
118
  const type = resolveType(typeOrRef);
1449
119
  // Idempotency handled inside runBatch (caches BatchResult under requestId).
1450
- const batchResult = await runBatch([{ type, payload }], user, requestId);
120
+ const batchResult = await runBatch(ctx, [{ type, payload }], user, requestId);
1451
121
  return unwrapSingle(batchResult);
1452
122
  },
1453
123
 
1454
- batch: runBatch,
124
+ batch: (commands, user, requestId?) => runBatch(ctx, commands, user, requestId),
125
+
126
+ query: (typeOrRef, payload, user) => executeQuery(ctx, resolveType(typeOrRef), payload, user),
1455
127
 
1456
- query: (typeOrRef, payload, user) => executeQuery(resolveType(typeOrRef), payload, user),
128
+ stream: (typeOrRef, payload, user) => executeStream(ctx, resolveType(typeOrRef), payload, user),
1457
129
 
1458
130
  async command(typeOrRef, payload, user) {
1459
131
  const type = resolveType(typeOrRef);
1460
- const batchResult = await runBatch([{ type, payload }], user);
132
+ const batchResult = await runBatch(ctx, [{ type, payload }], user);
1461
133
  const result = unwrapSingle(batchResult);
1462
134
 
1463
135
  if (!result.isSuccess) {
@@ -1465,6 +137,6 @@ export function createDispatcher(
1465
137
  }
1466
138
  },
1467
139
 
1468
- resolveAuthClaims: resolveAuthClaimsFn,
140
+ resolveAuthClaims: (user) => resolveAuthClaimsFn(ctx, user),
1469
141
  };
1470
142
  }