@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,1016 @@
1
+ import { and, asc, eq, gt, sql } from "drizzle-orm";
2
+ import { requestContext } from "../api/request-context";
3
+ import type { DbConnection, DbTx, PgClient } from "../db/connection";
4
+ import type { AppContext } from "../engine/types";
5
+ import {
6
+ EVENTS_PUBSUB_CHANNEL,
7
+ eventsTable,
8
+ getEventsHighWaterMark,
9
+ type StoredEvent,
10
+ } from "../event-store";
11
+ import {
12
+ emitDispatcherError,
13
+ emitEventConsumerLag,
14
+ emitEventConsumerPassOutcome,
15
+ emitEventDispatcherListenConnected,
16
+ getFallbackMeter,
17
+ getFallbackTracer,
18
+ type Meter,
19
+ type Tracer,
20
+ } from "../observability";
21
+ import {
22
+ ConsumerStatuses,
23
+ eventConsumerStateTable,
24
+ SHARED_INSTANCE_SENTINEL,
25
+ } from "./event-consumer-state";
26
+
27
+ // Async event-dispatcher — the "AsyncDaemon"-pendant for Kumiko.
28
+ //
29
+ // Consumers (SSE broadcast, search-index, cross-feature subscribers, and —
30
+ // later — async projections) read the events-table via a persistent cursor
31
+ // held in kumiko_event_consumers. One row per consumer, one independent
32
+ // cursor. A stalled Meili consumer doesn't block SSE; a dead subscription
33
+ // doesn't pause the others.
34
+ //
35
+ // Run loop, per consumer, per pass:
36
+ // 1. BEGIN
37
+ // 2. SELECT state row FOR UPDATE SKIP LOCKED
38
+ // — multi-instance-safe: if another poller holds the lock, this pass
39
+ // skips this consumer and tries the next. No duplicate delivery.
40
+ // 3. SELECT events WHERE id > lastProcessedEventId ORDER BY id ASC LIMIT batchSize
41
+ // 4. For each event: call the consumer's handler
42
+ // - handler throws → increment attempts, mark status="dead" at
43
+ // maxAttempts, surface lastError, STOP this consumer's pass
44
+ // (later events aren't consumed out-of-order)
45
+ // - handler succeeds → advance cursor, reset attempts
46
+ // 5. COMMIT — cursor update + dead-letter flag land atomic
47
+ //
48
+ // Order guarantee: per-consumer, events are applied in events.id order. We
49
+ // don't skip past a failing event — ops has to fix it or mark the consumer
50
+ // disabled. This matches Marten's subscription semantics (and EventStoreDB's
51
+ // persistent subscriptions): strictly-ordered + halt-on-poison.
52
+ //
53
+ // Delivery semantics: **at-least-once**. If a handler runs but the cursor
54
+ // update fails (crash mid-pass), the same event is delivered again next pass.
55
+ // Handlers MUST be idempotent.
56
+
57
+ export type EventConsumerHandler = (event: StoredEvent, ctx: AppContext) => Promise<void>;
58
+
59
+ // Per-consumer error policy. When skipApplyErrors is true and handler throws,
60
+ // the dispatcher logs the error, advances the cursor past the offending event,
61
+ // and keeps delivering — instead of the default retry + dead-letter flow.
62
+ // Wire by copying MultiStreamProjectionDefinition.errorMode.continuous into
63
+ // the EventConsumer (see api/server.ts MSP wiring).
64
+ export type EventConsumerErrorPolicy = {
65
+ readonly skipApplyErrors?: boolean;
66
+ };
67
+
68
+ export type EventConsumer = {
69
+ readonly name: string;
70
+ readonly handler: EventConsumerHandler;
71
+ readonly errorPolicy?: EventConsumerErrorPolicy;
72
+ // Owning feature — when present, the dispatcher skips this consumer's
73
+ // pass while the feature is globally disabled. Events remain in the store
74
+ // and the consumer resumes from the same cursor when the feature is
75
+ // re-enabled (no data loss, no replay). System consumers (SSE, search,
76
+ // framework-level plumbing) omit this and always run.
77
+ readonly featureName?: string;
78
+ // Delivery semantics across multi-instance deploys:
79
+ // "shared" (default) — one cursor across all instances. SKIP LOCKED
80
+ // serialises; each event delivered exactly once globally.
81
+ // "per-instance" — one cursor per (name, dispatcher.instanceId) shard.
82
+ // Every process delivers every event independently. For
83
+ // push-to-local-subscribers (SSE broker, in-memory cache
84
+ // invalidators). Handler MUST be side-effect-free with
85
+ // respect to shared storage (no DB writes), otherwise
86
+ // each instance duplicates the effect.
87
+ readonly delivery?: "shared" | "per-instance";
88
+ };
89
+
90
+ // Result of a dispatcher pass (runOnce / doPass). Shared across the public
91
+ // interface and the internal helpers so all three sites agree on the shape
92
+ // — adding a counter in one place wouldn't have compiled on the others
93
+ // when the type was inlined in each signature.
94
+ export type DispatcherPassResult = {
95
+ readonly processed: number;
96
+ readonly failed: number;
97
+ readonly byConsumer: Record<string, { processed: number; failed: number }>;
98
+ };
99
+
100
+ export type EventDispatcher = {
101
+ start(): Promise<void>;
102
+ stop(): Promise<void>;
103
+ // Force one pass now (tests drain deterministically via this).
104
+ // Throws if start() was never called — pre-registration of consumer
105
+ // state rows is a precondition, not a side-effect of the pass itself.
106
+ runOnce(): Promise<DispatcherPassResult>;
107
+ // Idempotent re-pre-registration of consumer state rows. Exists as a
108
+ // test-teardown surface: after `TRUNCATE kumiko_event_consumers` the
109
+ // rows are gone, and strict acquire() would skip every consumer as
110
+ // "not_registered". Tests call ensureRegistered() to repopulate without
111
+ // a full stop/start cycle. Production never needs this — start() runs
112
+ // it once on boot and the rows survive dispatcher lifetime.
113
+ ensureRegistered(): Promise<void>;
114
+ // Read-only view of the consumers this dispatcher is wired with. Exists
115
+ // for lane-filter assertions (Welle 2.6.b split-deploy tests) and for
116
+ // the boot-validator (Welle 2.6.c coverage check: every registered MSP
117
+ // must appear in at least one process's dispatcher). No runtime semantics
118
+ // — the list doesn't change after construction.
119
+ readonly consumers: readonly EventConsumer[];
120
+ };
121
+
122
+ export type EventDispatcherOptions = {
123
+ readonly db: DbConnection;
124
+ readonly consumers: readonly EventConsumer[];
125
+ readonly context: AppContext;
126
+ readonly batchSize?: number;
127
+ readonly pollIntervalMs?: number;
128
+ readonly maxAttempts?: number;
129
+ readonly tracer?: Tracer;
130
+ readonly meter?: Meter;
131
+ // Identifies THIS dispatcher process in the consumer-state table. Used as
132
+ // the `instance_id` value for every per-instance consumer's cursor row.
133
+ // Shared-delivery consumers ignore this and always use
134
+ // SHARED_INSTANCE_SENTINEL. Default undefined — dispatchers without any
135
+ // per-instance consumers don't need it. Required when at least one
136
+ // consumer has delivery="per-instance"; createEventDispatcher throws on
137
+ // boot if the invariant is violated, avoiding a later runtime surprise.
138
+ readonly instanceId?: string;
139
+ // Optional raw postgres.js client for LISTEN/NOTIFY-based wake-up
140
+ // (Sprint E.4). When present, `.start()` subscribes to EVENTS_PUBSUB_CHANNEL
141
+ // and fires runOnce on each NOTIFY — delivery latency becomes TCP-round-
142
+ // trip instead of pollIntervalMs. The polling timer remains active as a
143
+ // safety net (missed NOTIFYs from crashes, subscription drops).
144
+ readonly pgClient?: PgClient;
145
+ };
146
+
147
+ const DEFAULT_BATCH_SIZE = 200;
148
+ const DEFAULT_POLL_MS = 100;
149
+ const DEFAULT_MAX_ATTEMPTS = 10;
150
+
151
+ // --- processConsumer helpers ---
152
+ // Free functions (not closures) so they're independently readable and the
153
+ // dispatcher's main pass logic stays under ~50 LOC. Every helper takes an
154
+ // explicit `tx` — none of them use the outer dispatcher's closure state.
155
+
156
+ type ConsumerStateRow = typeof eventConsumerStateTable.$inferSelect;
157
+
158
+ type AcquireOutcome =
159
+ | { readonly state: ConsumerStateRow; readonly skip: null }
160
+ | {
161
+ readonly state: null;
162
+ readonly skip: "locked_by_other_instance" | "disabled" | "dead" | "not_registered";
163
+ };
164
+
165
+ // Lock the consumer's state row with SKIP LOCKED. Strict: no in-tx bootstrap.
166
+ // The row must exist — start() pre-registers every consumer up front so
167
+ // prune (event-retention) sees their cursors as soon as the process is up,
168
+ // closing the race where a lazy-bootstrapped consumer's cursor is absent
169
+ // during prune and its events are silently deleted.
170
+ //
171
+ // skip="not_registered" signals a row-missing-despite-start condition.
172
+ // Production shouldn't hit this — it means either start() wasn't called
173
+ // (runOnce() guards against that) or the state row was deleted externally
174
+ // (a test TRUNCATE without subsequent ensureRegistered(), or an operator
175
+ // intervention). Skipping quietly preserves the dispatcher's other
176
+ // consumers and surfaces the issue via the metrics pass-outcome.
177
+ async function acquireConsumerState(
178
+ tx: DbTx,
179
+ name: string,
180
+ instanceId: string,
181
+ ): Promise<AcquireOutcome> {
182
+ const [state] = (await tx
183
+ .select()
184
+ .from(eventConsumerStateTable)
185
+ .where(
186
+ and(
187
+ eq(eventConsumerStateTable.name, name),
188
+ eq(eventConsumerStateTable.instanceId, instanceId),
189
+ ),
190
+ )
191
+ .for("update", { skipLocked: true })) as [ConsumerStateRow | undefined];
192
+
193
+ if (!state) {
194
+ // Either the row never existed (no pre-reg, no ensureRegistered) or
195
+ // another instance currently holds the lock with SKIP LOCKED filtering
196
+ // us out. We can't distinguish here in a single query, so return
197
+ // "not_registered" — ops sees a skip-reason instead of silent delivery
198
+ // loss. Under normal operation (start() called, no external tampering)
199
+ // this path is never taken.
200
+ return { state: null, skip: "not_registered" };
201
+ }
202
+
203
+ if (state.status === ConsumerStatuses.disabled) return { state: null, skip: "disabled" };
204
+ if (state.status === ConsumerStatuses.dead) return { state: null, skip: "dead" };
205
+ return { state, skip: null };
206
+ }
207
+
208
+ // Shared pre-registration: one row per (consumer, shard), cursor = 0,
209
+ // status = idle. Shared-delivery consumers use SHARED_INSTANCE_SENTINEL;
210
+ // per-instance consumers use the dispatcher's instanceId. Idempotent
211
+ // under restart and concurrent start-calls via ON CONFLICT DO NOTHING
212
+ // on the composite PK — never clobbers an existing cursor.
213
+ async function preRegisterConsumers(
214
+ db: DbConnection,
215
+ consumers: readonly EventConsumer[],
216
+ dispatcherInstanceId: string | undefined,
217
+ ): Promise<void> {
218
+ for (const consumer of consumers) {
219
+ const instanceId = consumerInstanceId(consumer, dispatcherInstanceId);
220
+ await db
221
+ .insert(eventConsumerStateTable)
222
+ .values({ name: consumer.name, instanceId, status: "idle" })
223
+ .onConflictDoNothing({
224
+ target: [eventConsumerStateTable.name, eventConsumerStateTable.instanceId],
225
+ });
226
+ }
227
+ }
228
+
229
+ // Resolve the instance_id column value for one consumer on this dispatcher.
230
+ // Shared stays at the sentinel; per-instance rides the dispatcher's id.
231
+ // Throws when a per-instance consumer is registered without an instanceId
232
+ // — missing at boot is the sharp-edge to catch, not at first delivery.
233
+ function consumerInstanceId(
234
+ consumer: EventConsumer,
235
+ dispatcherInstanceId: string | undefined,
236
+ ): string {
237
+ if (consumer.delivery !== "per-instance") return SHARED_INSTANCE_SENTINEL;
238
+ if (!dispatcherInstanceId) {
239
+ throw new Error(
240
+ `EventConsumer "${consumer.name}" has delivery="per-instance" but the dispatcher was created without an instanceId — ` +
241
+ `pass EventDispatcherOptions.instanceId (typically from ServerOptions.instanceId / KUMIKO_INSTANCE_ID).`,
242
+ );
243
+ }
244
+ return dispatcherInstanceId;
245
+ }
246
+
247
+ // Mark the consumer row as "processing" for ops visibility. The SKIP LOCKED
248
+ // lock already guarantees single-writer semantics; this is purely
249
+ // informational (and resets on commit to idle/dead via persistConsumerOutcome).
250
+ async function markProcessing(tx: DbTx, name: string, instanceId: string): Promise<void> {
251
+ await tx
252
+ .update(eventConsumerStateTable)
253
+ .set({ status: "processing", updatedAt: sql`now()` })
254
+ .where(
255
+ and(
256
+ eq(eventConsumerStateTable.name, name),
257
+ eq(eventConsumerStateTable.instanceId, instanceId),
258
+ ),
259
+ );
260
+ }
261
+
262
+ async function fetchPendingEvents(
263
+ tx: DbTx,
264
+ cursor: bigint,
265
+ batchSize: number,
266
+ ): Promise<ReadonlyArray<typeof eventsTable.$inferSelect>> {
267
+ return (await tx
268
+ .select()
269
+ .from(eventsTable)
270
+ .where(gt(eventsTable.id, cursor))
271
+ .orderBy(asc(eventsTable.id))
272
+ .limit(batchSize)) as ReadonlyArray<typeof eventsTable.$inferSelect>;
273
+ }
274
+
275
+ type DeliveryOutcome = {
276
+ readonly cursor: bigint;
277
+ readonly attempts: number;
278
+ readonly lastError: string | null;
279
+ readonly deadLettered: boolean;
280
+ readonly processed: number;
281
+ readonly failed: number;
282
+ };
283
+
284
+ function rowToStoredEvent(row: typeof eventsTable.$inferSelect): StoredEvent {
285
+ return {
286
+ id: String(row.id),
287
+ aggregateId: row.aggregateId,
288
+ aggregateType: row.aggregateType,
289
+ tenantId: row.tenantId,
290
+ version: row.version,
291
+ type: row.type,
292
+ eventVersion: row.eventVersion,
293
+ payload: row.payload,
294
+ metadata: row.metadata,
295
+ createdAt: row.createdAt,
296
+ createdBy: row.createdBy,
297
+ };
298
+ }
299
+
300
+ // Deliver events to the consumer's handler in events.id order. Halt-on-
301
+ // poison: a throw breaks the loop, the cursor stays at the last successful
302
+ // event, and attempts climb. At maxAttempts the caller persists status=
303
+ // "dead" and the consumer is parked until ops intervenes (see
304
+ // restartConsumer / skipPoisonEvent).
305
+ async function deliverEvents(
306
+ consumer: EventConsumer,
307
+ events: ReadonlyArray<typeof eventsTable.$inferSelect>,
308
+ context: AppContext,
309
+ maxAttempts: number,
310
+ state: ConsumerStateRow,
311
+ ): Promise<DeliveryOutcome> {
312
+ let cursor = state.lastProcessedEventId;
313
+ let attempts = state.attempts;
314
+ let lastError: string | null = state.lastError ?? null;
315
+ let deadLettered = false;
316
+ let processed = 0;
317
+ let failed = 0;
318
+
319
+ for (const row of events) {
320
+ try {
321
+ // Propagate causation: if the handler calls ctx.appendEvent, the new
322
+ // event should record THIS event as its cause. correlationId is
323
+ // inherited unchanged — it survives the hop across streams by design.
324
+ // requestId falls back to a fresh id because the dispatcher runs
325
+ // outside any HTTP request (background poll), and a stable log-
326
+ // correlation handle is still useful for debugging.
327
+ const stored = rowToStoredEvent(row);
328
+ const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
329
+ const causationId = String(stored.id);
330
+ const requestId = requestContext.generateId();
331
+ await requestContext.run({ requestId, correlationId, causationId }, async () => {
332
+ await consumer.handler(stored, context);
333
+ });
334
+ cursor = row.id;
335
+ attempts = 0;
336
+ lastError = null;
337
+ processed += 1;
338
+ } catch (e) {
339
+ const errMessage = e instanceof Error ? e.message : String(e);
340
+ if (consumer.errorPolicy?.skipApplyErrors) {
341
+ // Best-effort mode: record the error on the skip counter so ops
342
+ // can alert on a spike of skipped events, advance the cursor past
343
+ // the bad event, keep going. The consumer stays "idle", not "dead".
344
+ // Also emit a warn-level log line — the metric tells ops THAT events
345
+ // are being dropped, the log tells them WHICH events. Without this
346
+ // a poisoned-then-skipped event is invisible to forensic search.
347
+ const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
348
+ emitDispatcherError(context.meter ?? getFallbackMeter(), {
349
+ handler: consumer.name,
350
+ errorClass,
351
+ });
352
+ context.log?.warn(
353
+ `event-dispatcher: ${consumer.name} skipped event ${row.id} (${errorClass}): ${errMessage}`,
354
+ );
355
+ cursor = row.id;
356
+ attempts = 0;
357
+ lastError = null;
358
+ failed += 1;
359
+ continue;
360
+ }
361
+ attempts += 1;
362
+ lastError = errMessage;
363
+ failed += 1;
364
+ if (attempts >= maxAttempts) deadLettered = true;
365
+ break;
366
+ }
367
+ }
368
+
369
+ return { cursor, attempts, lastError, deadLettered, processed, failed };
370
+ }
371
+
372
+ async function persistConsumerOutcome(
373
+ tx: DbTx,
374
+ name: string,
375
+ instanceId: string,
376
+ outcome: DeliveryOutcome,
377
+ ): Promise<void> {
378
+ await tx
379
+ .update(eventConsumerStateTable)
380
+ .set({
381
+ lastProcessedEventId: outcome.cursor,
382
+ attempts: outcome.attempts,
383
+ status: outcome.deadLettered ? "dead" : "idle",
384
+ lastError: outcome.lastError,
385
+ updatedAt: sql`now()`,
386
+ })
387
+ .where(
388
+ and(
389
+ eq(eventConsumerStateTable.name, name),
390
+ eq(eventConsumerStateTable.instanceId, instanceId),
391
+ ),
392
+ );
393
+ }
394
+
395
+ // Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
396
+ // consistent with the cursor we just advanced to. `MAX(id)` on the events
397
+ // table is an O(1) reverse-index scan — cheap even under load.
398
+ async function emitLagFromTx(
399
+ tx: DbTx,
400
+ consumerName: string,
401
+ instanceId: string,
402
+ cursor: bigint,
403
+ meter: Meter,
404
+ ): Promise<void> {
405
+ const result = await tx.execute(
406
+ sql`SELECT COALESCE(MAX(id), 0)::bigint AS head FROM kumiko_events`,
407
+ );
408
+ // @cast-boundary db-row — raw drizzle.execute() COALESCE-aggregate row
409
+ const rows = Array.isArray(result) ? (result as Array<{ head?: bigint | string | null }>) : [];
410
+ const raw = rows[0]?.head;
411
+ const head = typeof raw === "bigint" ? raw : BigInt(raw ?? 0);
412
+ const lag = head > cursor ? Number(head - cursor) : 0;
413
+ emitEventConsumerLag(meter, { consumer: consumerName, instanceId }, lag);
414
+ }
415
+
416
+ export function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher {
417
+ const {
418
+ db,
419
+ consumers,
420
+ context,
421
+ batchSize = DEFAULT_BATCH_SIZE,
422
+ pollIntervalMs = DEFAULT_POLL_MS,
423
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
424
+ } = options;
425
+
426
+ // Fail-fast on misconfigured per-instance wiring. Catching this at
427
+ // construction surfaces the problem in boot logs instead of first
428
+ // delivery attempt — where it would land as a confusing preRegister
429
+ // throw much later in the startup sequence.
430
+ for (const consumer of consumers) {
431
+ if (consumer.delivery === "per-instance" && !options.instanceId) {
432
+ throw new Error(
433
+ `EventConsumer "${consumer.name}" has delivery="per-instance" but EventDispatcherOptions.instanceId is missing. ` +
434
+ `Pass ServerOptions.instanceId (defaults to KUMIKO_INSTANCE_ID or a boot-time UUID) when any consumer uses per-instance delivery.`,
435
+ );
436
+ }
437
+ }
438
+ if (options.instanceId === SHARED_INSTANCE_SENTINEL) {
439
+ throw new Error(
440
+ `EventDispatcherOptions.instanceId cannot equal the reserved sentinel "${SHARED_INSTANCE_SENTINEL}". ` +
441
+ `Pick any other stable string (typically KUMIKO_INSTANCE_ID from the deploy env).`,
442
+ );
443
+ }
444
+ const tracer: Tracer = options.tracer ?? getFallbackTracer();
445
+ const meter: Meter = options.meter ?? getFallbackMeter();
446
+
447
+ let running = false;
448
+ // Separate from `running` on purpose: pre-registration of consumer state
449
+ // rows is a one-time boot action, while running/timer/LISTEN is a
450
+ // lifecycle toggle. stop() flips running back to false but leaves
451
+ // preRegistered true — a subsequent runOnce() is still safe because the
452
+ // state rows are in place. Production code never stops-then-runs-once;
453
+ // tests do (drain on-demand without a timer loop).
454
+ let preRegistered = false;
455
+ let timer: ReturnType<typeof setInterval> | null = null;
456
+ // LISTEN subscription handle. Set when .start() successfully subscribed
457
+ // to EVENTS_PUBSUB_CHANNEL; cleared by .stop(). The timer remains active
458
+ // even with LISTEN attached — it's a cheap safety net against missed
459
+ // NOTIFYs (subscription drop, crash mid-commit).
460
+ let pgUnlisten: (() => Promise<void>) | null = null;
461
+
462
+ // Serialises concurrent runOnce() calls from both wake-up sources (timer
463
+ // + any future explicit nudge). Mirrors outbox-poller's passInFlight
464
+ // pattern so behaviour under races stays predictable.
465
+ let passInFlight: Promise<DispatcherPassResult> | null = null;
466
+
467
+ async function runOnce(): Promise<DispatcherPassResult> {
468
+ if (!preRegistered) {
469
+ throw new Error(
470
+ "EventDispatcher.runOnce() called before start() — consumer state rows are not registered. Call start() first (production) or ensureRegistered() (tests after truncating kumiko_event_consumers).",
471
+ );
472
+ }
473
+ if (passInFlight) return passInFlight;
474
+ passInFlight = doPass();
475
+ try {
476
+ return await passInFlight;
477
+ } finally {
478
+ passInFlight = null;
479
+ }
480
+ }
481
+
482
+ async function doPass(): Promise<DispatcherPassResult> {
483
+ let totalProcessed = 0;
484
+ let totalFailed = 0;
485
+ const byConsumer: Record<string, { processed: number; failed: number }> = {};
486
+
487
+ // Feature-toggle snapshot taken once per pass (not per consumer): all
488
+ // consumers see the same disabled-set even if an operator flips a
489
+ // toggle mid-pass, so "this event batch" decisions stay consistent.
490
+ const effective = context.effectiveFeatures?.();
491
+
492
+ // Seriell pro consumer. Parallelisierung wäre möglich (je eigene TX), aber
493
+ // das einfache Modell reicht für v1 — jeder consumer hat geringe
494
+ // per-event-Arbeit (network call at worst). Bei hunderten Events pro
495
+ // Batch lohnt sich Parallelisierung — Optimierung für später.
496
+ for (const consumer of consumers) {
497
+ // Feature-gate: consumers tagged with a featureName get paused while
498
+ // that feature is globally disabled. Cursor stays put — events accumulate
499
+ // and are re-delivered in order when the feature is re-enabled.
500
+ if (effective && consumer.featureName && !effective.has(consumer.featureName)) {
501
+ byConsumer[consumer.name] = { processed: 0, failed: 0 };
502
+ continue;
503
+ }
504
+ const perConsumer = await processConsumer(consumer);
505
+ byConsumer[consumer.name] = perConsumer;
506
+ totalProcessed += perConsumer.processed;
507
+ totalFailed += perConsumer.failed;
508
+ }
509
+
510
+ return { processed: totalProcessed, failed: totalFailed, byConsumer };
511
+ }
512
+
513
+ async function processConsumer(
514
+ consumer: EventConsumer,
515
+ ): Promise<{ processed: number; failed: number }> {
516
+ let processed = 0;
517
+ let failed = 0;
518
+
519
+ const instanceId = consumerInstanceId(consumer, options.instanceId);
520
+
521
+ const span = tracer.startSpan("events.consumer.pass", {
522
+ attributes: {
523
+ "consumer.name": consumer.name,
524
+ "consumer.instance_id": instanceId,
525
+ },
526
+ });
527
+
528
+ try {
529
+ await db.transaction(async (tx) => {
530
+ const acquired = await acquireConsumerState(tx, consumer.name, instanceId);
531
+ // skip: another instance holds the lock, or the consumer is
532
+ // disabled/dead. Nothing to deliver this pass.
533
+ if (acquired.skip !== null) {
534
+ span.setAttribute("consumer.skip_reason", acquired.skip);
535
+ return;
536
+ }
537
+ await markProcessing(tx, consumer.name, instanceId);
538
+
539
+ const events = await fetchPendingEvents(tx, acquired.state.lastProcessedEventId, batchSize);
540
+ const outcome = await deliverEvents(consumer, events, context, maxAttempts, acquired.state);
541
+ processed = outcome.processed;
542
+ failed = outcome.failed;
543
+
544
+ await persistConsumerOutcome(tx, consumer.name, instanceId, outcome);
545
+ await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter);
546
+ });
547
+
548
+ emitEventConsumerPassOutcome(
549
+ meter,
550
+ { consumer: consumer.name, instanceId },
551
+ processed,
552
+ failed,
553
+ );
554
+ span.setAttribute("consumer.processed", processed);
555
+ span.setAttribute("consumer.failed", failed);
556
+ span.setStatus(failed === 0 ? "ok" : "error");
557
+ } catch (e) {
558
+ // Unexpected: a handler error is caught inside deliverEvents and
559
+ // surfaces via `failed`, so anything landing here is infrastructure
560
+ // (db connection lost, serialization, standard-metrics not registered
561
+ // on this meter). Don't let one consumer's outage stall the others,
562
+ // but do log — a silent rollback here looks like "at-most-once" to
563
+ // callers and at-least-once-with-duplicate-delivery on the next pass;
564
+ // neither is what we want, so ops needs to see it.
565
+ const msg = e instanceof Error ? e.message : String(e);
566
+ context.log?.error(`[event-dispatcher] ${consumer.name} pass failed: ${msg}`);
567
+ span.setStatus("error", msg);
568
+ } finally {
569
+ span.end();
570
+ }
571
+
572
+ return { processed, failed };
573
+ }
574
+
575
+ return {
576
+ consumers,
577
+ async start() {
578
+ // skip: already running, idempotent
579
+ if (running) return;
580
+ running = true;
581
+
582
+ // Pre-register consumer state rows. Without this, a consumer first
583
+ // bootstraps lazily on its first runOnce — and if prune runs between
584
+ // "process came up" and "first pass landed", prune wouldn't see the
585
+ // consumer in the state table, would delete events past its (absent)
586
+ // cursor, and the consumer's first pass would silently skip them.
587
+ //
588
+ // Pre-registering turns every consumer into a row-with-cursor-0 the
589
+ // moment the dispatcher starts — so the retention guard
590
+ // (pruneEvents → ConsumerLagError) correctly refuses to prune past
591
+ // any consumer that exists, including freshly-deployed ones.
592
+ await preRegisterConsumers(db, consumers, options.instanceId);
593
+ preRegistered = true;
594
+
595
+ timer = setInterval(() => {
596
+ void runOnce().catch(() => {
597
+ // skip: per-consumer errors already recorded in the state row
598
+ });
599
+ }, pollIntervalMs);
600
+
601
+ // NOTIFY-based wake-up: subscribe on the same channel that
602
+ // event-store.append fires on commit. Fires runOnce directly, no
603
+ // polling round-trip. The timer stays on as a belt-and-braces
604
+ // fallback (dropped subscriptions, missed commits under load).
605
+ //
606
+ // Observability: the gauge kumiko_event_dispatcher_listen_connected
607
+ // flips to 1 on initial subscribe AND on every postgres.js silent
608
+ // reconnect (via the onlisten callback). A drop to 0 while running
609
+ // means delivery latency regressed from TCP-round-trip to
610
+ // pollIntervalMs — ops-visible.
611
+ emitEventDispatcherListenConnected(meter, false);
612
+ if (options.pgClient) {
613
+ try {
614
+ const sub = await options.pgClient.listen(
615
+ EVENTS_PUBSUB_CHANNEL,
616
+ () => {
617
+ void runOnce().catch(() => {
618
+ // skip: per-consumer errors already recorded in the state row
619
+ });
620
+ },
621
+ () => {
622
+ // Fires on initial connect AND on each reconnect. postgres.js
623
+ // reconnects transparently if the TCP connection drops, so the
624
+ // only way to see the recovery window is to flip the gauge
625
+ // every time this callback lands.
626
+ emitEventDispatcherListenConnected(meter, true);
627
+ },
628
+ );
629
+ pgUnlisten = sub.unlisten;
630
+ } catch (e) {
631
+ emitEventDispatcherListenConnected(meter, false);
632
+ const msg = e instanceof Error ? e.message : String(e);
633
+ context.log?.error(`[event-dispatcher] pg LISTEN failed: ${msg}`);
634
+ }
635
+ }
636
+ },
637
+
638
+ async stop() {
639
+ // skip: already stopped, idempotent
640
+ if (!running) return;
641
+ running = false;
642
+
643
+ if (timer) {
644
+ clearInterval(timer);
645
+ timer = null;
646
+ }
647
+
648
+ if (pgUnlisten) {
649
+ await pgUnlisten().catch(() => {
650
+ // skip: unlisten failure only matters during shutdown — the
651
+ // subscription is being torn down anyway.
652
+ });
653
+ pgUnlisten = null;
654
+ emitEventDispatcherListenConnected(meter, false);
655
+ }
656
+
657
+ // Drain any in-flight pass so shutdown observes consistent state.
658
+ if (passInFlight) {
659
+ await passInFlight.catch(() => {
660
+ // skip: errors already recorded per-consumer inside the pass
661
+ });
662
+ }
663
+ // preRegistered stays true — the rows survive stop(). runOnce()
664
+ // after a stop() still works (tests stop the timer and then drain
665
+ // deterministically).
666
+ },
667
+
668
+ async ensureRegistered() {
669
+ await preRegisterConsumers(db, consumers, options.instanceId);
670
+ preRegistered = true;
671
+ },
672
+
673
+ runOnce,
674
+ };
675
+ }
676
+
677
+ // --- Ops recovery surface ---
678
+ //
679
+ // These are intentionally verb-distinct; each maps to a CLI sub-command.
680
+ // They all target a single consumer row by name. Every call returns the
681
+ // state after the write so the CLI can echo what actually changed.
682
+ //
683
+ // Semantics:
684
+ // restartConsumer status="dead" → "idle", attempts=0, lastError=null.
685
+ // Cursor unchanged → next pass retries the SAME event
686
+ // that poisoned the consumer. For transient failures.
687
+ // disableConsumer status=* → "disabled". Dispatcher skips this consumer
688
+ // until enableConsumer() flips it back.
689
+ // enableConsumer status="disabled" → "idle". No-op on any other state.
690
+ // skipPoisonEvent cursor advances past the first event after the
691
+ // current cursor (the one that's failing). attempts=0,
692
+ // lastError=null, status="idle". For events that will
693
+ // never succeed (broken payload, removed feature code).
694
+
695
+ function normalizeConsumerState(
696
+ row: typeof eventConsumerStateTable.$inferSelect,
697
+ ): ConsumerRecoveryState {
698
+ return {
699
+ name: row.name,
700
+ instanceId: row.instanceId,
701
+ status: row.status,
702
+ lastProcessedEventId: row.lastProcessedEventId,
703
+ attempts: row.attempts,
704
+ lastError: row.lastError,
705
+ updatedAt: row.updatedAt,
706
+ };
707
+ }
708
+
709
+ export type ConsumerRecoveryState = {
710
+ readonly name: string;
711
+ readonly instanceId: string;
712
+ readonly status: string;
713
+ readonly lastProcessedEventId: bigint;
714
+ readonly attempts: number;
715
+ readonly lastError: string | null;
716
+ readonly updatedAt: Temporal.Instant;
717
+ };
718
+
719
+ // Ops calls default to the SHARED_INSTANCE_SENTINEL row — that's the only
720
+ // row shared-delivery consumers have, so legacy CLI invocations without
721
+ // --instance-id keep working. Per-instance consumers require an explicit
722
+ // instanceId: picking one of N shards arbitrarily ("first row wins") or
723
+ // mutating all shards simultaneously ("bounce every instance") are both
724
+ // worse than a loud missing-arg error on the CLI.
725
+ async function requireConsumerRow(
726
+ db: DbConnection,
727
+ name: string,
728
+ instanceId: string,
729
+ ): Promise<typeof eventConsumerStateTable.$inferSelect> {
730
+ const [row] = await db
731
+ .select()
732
+ .from(eventConsumerStateTable)
733
+ .where(
734
+ and(
735
+ eq(eventConsumerStateTable.name, name),
736
+ eq(eventConsumerStateTable.instanceId, instanceId),
737
+ ),
738
+ );
739
+ if (!row) {
740
+ throw new Error(
741
+ `Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
742
+ `For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`,
743
+ );
744
+ }
745
+ return row;
746
+ }
747
+
748
+ export async function restartConsumer(
749
+ db: DbConnection,
750
+ name: string,
751
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
752
+ ): Promise<ConsumerRecoveryState> {
753
+ const before = await requireConsumerRow(db, name, instanceId);
754
+ if (before.status !== "dead") {
755
+ throw new Error(
756
+ `Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`,
757
+ );
758
+ }
759
+ const [updated] = await db
760
+ .update(eventConsumerStateTable)
761
+ .set({ status: "idle", attempts: 0, lastError: null, updatedAt: sql`now()` })
762
+ .where(
763
+ and(
764
+ eq(eventConsumerStateTable.name, name),
765
+ eq(eventConsumerStateTable.instanceId, instanceId),
766
+ ),
767
+ )
768
+ .returning();
769
+ if (!updated) {
770
+ throw new Error(
771
+ `Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
772
+ );
773
+ }
774
+ return normalizeConsumerState(updated);
775
+ }
776
+
777
+ export async function disableConsumer(
778
+ db: DbConnection,
779
+ name: string,
780
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
781
+ ): Promise<ConsumerRecoveryState> {
782
+ await requireConsumerRow(db, name, instanceId);
783
+ const [updated] = await db
784
+ .update(eventConsumerStateTable)
785
+ .set({ status: "disabled", updatedAt: sql`now()` })
786
+ .where(
787
+ and(
788
+ eq(eventConsumerStateTable.name, name),
789
+ eq(eventConsumerStateTable.instanceId, instanceId),
790
+ ),
791
+ )
792
+ .returning();
793
+ if (!updated) {
794
+ throw new Error(
795
+ `Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
796
+ );
797
+ }
798
+ return normalizeConsumerState(updated);
799
+ }
800
+
801
+ export async function enableConsumer(
802
+ db: DbConnection,
803
+ name: string,
804
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
805
+ ): Promise<ConsumerRecoveryState> {
806
+ const before = await requireConsumerRow(db, name, instanceId);
807
+ if (before.status !== "disabled") {
808
+ throw new Error(
809
+ `Consumer "${name}" (instance_id="${instanceId}") is not disabled (status="${before.status}"). Enable only flips disabled → idle; use "restart" for a dead consumer.`,
810
+ );
811
+ }
812
+ const [updated] = await db
813
+ .update(eventConsumerStateTable)
814
+ .set({ status: "idle", attempts: 0, lastError: null, updatedAt: sql`now()` })
815
+ .where(
816
+ and(
817
+ eq(eventConsumerStateTable.name, name),
818
+ eq(eventConsumerStateTable.instanceId, instanceId),
819
+ ),
820
+ )
821
+ .returning();
822
+ if (!updated) {
823
+ throw new Error(
824
+ `Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
825
+ );
826
+ }
827
+ return normalizeConsumerState(updated);
828
+ }
829
+
830
+ // skipPoisonEvent advances the cursor past the first event after the
831
+ // current cursor. Single TX so concurrent dispatcher passes can't double-
832
+ // advance. If no event exists past the cursor, there is nothing to skip —
833
+ // treat as idempotent no-op (cursor already at head).
834
+ export async function skipPoisonEvent(
835
+ db: DbConnection,
836
+ name: string,
837
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
838
+ ): Promise<ConsumerRecoveryState & { readonly skippedEventId: bigint | null }> {
839
+ const before = await requireConsumerRow(db, name, instanceId);
840
+ return db.transaction(async (tx) => {
841
+ const [poison] = (await tx
842
+ .select({ id: eventsTable.id })
843
+ .from(eventsTable)
844
+ .where(gt(eventsTable.id, before.lastProcessedEventId))
845
+ .orderBy(asc(eventsTable.id))
846
+ .limit(1)) as ReadonlyArray<{ id: bigint }>;
847
+ if (!poison) {
848
+ const [unchanged] = await tx
849
+ .select()
850
+ .from(eventConsumerStateTable)
851
+ .where(
852
+ and(
853
+ eq(eventConsumerStateTable.name, name),
854
+ eq(eventConsumerStateTable.instanceId, instanceId),
855
+ ),
856
+ );
857
+ if (!unchanged)
858
+ throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished — retry.`);
859
+ return { ...normalizeConsumerState(unchanged), skippedEventId: null };
860
+ }
861
+ const [updated] = await tx
862
+ .update(eventConsumerStateTable)
863
+ .set({
864
+ lastProcessedEventId: poison.id,
865
+ status: "idle",
866
+ attempts: 0,
867
+ lastError: null,
868
+ updatedAt: sql`now()`,
869
+ })
870
+ .where(
871
+ and(
872
+ eq(eventConsumerStateTable.name, name),
873
+ eq(eventConsumerStateTable.instanceId, instanceId),
874
+ ),
875
+ )
876
+ .returning();
877
+ if (!updated)
878
+ throw new Error(
879
+ `Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`,
880
+ );
881
+ return { ...normalizeConsumerState(updated), skippedEventId: poison.id };
882
+ });
883
+ }
884
+
885
+ // Read-only status for one consumer shard — CLI surface.
886
+ export async function getConsumerState(
887
+ db: DbConnection,
888
+ name: string,
889
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
890
+ ): Promise<{
891
+ readonly name: string;
892
+ readonly instanceId: string;
893
+ readonly status: string;
894
+ readonly lastProcessedEventId: bigint;
895
+ readonly attempts: number;
896
+ readonly lastError: string | null;
897
+ readonly updatedAt: Temporal.Instant;
898
+ } | null> {
899
+ const [row] = await db
900
+ .select()
901
+ .from(eventConsumerStateTable)
902
+ .where(
903
+ and(
904
+ eq(eventConsumerStateTable.name, name),
905
+ eq(eventConsumerStateTable.instanceId, instanceId),
906
+ ),
907
+ );
908
+ if (!row) return null;
909
+ return {
910
+ name: row.name,
911
+ instanceId: row.instanceId,
912
+ status: row.status,
913
+ lastProcessedEventId: row.lastProcessedEventId,
914
+ attempts: row.attempts,
915
+ lastError: row.lastError,
916
+ updatedAt: row.updatedAt,
917
+ };
918
+ }
919
+
920
+ // List every consumer the registry knows about, joined with all shard rows
921
+ // from the state table. One entry per (name, instance_id) shard. Consumers
922
+ // that have never run appear with status="never-run" and instance_id =
923
+ // SHARED_INSTANCE_SENTINEL — a placeholder, because without a running
924
+ // dispatcher we can't know the instance-ids of per-instance consumers yet.
925
+ // Mirrors listProjectionsWithState — the registry (not the DB) is the
926
+ // source-of-truth for which consumer-names exist; the DB is the source-
927
+ // of-truth for which instance-shards have been seen.
928
+ export async function listConsumersWithState(
929
+ db: DbConnection,
930
+ registeredNames: readonly string[],
931
+ ): Promise<
932
+ ReadonlyArray<{
933
+ readonly name: string;
934
+ readonly instanceId: string;
935
+ readonly status: string;
936
+ readonly lastProcessedEventId: bigint;
937
+ readonly attempts: number;
938
+ readonly lastError: string | null;
939
+ }>
940
+ > {
941
+ const stateRows = await db.select().from(eventConsumerStateTable);
942
+ const registered = new Set(registeredNames);
943
+
944
+ // Materialize one output row per (name, instance_id). Registered names
945
+ // without any shard (never-run) get a placeholder row so ops can still
946
+ // see the name exists.
947
+ const out: Array<{
948
+ name: string;
949
+ instanceId: string;
950
+ status: string;
951
+ lastProcessedEventId: bigint;
952
+ attempts: number;
953
+ lastError: string | null;
954
+ }> = [];
955
+
956
+ const seenNames = new Set<string>();
957
+ for (const r of stateRows) {
958
+ if (!registered.has(r.name)) continue; // stale row from an older deploy
959
+ seenNames.add(r.name);
960
+ out.push({
961
+ name: r.name,
962
+ instanceId: r.instanceId,
963
+ status: r.status,
964
+ lastProcessedEventId: r.lastProcessedEventId,
965
+ attempts: r.attempts,
966
+ lastError: r.lastError,
967
+ });
968
+ }
969
+ for (const name of registeredNames) {
970
+ if (seenNames.has(name)) continue;
971
+ out.push({
972
+ name,
973
+ instanceId: SHARED_INSTANCE_SENTINEL,
974
+ status: "never-run",
975
+ lastProcessedEventId: 0n,
976
+ attempts: 0,
977
+ lastError: null,
978
+ });
979
+ }
980
+ return out;
981
+ }
982
+
983
+ export type ConsumerProgress = {
984
+ readonly name: string;
985
+ readonly instanceId: string;
986
+ readonly status: string;
987
+ readonly lastProcessedEventId: bigint;
988
+ readonly attempts: number;
989
+ readonly lastError: string | null;
990
+ // Global MAX(events.id) at query time.
991
+ readonly highWaterMark: bigint;
992
+ // HWM - cursor. 0n when caught-up. Disabled consumers often show high
993
+ // lag intentionally (ops parks them before pruning).
994
+ readonly lag: bigint;
995
+ };
996
+
997
+ // Like listConsumersWithState, but also returns HWM + lag per consumer.
998
+ // Async consumers (MSPs) lag behind inline projections because they run
999
+ // post-commit — lag is the primary signal for backpressure, dead consumers,
1000
+ // or dispatcher stalls. Programmatic callers can map the result to a
1001
+ // `kumiko_consumer_lag{name}` Prometheus gauge.
1002
+ export async function getAllConsumerProgress(
1003
+ db: DbConnection,
1004
+ registeredNames: readonly string[],
1005
+ ): Promise<readonly ConsumerProgress[]> {
1006
+ const [consumers, highWaterMark] = await Promise.all([
1007
+ listConsumersWithState(db, registeredNames),
1008
+ getEventsHighWaterMark(db),
1009
+ ]);
1010
+
1011
+ return consumers.map((c) => ({
1012
+ ...c,
1013
+ highWaterMark,
1014
+ lag: highWaterMark - c.lastProcessedEventId,
1015
+ }));
1016
+ }