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