@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
@@ -0,0 +1,826 @@
1
+ import { requestContext } from "../api/request-context";
2
+ import type { SseBroker } from "../api/sse-broker";
3
+ import type { DbConnection, DbRunner, DbTx } from "../db/connection";
4
+ import { runInSavepoint, selectMany } from "../db/query";
5
+ import type { buildEntityTable } from "../db/table-builder";
6
+ import { createTenantDb } from "../db/tenant-db";
7
+ import type { defineTransitions } from "../engine/state-machine";
8
+ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
9
+ import type {
10
+ AggregateStreamHandle,
11
+ AppContext,
12
+ AppendEventArgs,
13
+ AppendEventFn,
14
+ AuthClaimsContext,
15
+ FetchForWritingArgs,
16
+ HandlerContext,
17
+ JobRunnerRef,
18
+ Registry,
19
+ SessionUser,
20
+ WriteResult,
21
+ } from "../engine/types";
22
+ import type { TenantId } from "../engine/types/identifiers";
23
+ import {
24
+ FeatureDisabledError,
25
+ InternalError,
26
+ VersionConflictError,
27
+ type WriteErrorInfo,
28
+ } from "../errors";
29
+ import {
30
+ archiveStream as archiveStreamHelper,
31
+ isStreamArchived,
32
+ restoreStream as restoreStreamHelper,
33
+ } from "../event-store/archive";
34
+ import {
35
+ IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError,
36
+ VersionConflictError as EventStoreVersionConflictError,
37
+ } from "../event-store/errors";
38
+ import {
39
+ getStreamVersion,
40
+ loadAggregate,
41
+ loadAggregateAsOf,
42
+ type StoredEvent,
43
+ } from "../event-store/event-store";
44
+ import {
45
+ type LoadAggregateWithSnapshotOptions,
46
+ type LoadAggregateWithSnapshotResult,
47
+ loadAggregateWithSnapshot,
48
+ type SnapshotReducer,
49
+ saveSnapshot,
50
+ } from "../event-store/snapshot";
51
+ import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster";
52
+ import { createFileContext } from "../files/file-handle";
53
+ import {
54
+ createMetricsHandle,
55
+ createNoopMetricsHandle,
56
+ emitDispatcherError,
57
+ emitDispatcherHandler,
58
+ type getFallbackMeter,
59
+ getFallbackTracer,
60
+ observabilityContext,
61
+ } from "../observability";
62
+ import { buildBucketKey } from "../rate-limit";
63
+ import { createTzContext } from "../time";
64
+ import { appendDomainEventCore } from "./append-event-core";
65
+ import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver";
66
+ import { executeQuery } from "./dispatch-query";
67
+ import { executeWrite } from "./dispatch-write";
68
+ import {
69
+ type AfterCommitHook,
70
+ dispatcherSpanAttributes,
71
+ isFailedWriteResult,
72
+ } from "./dispatcher-utils";
73
+ import type { IdempotencyGuard } from "./idempotency";
74
+ import type { LifecycleHooks } from "./lifecycle-pipeline";
75
+
76
+ export type BatchCommand = {
77
+ readonly type: string;
78
+ readonly payload: unknown;
79
+ };
80
+
81
+ export type BatchResult =
82
+ | { readonly isSuccess: true; readonly results: readonly WriteResult[] }
83
+ | {
84
+ readonly isSuccess: false;
85
+ readonly error: WriteErrorInfo;
86
+ readonly failedIndex: number;
87
+ readonly results: readonly WriteResult[];
88
+ };
89
+
90
+ // Bundles everything the dispatch-phase functions (query/write/batch) need —
91
+ // hoisted to module scope out of createDispatcher's former closure, so those
92
+ // captures travel explicitly instead of implicitly.
93
+ export type DispatchContext = {
94
+ registry: Registry;
95
+ appContext: AppContext;
96
+ idempotency: IdempotencyGuard | undefined;
97
+ lifecycle: LifecycleHooks | undefined;
98
+ jobRunner: JobRunnerRef | undefined;
99
+ effectiveFeatures: EffectiveFeaturesResolver | undefined;
100
+ sseBroker: SseBroker | undefined;
101
+ tableCache: Map<string, ReturnType<typeof buildEntityTable>>;
102
+ transitionCache: Map<string, ReturnType<typeof defineTransitions>>;
103
+ tracer: ReturnType<typeof getFallbackTracer>;
104
+ meter: ReturnType<typeof getFallbackMeter>;
105
+ };
106
+
107
+ // Narrowing-helper: AppContext.db ist DbConnection|TenantDb|undefined. Die
108
+ // dispatch-Pfade brauchen DbConnection (oder DbTx aus Caller-Scope) für
109
+ // appendEvent/projection-writes; TenantDb-Branch wird hier ausgeschlossen.
110
+ export function resolveDbSource(
111
+ ctx: DispatchContext,
112
+ tx: DbTx | undefined,
113
+ ): DbConnection | DbTx | undefined {
114
+ const { appContext: context } = ctx;
115
+ return tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
116
+ }
117
+
118
+ // ctx.appendEvent — append a domain event onto a specific aggregate stream
119
+ // in the current tx, then fire matching inline projections. Core logic
120
+ // lives in appendDomainEventCore; this wrapper just locates dbSource +
121
+ // stringifies the SessionUser id for the shared helper.
122
+ async function appendDomainEvent(
123
+ ctx: DispatchContext,
124
+ args: AppendEventArgs,
125
+ user: SessionUser,
126
+ tx: DbTx | undefined,
127
+ callerFeature: string | undefined,
128
+ ): Promise<void> {
129
+ const { registry } = ctx;
130
+ const dbSource = resolveDbSource(ctx, tx);
131
+ if (!dbSource) {
132
+ throw new InternalError({
133
+ message: `ctx.appendEvent("${args.type}") requires a database connection — none is configured.`,
134
+ });
135
+ }
136
+ await appendDomainEventCore(
137
+ {
138
+ registry,
139
+ db: dbSource,
140
+ tenantId: user.tenantId,
141
+ userId: String(user.id),
142
+ callSiteLabel: "ctx.appendEvent",
143
+ callerFeature,
144
+ },
145
+ args,
146
+ );
147
+ }
148
+
149
+ export function buildHandlerContext(
150
+ ctx: DispatchContext,
151
+ type: string,
152
+ user: SessionUser,
153
+ tx?: DbTx,
154
+ afterCommitHooks?: AfterCommitHook[],
155
+ includeDeleted?: boolean,
156
+ ): HandlerContext {
157
+ const { registry, appContext: context, effectiveFeatures, jobRunner } = ctx;
158
+ const isSystem = registry.isHandlerSystemScoped(type);
159
+ // The outer dispatcher receives a DbConnection from the server/stack;
160
+ // AppContext's `db` union also allows TenantDb (for downstream hook calls),
161
+ // but at this point we're the root of the pipeline — cast is safe.
162
+ const dbSource = resolveDbSource(ctx, tx);
163
+ const reqCtx = requestContext.get();
164
+ const db = dbSource
165
+ ? createTenantDb(
166
+ dbSource,
167
+ user.tenantId,
168
+ isSystem ? "system" : "tenant",
169
+ context.tracer,
170
+ context.meter,
171
+ // Propagate the request's AbortSignal so every TenantDb query
172
+ // throws when the client has disconnected — handlers with many
173
+ // sequential queries skip the rest of the chain instead of
174
+ // burning DB-CPU for results no one reads.
175
+ reqCtx?.signal,
176
+ )
177
+ : undefined;
178
+ const log = context.log?.child({
179
+ handler: type,
180
+ tenantId: user.tenantId,
181
+ userId: user.id,
182
+ ...(reqCtx && { requestId: reqCtx.requestId }),
183
+ });
184
+ const notify = context._notifyFactory ? context._notifyFactory(user, user.tenantId) : undefined;
185
+ // Mirror notify: only built when the config feature wired its factory.
186
+ const config =
187
+ context._configAccessorFactory && db
188
+ ? context._configAccessorFactory({
189
+ user: { id: user.id, tenantId: user.tenantId },
190
+ db,
191
+ secrets: context.secrets,
192
+ })
193
+ : undefined;
194
+ // ctx.files resolved per-tenant through file-foundation (lazy — the
195
+ // provider is only resolved when a handle actually does I/O). Boot wires
196
+ // _fileProviderResolver when a file-provider plugin is mounted; falls back
197
+ // to a statically-injected context.files (tests).
198
+ const fileResolver = context._fileProviderResolver;
199
+ const files = fileResolver ? createFileContext(() => fileResolver(user.tenantId)) : context.files;
200
+
201
+ // Observability — feature-bound metrics handle, so ctx.metrics.inc("foo")
202
+ // resolves to kumiko_<feature>_foo. Unknown feature falls back to noop
203
+ // so legacy internal handlers don't crash.
204
+ const tracer = context.tracer ?? getFallbackTracer();
205
+ const meter = context.meter;
206
+ const featureName = registry.getHandlerFeature(type);
207
+ const metrics =
208
+ meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
209
+
210
+ // Cross-feature bridge. Queries and writes invoked through ctx.* share:
211
+ // - the current transaction (tx) — nested writes roll back with the parent
212
+ // - the current afterCommitHooks sink — deferred side-effects fire once
213
+ // when the outermost transaction commits
214
+ // `queryAs` / `writeAs` let a handler explicitly switch identity
215
+ // (e.g. system-privileged lookups that bypass field-access read filters).
216
+ const bridgeSink = afterCommitHooks ?? [];
217
+ const scheduleAfterCommit = (hook: AfterCommitHook): void => {
218
+ bridgeSink.push(hook);
219
+ };
220
+ const bridge = {
221
+ query: (targetType: string, payload: unknown) =>
222
+ executeQuery(ctx, targetType, payload, user, tx), // @wrapper-known semantic-alias
223
+ queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
224
+ executeQuery(ctx, targetType, payload, asUser, tx), // @wrapper-known semantic-alias
225
+ write: async (targetType: string, payload: unknown) => {
226
+ const res = await executeWrite(ctx, targetType, payload, user, tx, bridgeSink);
227
+ return res;
228
+ },
229
+ writeAs: async (asUser: SessionUser, targetType: string, payload: unknown) => {
230
+ const res = await executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink);
231
+ return res;
232
+ },
233
+ // Strict + unsafe share the same runtime — only the type-surface
234
+ // differs. The strict signature is what's exposed to typed callers;
235
+ // unsafe is the explicit escape-hatch for runtime-pluggable events.
236
+ appendEvent: (async (args: AppendEventArgs) => {
237
+ await appendDomainEvent(ctx, args, user, tx, registry.getHandlerFeature(type));
238
+ }) as AppendEventFn, // @cast-boundary engine-bridge
239
+ unsafeAppendEvent: async (args: AppendEventArgs) => {
240
+ await appendDomainEvent(ctx, args, user, tx, registry.getHandlerFeature(type));
241
+ },
242
+ // Savepoint-scoped append: catches a losing writer's VersionConflictError
243
+ // without poisoning the rest of the handler's transaction. Bun.SQL/
244
+ // postgres.js abort the WHOLE begin() on an uncaught statement error
245
+ // (SQLSTATE 25P02) even if the JS error is caught — runInSavepoint
246
+ // wraps the append in a real SAVEPOINT so only that nested scope rolls
247
+ // back on conflict, and subsequent statements in the outer tx (e.g. a
248
+ // dedup-anchor insert) still succeed. Use when a handler must react to
249
+ // losing a concurrent-append race instead of failing the whole write.
250
+ tryAppendEvent: async (args: AppendEventArgs) => {
251
+ if (!tx) {
252
+ throw new InternalError({
253
+ message: `ctx.tryAppendEvent("${args.type}") requires an active transaction — no tx is threaded through this call.`,
254
+ });
255
+ }
256
+ try {
257
+ const event = await runInSavepoint(tx, (sp) =>
258
+ appendDomainEventCore(
259
+ {
260
+ registry,
261
+ db: sp as DbRunner,
262
+ tenantId: user.tenantId,
263
+ userId: String(user.id),
264
+ callSiteLabel: "ctx.tryAppendEvent",
265
+ callerFeature: registry.getHandlerFeature(type),
266
+ },
267
+ args,
268
+ ),
269
+ );
270
+ return { ok: true as const, event };
271
+ } catch (e) {
272
+ if (e instanceof EventStoreVersionConflictError) {
273
+ return { ok: false as const, conflict: e };
274
+ }
275
+ if (e instanceof EventStoreIdempotentAppendConflictError) {
276
+ return { ok: false as const, conflict: e };
277
+ }
278
+ throw e;
279
+ }
280
+ },
281
+ fetchForWriting: async (args: FetchForWritingArgs): Promise<AggregateStreamHandle> => {
282
+ const dbSource = resolveDbSource(ctx, tx);
283
+ if (!dbSource) {
284
+ throw new InternalError({
285
+ message: `ctx.fetchForWriting("${args.aggregateId}") requires a database connection — none is configured.`,
286
+ });
287
+ }
288
+ // Stream-version authoritative (same policy as CRUD executor + Block 0).
289
+ // A single SELECT MAX(version) is cheaper than loading the full stream
290
+ // when the caller just wants to append — but most callers also want
291
+ // the events (business-rule checks), so fetch both in parallel.
292
+ const [storedEvents, fetchedVersion] = await Promise.all([
293
+ loadAggregate(dbSource, args.aggregateId, user.tenantId),
294
+ getStreamVersion(dbSource, args.aggregateId, user.tenantId),
295
+ ]);
296
+ const events = await upcastStoredEvents(storedEvents, registry.getEventUpcasters(), {
297
+ db: dbSource,
298
+ tenantId: user.tenantId,
299
+ });
300
+
301
+ // Optimistic concurrency: if the caller knows the version they
302
+ // worked against (e.g. from a prior read-model row) and the stream
303
+ // has moved on, fail fast before any downstream work.
304
+ if (args.expectedVersion !== undefined && args.expectedVersion !== fetchedVersion) {
305
+ throw new VersionConflictError({
306
+ entityId: args.aggregateId,
307
+ expectedVersion: args.expectedVersion,
308
+ currentVersion: fetchedVersion,
309
+ });
310
+ }
311
+
312
+ // Handle's internal version bumps on every appendOne so multiple
313
+ // appends in a row stay in order without re-reading the DB.
314
+ let handleVersion = fetchedVersion;
315
+ const appendOne = async (appendArgs: {
316
+ readonly type: string;
317
+ readonly payload: unknown;
318
+ }): Promise<void> => {
319
+ await appendDomainEvent(
320
+ ctx,
321
+ {
322
+ aggregateId: args.aggregateId,
323
+ aggregateType: args.aggregateType,
324
+ type: appendArgs.type,
325
+ payload: appendArgs.payload,
326
+ },
327
+ user,
328
+ tx,
329
+ registry.getHandlerFeature(type),
330
+ );
331
+ handleVersion += 1;
332
+ };
333
+
334
+ return {
335
+ events,
336
+ get version() {
337
+ return handleVersion;
338
+ },
339
+ appendOne,
340
+ };
341
+ },
342
+ loadAggregate: async (
343
+ aggregateId: string,
344
+ loadOptions?: { readonly asOf?: Temporal.Instant },
345
+ ): Promise<readonly StoredEvent[]> => {
346
+ const dbSource = resolveDbSource(ctx, tx);
347
+ if (!dbSource) {
348
+ throw new InternalError({
349
+ message: `ctx.loadAggregate("${aggregateId}") requires a database connection — none is configured.`,
350
+ });
351
+ }
352
+ const events = loadOptions?.asOf
353
+ ? await loadAggregateAsOf(dbSource, aggregateId, user.tenantId, loadOptions.asOf)
354
+ : await loadAggregate(dbSource, aggregateId, user.tenantId);
355
+ return upcastStoredEvents(events, registry.getEventUpcasters(), {
356
+ db: dbSource,
357
+ tenantId: user.tenantId,
358
+ });
359
+ },
360
+ archiveStream: async (
361
+ aggregateId: string,
362
+ archiveArgs: { readonly aggregateType: string; readonly reason?: string },
363
+ ): Promise<void> => {
364
+ const dbSource = resolveDbSource(ctx, tx);
365
+ if (!dbSource) {
366
+ throw new InternalError({
367
+ message: `ctx.archiveStream("${aggregateId}") requires a database connection — none is configured.`,
368
+ });
369
+ }
370
+ await archiveStreamHelper(dbSource, {
371
+ tenantId: user.tenantId,
372
+ aggregateId,
373
+ aggregateType: archiveArgs.aggregateType,
374
+ archivedBy: user.id,
375
+ reason: archiveArgs.reason,
376
+ });
377
+ },
378
+ restoreStream: async (aggregateId: string): Promise<void> => {
379
+ const dbSource = resolveDbSource(ctx, tx);
380
+ if (!dbSource) {
381
+ throw new InternalError({
382
+ message: `ctx.restoreStream("${aggregateId}") requires a database connection — none is configured.`,
383
+ });
384
+ }
385
+ await restoreStreamHelper(dbSource, user.tenantId, aggregateId);
386
+ },
387
+ isStreamArchived: async (aggregateId: string): Promise<boolean> => {
388
+ const dbSource = resolveDbSource(ctx, tx);
389
+ if (!dbSource) {
390
+ throw new InternalError({
391
+ message: `ctx.isStreamArchived("${aggregateId}") requires a database connection — none is configured.`,
392
+ });
393
+ }
394
+ return isStreamArchived(dbSource, user.tenantId, aggregateId);
395
+ },
396
+ snapshotAggregate: async (snapshotArgs: {
397
+ readonly aggregateId: string;
398
+ readonly aggregateType: string;
399
+ readonly version: number;
400
+ readonly state: Record<string, unknown>;
401
+ readonly snapshotVersion?: number;
402
+ }): Promise<void> => {
403
+ const dbSource = resolveDbSource(ctx, tx);
404
+ if (!dbSource) {
405
+ throw new InternalError({
406
+ message: `ctx.snapshotAggregate("${snapshotArgs.aggregateId}") requires a database connection — none is configured.`,
407
+ });
408
+ }
409
+ await saveSnapshot(dbSource, {
410
+ aggregateId: snapshotArgs.aggregateId,
411
+ tenantId: user.tenantId,
412
+ aggregateType: snapshotArgs.aggregateType,
413
+ version: snapshotArgs.version,
414
+ state: snapshotArgs.state,
415
+ snapshotVersion: snapshotArgs.snapshotVersion,
416
+ });
417
+ },
418
+ loadAggregateWithSnapshot: async <TState extends Record<string, unknown>>(
419
+ aggregateId: string,
420
+ reducer: SnapshotReducer<TState>,
421
+ initial: TState,
422
+ loadOptions?: Omit<LoadAggregateWithSnapshotOptions, "upcastEvent">,
423
+ ): Promise<LoadAggregateWithSnapshotResult<TState>> => {
424
+ const dbSource = resolveDbSource(ctx, tx);
425
+ if (!dbSource) {
426
+ throw new InternalError({
427
+ message: `ctx.loadAggregateWithSnapshot("${aggregateId}") requires a database connection — none is configured.`,
428
+ });
429
+ }
430
+ // Upcaster-aware: pass an upcastEvent callback so loadAggregateWithSnapshot
431
+ // walks every delta through the registered chain before invoking the
432
+ // user's (sync) reducer. Async upcasters (DB-enrichment) are awaited
433
+ // inside loadAggregateWithSnapshot — feature authors never see legacy
434
+ // payload shapes regardless of which load path they chose.
435
+ const upcasters = registry.getEventUpcasters();
436
+ const upcastCtx = { db: dbSource, tenantId: user.tenantId };
437
+ return loadAggregateWithSnapshot<TState>(
438
+ dbSource,
439
+ aggregateId,
440
+ user.tenantId,
441
+ reducer,
442
+ initial,
443
+ {
444
+ ...loadOptions,
445
+ upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx), // @wrapper-known semantic-alias
446
+ },
447
+ );
448
+ },
449
+ queryProjection: async <T = Record<string, unknown>>(
450
+ qualifiedName: string,
451
+ queryOptions?: { readonly unsafeAllTenants?: boolean },
452
+ ): Promise<readonly T[]> => {
453
+ // queryProjection works against both single-stream and multi-stream
454
+ // projections. MSPs without a table cannot be queried — those are
455
+ // side-effect-only consumers (no state to read back).
456
+ const singleProj = registry.getAllProjections().get(qualifiedName);
457
+ const mspProj = registry.getAllMultiStreamProjections().get(qualifiedName);
458
+ const projTable = singleProj?.table ?? mspProj?.table;
459
+ if (!projTable) {
460
+ const singleNames = [...registry.getAllProjections().keys()];
461
+ const mspNames = [...registry.getAllMultiStreamProjections().keys()].filter(
462
+ (n) => registry.getAllMultiStreamProjections().get(n)?.table,
463
+ );
464
+ const all = [...singleNames, ...mspNames];
465
+ throw new InternalError({
466
+ message:
467
+ `ctx.queryProjection("${qualifiedName}") — projection not registered, or it is a ` +
468
+ `table-less MSP (side-effect-only). Known queryable projections: ${all.join(", ") || "(none)"}`,
469
+ });
470
+ }
471
+ const dbSource = resolveDbSource(ctx, tx);
472
+ if (!dbSource) {
473
+ throw new InternalError({
474
+ message: `ctx.queryProjection("${qualifiedName}") requires a database connection — none is configured.`,
475
+ });
476
+ }
477
+ // Introspect for a tenant_id column on the projection table. Auto-
478
+ // filter keeps cross-tenant leaks out unless the handler explicitly
479
+ // opts in. Works with any drizzle-table whose tenant column is named
480
+ // tenantId on the JS side.
481
+ const tenantCol = (projTable as Record<string, unknown>)["tenantId"];
482
+ const where =
483
+ tenantCol && !queryOptions?.unsafeAllTenants ? { tenantId: user.tenantId } : undefined;
484
+ const rows = await selectMany<Record<string, unknown>>(dbSource, projTable, where);
485
+ return rows as readonly T[]; // @cast-boundary engine-payload
486
+ },
487
+ // Thin pass-through: one resolve impl lives on the dispatcher, the
488
+ // handler surface just forwards the call so both entry points (login
489
+ // handler via ctx.resolveAuthClaims, switch-tenant route via
490
+ // dispatcher.resolveAuthClaims) cannot drift.
491
+ resolveAuthClaims: (claimsUser: SessionUser) => resolveAuthClaimsFn(ctx, claimsUser), // @wrapper-known semantic-alias
492
+
493
+ // Feature-effective check for in-handler opt-in logic. Scope:
494
+ // **current user's tenant** — for cross-tenant lookups (rare,
495
+ // SysAdmin operations) read effectiveFeatures(otherTenantId) directly.
496
+ // When the feature-toggles or tier-engine feature isn't wired (no
497
+ // effectiveFeatures callback), always returns true — apps without
498
+ // tier-cuts treat all features on.
499
+ //
500
+ // Falls back to the live trialGate when the sync set says the feature
501
+ // is off — the sync set never contains trial-tier features (time-
502
+ // derived, can't boot-cache), so without this a trial tenant checking
503
+ // a companion feature's toggle would silently read `false` even though
504
+ // the dispatch gate already lets trial-tier handlers run.
505
+ hasFeature: async (featureName: string): Promise<boolean> => {
506
+ if (!effectiveFeatures) return true;
507
+ if (effectiveFeatures(user.tenantId).has(featureName)) return true;
508
+ if (!effectiveFeatures.trialGate) return false;
509
+ return effectiveFeatures.trialGate(user.tenantId, featureName);
510
+ },
511
+ };
512
+
513
+ // Registry is always the dispatcher's registry — injecting it here lets
514
+ // tests/callers pass `context` without `registry` and still get a valid
515
+ // HandlerContext. The spread-then-assign order matters: anything in
516
+ // `context` can be overridden, but we want the authoritative registry
517
+ // from the dispatcher's own closure to win.
518
+ // ctx.tz ist immer da. Tenant + User-Defaults kommen aus dem
519
+ // SessionUser sobald die Felder existieren — bis dahin "UTC". Ein
520
+ // app-injizierter GeoTzProvider (context.geoTzProvider) speist
521
+ // ctx.tz.fromCoordinates / fromAddress.
522
+ const tz = createTzContext(
523
+ context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {},
524
+ );
525
+
526
+ return {
527
+ ...context,
528
+ registry,
529
+ db,
530
+ log,
531
+ notify,
532
+ ...(config && { config }),
533
+ ...(files && { files }),
534
+ tracer,
535
+ metrics,
536
+ tz,
537
+ // Cancellation signal flows from the HTTP middleware via
538
+ // requestContext. Conditional spread so non-HTTP entry-points
539
+ // (jobs, dispatcher MSP-applies) don't get a phantom signal that
540
+ // would always read aborted=false but feel meaningful.
541
+ ...(reqCtx?.signal ? { signal: reqCtx.signal } : {}),
542
+ // Propagate the feature-toggle resolver so the lifecycle pipeline,
543
+ // MSP runner, and ctx.hasFeature all pull from the same source.
544
+ ...(effectiveFeatures && { effectiveFeatures }),
545
+ // Lets write handlers call ctx.jobRunner.dispatch(...) directly, same
546
+ // as a follow-up job would (test-stack.ts wires the matching runner).
547
+ ...(jobRunner && { jobRunner }),
548
+ // ctx.user als Convenience-Alias auf event.user. Der typisch-
549
+ // intuitive Pfad „der Context kennt seinen User" — ohne den
550
+ // schreiben Handler `event.user.tenantId` und brechen sich die
551
+ // Finger an typo-resistenten ctx.user-Patterns. Identisch zum
552
+ // event.user-Wert; Identity-Switches nutzen weiterhin queryAs/writeAs.
553
+ user,
554
+ _userId: user.id,
555
+ _tenantId: user.tenantId,
556
+ _handlerType: type,
557
+ scheduleAfterCommit,
558
+ ...(includeDeleted && { includeDeleted: true }),
559
+ ...bridge,
560
+ } as HandlerContext; // @cast-boundary engine-bridge
561
+ }
562
+
563
+ // Wrap handler execution in a dispatcher.handler span AND emit the standard
564
+ // dispatcher metrics (duration + error counter). Errors are re-thrown so
565
+ // control flow stays identical to the uninstrumented path.
566
+ //
567
+ // Writes are special-cased: executeWriteInner converts thrown handler errors
568
+ // into a WriteResult with isSuccess=false (rather than letting them bubble).
569
+ // We inspect the result to paint the dispatcher span + error counter on
570
+ // those structural failures too — otherwise "handler threw" would only show
571
+ // up when the caller forgot to use writeFailure().
572
+ export async function runHandlerInstrumented<T>(
573
+ ctx: DispatchContext,
574
+ type: string,
575
+ operation: "query" | "write" | "stream",
576
+ user: SessionUser,
577
+ inner: () => Promise<T>,
578
+ ): Promise<T> {
579
+ const { tracer: dispatcherTracer, meter: dispatcherMeter, registry } = ctx;
580
+ const start = performance.now();
581
+ // Outcome recorded inside the withSpan callback, emitted in finally so
582
+ // success/failure/throw all hit a single metric-emit path.
583
+ let success = true;
584
+ let errorClass: string | undefined;
585
+
586
+ try {
587
+ return await dispatcherTracer.withSpan(
588
+ "kumiko.dispatcher.handler",
589
+ {
590
+ attributes: dispatcherSpanAttributes(
591
+ type,
592
+ operation,
593
+ user,
594
+ registry.getHandlerFeature(type),
595
+ ),
596
+ },
597
+ async (span) => {
598
+ try {
599
+ const result = await inner();
600
+ if (operation === "write" && isFailedWriteResult(result)) {
601
+ success = false;
602
+ errorClass = result.error?.code ?? "UnknownError";
603
+ span.setStatus("error", errorClass);
604
+ }
605
+ return result;
606
+ } catch (error) {
607
+ success = false;
608
+ errorClass = error instanceof Error && error.name ? error.name : "UnknownError";
609
+ throw error;
610
+ }
611
+ },
612
+ );
613
+ } finally {
614
+ if (!success && errorClass) {
615
+ emitDispatcherError(dispatcherMeter, { handler: type, errorClass });
616
+ }
617
+ emitDispatcherHandler(
618
+ dispatcherMeter,
619
+ { handler: type, success },
620
+ (performance.now() - start) / 1000,
621
+ );
622
+ }
623
+ }
624
+
625
+ // Generator-native counterpart to runHandlerInstrumented — a stream's
626
+ // lifetime spans every `for await` pull the caller makes, so the span
627
+ // can't be scoped via withSpan's single-callback shape. Drive the inner
628
+ // generator manually (not yield*): each pull must run inside
629
+ // observabilityContext.run({ activeSpan: span }) so handler-side
630
+ // startSpan() calls parent onto the dispatcher span. finally forwards
631
+ // .return() to the inner generator (yield* did that for free). Metrics
632
+ // land in the same finally path so success/failure/throw/abort all hit
633
+ // one emit, like the Promise path.
634
+ export async function* runStreamInstrumented<T>(
635
+ ctx: DispatchContext,
636
+ type: string,
637
+ user: SessionUser,
638
+ inner: () => AsyncGenerator<T>,
639
+ ): AsyncGenerator<T> {
640
+ const { tracer: dispatcherTracer, meter: dispatcherMeter, registry } = ctx;
641
+ const start = performance.now();
642
+ let success = true;
643
+ // Set only once `inner()` has drained on its own — NOT set when the
644
+ // consumer walks away early (generator.return(), the normal SSE-abort
645
+ // path in api/routes.ts). yield* forwards that .return() straight through
646
+ // without throwing, so without this flag an aborted stream would fall
647
+ // through to the success-metric branch below and get counted as a clean
648
+ // completion.
649
+ let completedNormally = false;
650
+ let errorClass: string | undefined;
651
+ const span = dispatcherTracer.startSpan("kumiko.dispatcher.handler", {
652
+ attributes: dispatcherSpanAttributes(type, "stream", user, registry.getHandlerFeature(type)),
653
+ });
654
+ const it = inner();
655
+ try {
656
+ let next = await observabilityContext.run({ activeSpan: span }, () => it.next());
657
+ while (!next.done) {
658
+ yield next.value;
659
+ next = await observabilityContext.run({ activeSpan: span }, () => it.next());
660
+ }
661
+ completedNormally = true;
662
+ return next.value;
663
+ } catch (error) {
664
+ success = false;
665
+ errorClass = error instanceof Error && error.name ? error.name : "UnknownError";
666
+ if (error instanceof Error) span.recordException(error);
667
+ span.setStatus("error", errorClass);
668
+ throw error;
669
+ } finally {
670
+ // forward close so inner()'s finally still fires — yield* did this for free
671
+ try {
672
+ await observabilityContext.run({ activeSpan: span }, () => it.return?.(undefined));
673
+ } catch (closeError) {
674
+ // Only fold this in when nothing has already failed — a close-time
675
+ // error while an earlier error is in flight would mask the real
676
+ // cause reported above.
677
+ if (success) {
678
+ success = false;
679
+ errorClass =
680
+ closeError instanceof Error && closeError.name ? closeError.name : "UnknownError";
681
+ span.setStatus("error", errorClass);
682
+ }
683
+ }
684
+ span.end();
685
+ if (!success && errorClass) {
686
+ emitDispatcherError(dispatcherMeter, { handler: type, errorClass });
687
+ }
688
+ // Consumer-abort (generator.return(), the normal SSE Tab-close path)
689
+ // is not a handler failure — label it outcome:"aborted" so live-stream
690
+ // success rates stay meaningful. True handler throws keep success:false.
691
+ emitDispatcherHandler(
692
+ dispatcherMeter,
693
+ {
694
+ handler: type,
695
+ success,
696
+ outcome: completedNormally ? "completed" : "aborted",
697
+ },
698
+ (performance.now() - start) / 1000,
699
+ );
700
+ }
701
+ }
702
+
703
+ // Feature-toggle gate. Returns the error to fold into a WriteFailure in the
704
+ // write path, or throws for the query path (where throws flow through the
705
+ // same outer instrumentation wrapper as other dispatcher errors).
706
+ //
707
+ // When `effectiveFeatures` is not wired (tests, apps without feature-toggles
708
+ // loaded), every handler is treated as enabled — the gate is a pure
709
+ // pass-through in that common case.
710
+ export async function checkFeatureEnabled(
711
+ ctx: DispatchContext,
712
+ qualifiedHandler: string,
713
+ tenantId: TenantId,
714
+ ): Promise<import("../errors").FeatureDisabledError | undefined> {
715
+ const { effectiveFeatures, registry } = ctx;
716
+ if (!effectiveFeatures) return undefined;
717
+ const owner = registry.getHandlerFeature(qualifiedHandler);
718
+ // skip: handler without an owning feature cannot be toggled — shouldn't
719
+ // happen for registry-built handlers, but guards against edge-case
720
+ // runtime injections.
721
+ if (!owner) return undefined;
722
+ const set = effectiveFeatures(tenantId);
723
+ if (set.has(owner)) return undefined;
724
+ // Feature is off for the stored tier — give the live trial-gate a last
725
+ // chance. Time-derived (tenant.inserted_at + window), so it can't live in
726
+ // the boot-cached sync resolver; consulted only on this already-disabled
727
+ // cold path, never on the hot enabled path.
728
+ if (effectiveFeatures.trialGate && (await effectiveFeatures.trialGate(tenantId, owner))) {
729
+ return undefined;
730
+ }
731
+ return new FeatureDisabledError(owner, qualifiedHandler);
732
+ }
733
+
734
+ export async function ensureFeatureEnabled(
735
+ ctx: DispatchContext,
736
+ qualifiedHandler: string,
737
+ tenantId: TenantId,
738
+ ): Promise<void> {
739
+ const err = await checkFeatureEnabled(ctx, qualifiedHandler, tenantId);
740
+ if (err) throw err;
741
+ }
742
+
743
+ // L3 rate limit gate. Called by both query and write paths before
744
+ // access-check. Reasoning:
745
+ // - handler without rateLimit → no-op
746
+ // - app booted without rateLimit resolver → InternalError so the
747
+ // misconfig surfaces immediately, not on first 429
748
+ // - bucket builder returns "skip" (e.g. ip-based but no client IP):
749
+ // pass through. ip-modes are commonly used at L1/L2 middleware
750
+ // where the IP comes from Hono directly; falling back to "skip"
751
+ // here keeps non-HTTP entry-points (jobs, MSPs) functional.
752
+ export async function enforceRateLimit(
753
+ ctx: DispatchContext,
754
+ rateLimit: import("../engine/types").RateLimitOption | undefined,
755
+ handlerName: string,
756
+ user: SessionUser,
757
+ ): Promise<void> {
758
+ const { appContext: context } = ctx;
759
+ // skip: defence-in-depth — both call-sites already gate on
760
+ // handler.rateLimit !== undefined, so this branch only fires
761
+ // if a future caller forgets the inline check.
762
+ if (!rateLimit) return;
763
+ const reqCtx = requestContext.get();
764
+ const bucket = buildBucketKey(rateLimit, {
765
+ handlerName,
766
+ user,
767
+ ip: reqCtx?.ip,
768
+ });
769
+ // skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
770
+ // requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
771
+ if (bucket.kind === "skip") return;
772
+ if (!context.rateLimit) {
773
+ throw new InternalError({
774
+ message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
775
+ });
776
+ }
777
+ await context.rateLimit.enforce(bucket.key, {
778
+ limit: rateLimit.limit,
779
+ windowSeconds: rateLimit.windowSeconds,
780
+ cost: rateLimit.cost,
781
+ });
782
+ }
783
+
784
+ // Build the per-hook context every auth-claims invocation gets. Claims
785
+ // hooks run OUTSIDE any request transaction (login is itself the root
786
+ // operation, not a nested call) and read-only — so the TenantDb is
787
+ // scoped as "tenant" and no tx is threaded through. Hooks that need
788
+ // cross-tenant lookups opt in explicitly via queryAs(systemUser, ...).
789
+ function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthClaimsContext {
790
+ const { appContext: context } = ctx;
791
+ const dbSource = resolveDbSource(ctx, undefined);
792
+ if (!dbSource) {
793
+ throw new InternalError({
794
+ message: "dispatcher.resolveAuthClaims requires a database connection — none is configured.",
795
+ });
796
+ }
797
+ const db = createTenantDb(dbSource, user.tenantId, "tenant", context.tracer, context.meter);
798
+ const configAccessor = context._configAccessorFactory
799
+ ? context._configAccessorFactory({
800
+ user: { id: user.id, tenantId: user.tenantId },
801
+ db,
802
+ secrets: context.secrets,
803
+ })
804
+ : undefined;
805
+ return {
806
+ db,
807
+ queryAs: (asUser: SessionUser, qn: string, payload: unknown) =>
808
+ executeQuery(ctx, qn, payload, asUser), // @wrapper-known semantic-alias
809
+ ...(configAccessor && { config: configAccessor }),
810
+ };
811
+ }
812
+
813
+ export async function resolveAuthClaimsFn(
814
+ ctx: DispatchContext,
815
+ user: SessionUser,
816
+ ): Promise<Record<string, unknown>> {
817
+ const { registry, appContext: context } = ctx;
818
+ const hooks = registry.getAuthClaimsHooks();
819
+ if (hooks.length === 0) return {};
820
+ return runAuthClaimsResolver({
821
+ user,
822
+ hooks,
823
+ contextFactory: (claimsUser: SessionUser) => buildAuthClaimsContext(ctx, claimsUser),
824
+ ...(context.log && { log: context.log }),
825
+ });
826
+ }