@cosmicdrift/kumiko-framework 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (424) hide show
  1. package/README.md +9 -38
  2. package/package.json +21 -2
  3. package/src/__tests__/anonymous-access.integration.test.ts +185 -0
  4. package/src/__tests__/consumer-cli.integration.test.ts +172 -0
  5. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  6. package/src/__tests__/full-stack.integration.test.ts +1 -1
  7. package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
  8. package/src/__tests__/schema-cli.integration.test.ts +50 -0
  9. package/src/__tests__/store-table.integration.test.ts +93 -0
  10. package/src/api/__tests__/api.test.ts +373 -0
  11. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  13. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
  14. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
  15. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
  16. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
  17. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  18. package/src/api/__tests__/batch.integration.test.ts +101 -11
  19. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  20. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  21. package/src/api/__tests__/jwt.test.ts +200 -1
  22. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
  23. package/src/api/__tests__/pat-scope.test.ts +36 -0
  24. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  25. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
  26. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  27. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  28. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  29. package/src/api/__tests__/sse-broker.test.ts +57 -0
  30. package/src/api/__tests__/sse-route.test.ts +4 -0
  31. package/src/api/api-constants.ts +11 -0
  32. package/src/api/auth-middleware.ts +248 -40
  33. package/src/api/auth-routes.ts +576 -95
  34. package/src/api/index.ts +13 -4
  35. package/src/api/jwt.ts +170 -11
  36. package/src/api/pat-scope.ts +14 -0
  37. package/src/api/pii-leak-guard.ts +47 -0
  38. package/src/api/request-context.ts +3 -0
  39. package/src/api/request-id-middleware.ts +2 -0
  40. package/src/api/routes.ts +169 -2
  41. package/src/api/server.ts +112 -16
  42. package/src/api/sse-broker.ts +39 -0
  43. package/src/bun-db/__tests__/PATTERN.md +0 -1
  44. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
  45. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  46. package/src/bun-db/__tests__/write-brand.test.ts +21 -0
  47. package/src/bun-db/connection.ts +3 -3
  48. package/src/bun-db/index.ts +2 -0
  49. package/src/bun-db/query.ts +105 -32
  50. package/src/consumer-cli.ts +134 -0
  51. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  52. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  53. package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
  54. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  55. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  56. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  57. package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
  58. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  59. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  60. package/src/crypto/blind-index.ts +118 -0
  61. package/src/crypto/event-pii.ts +70 -0
  62. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  63. package/src/crypto/index.ts +67 -0
  64. package/src/crypto/kms-adapter.ts +2 -0
  65. package/src/crypto/pg-kms-adapter.ts +295 -0
  66. package/src/crypto/pii-field-encryption.ts +248 -0
  67. package/src/crypto/request-kms-cache.ts +38 -0
  68. package/src/crypto/subject-resolver.ts +91 -0
  69. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
  70. package/src/db/__tests__/blind-index.integration.test.ts +248 -0
  71. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  72. package/src/db/__tests__/collect-table-metas.test.ts +10 -10
  73. package/src/db/__tests__/config-seed.integration.test.ts +13 -5
  74. package/src/db/__tests__/dialect-instant.test.ts +1 -4
  75. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  76. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  77. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  78. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
  79. package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
  80. package/src/db/__tests__/feature-table-sources.test.ts +34 -0
  81. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
  82. package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
  83. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  84. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  85. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  86. package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
  87. package/src/db/__tests__/rebuild-marker.test.ts +13 -3
  88. package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
  89. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  90. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  91. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  92. package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
  93. package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
  94. package/src/db/api.ts +2 -2
  95. package/src/db/apply-entity-event.ts +18 -14
  96. package/src/db/blind-index-cleanup.ts +55 -0
  97. package/src/db/bun-provider.ts +2 -2
  98. package/src/db/collect-table-metas.ts +6 -7
  99. package/src/db/config-seed.ts +9 -9
  100. package/src/db/connection.ts +7 -12
  101. package/src/db/cursor.ts +1 -18
  102. package/src/db/dialect.ts +20 -26
  103. package/src/db/entity-field-encryption.ts +81 -24
  104. package/src/db/entity-table-meta-types.ts +2 -0
  105. package/src/db/entity-table-meta.ts +63 -90
  106. package/src/db/event-store-executor-context.ts +404 -0
  107. package/src/db/event-store-executor-read.ts +275 -0
  108. package/src/db/event-store-executor-write.ts +644 -0
  109. package/src/db/event-store-executor.ts +28 -1155
  110. package/src/db/feature-table-sources.ts +6 -5
  111. package/src/db/index.ts +20 -3
  112. package/src/db/located-timestamp.ts +4 -0
  113. package/src/db/migrate-generator.ts +39 -6
  114. package/src/db/migrate-runner.ts +107 -11
  115. package/src/db/pg-error.ts +9 -1
  116. package/src/db/postgres-provider.ts +2 -2
  117. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  118. package/src/db/queries/backfill-pii.ts +277 -0
  119. package/src/db/queries/ddl.ts +45 -0
  120. package/src/db/queries/event-consumer.ts +35 -2
  121. package/src/db/queries/event-store.ts +126 -19
  122. package/src/db/queries/projection-rebuild.ts +22 -8
  123. package/src/db/queries/shadow-swap.ts +183 -0
  124. package/src/db/queries/test-stack.ts +4 -30
  125. package/src/db/query-api.ts +1 -0
  126. package/src/db/query.ts +1 -0
  127. package/src/db/rebuild-marker.ts +18 -2
  128. package/src/db/reference-data.ts +2 -3
  129. package/src/db/replay-migration-sql.ts +257 -0
  130. package/src/db/schema-inspection.ts +1 -1
  131. package/src/db/table-builder.ts +102 -71
  132. package/src/db/tenant-db.ts +15 -53
  133. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  134. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  135. package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
  136. package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
  137. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
  138. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
  139. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  140. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
  141. package/src/engine/__tests__/boot-validator.test.ts +219 -15
  142. package/src/engine/__tests__/build-app-schema.test.ts +82 -0
  143. package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
  144. package/src/engine/__tests__/build-target.test.ts +3 -11
  145. package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
  146. package/src/engine/__tests__/config-helpers.test.ts +16 -0
  147. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  148. package/src/engine/__tests__/define-roles.test.ts +21 -0
  149. package/src/engine/__tests__/engine.test.ts +163 -1
  150. package/src/engine/__tests__/entity-handlers.test.ts +80 -0
  151. package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
  152. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  153. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  154. package/src/engine/__tests__/factories-time.test.ts +2 -66
  155. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  156. package/src/engine/__tests__/feature-manifest.test.ts +31 -1
  157. package/src/engine/__tests__/field-access.test.ts +23 -1
  158. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  159. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  160. package/src/engine/__tests__/nav.test.ts +86 -1
  161. package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
  162. package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
  163. package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
  164. package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
  165. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
  166. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  167. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  168. package/src/engine/__tests__/registry.test.ts +144 -0
  169. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  170. package/src/engine/__tests__/screen.test.ts +147 -1
  171. package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
  172. package/src/engine/__tests__/store-table.test.ts +229 -0
  173. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  174. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
  175. package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
  176. package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
  177. package/src/engine/boot-validator/action-wiring.ts +149 -0
  178. package/src/engine/boot-validator/boot-check.ts +21 -0
  179. package/src/engine/boot-validator/config-deps.ts +40 -4
  180. package/src/engine/boot-validator/entity-handler.ts +20 -21
  181. package/src/engine/boot-validator/entity-list-screens.ts +88 -0
  182. package/src/engine/boot-validator/gdpr-storage.ts +0 -20
  183. package/src/engine/boot-validator/i18n-keys.ts +58 -4
  184. package/src/engine/boot-validator/index.ts +33 -12
  185. package/src/engine/boot-validator/nav.ts +125 -0
  186. package/src/engine/boot-validator/pii-retention.ts +95 -10
  187. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
  188. package/src/engine/boot-validator/workspaces.ts +68 -0
  189. package/src/engine/build-app-schema.ts +16 -0
  190. package/src/engine/build-config-feature-schema.ts +44 -7
  191. package/src/engine/codemod/pipeline-codemod.ts +5 -5
  192. package/src/engine/config-helpers.ts +15 -0
  193. package/src/engine/constants.ts +32 -6
  194. package/src/engine/create-app.ts +11 -0
  195. package/src/engine/define-feature.ts +95 -947
  196. package/src/engine/define-handler.ts +28 -94
  197. package/src/engine/define-workflow.ts +1 -1
  198. package/src/engine/effective-features.ts +12 -2
  199. package/src/engine/entity-handlers.ts +76 -11
  200. package/src/engine/extensions/tenant-data.ts +19 -0
  201. package/src/engine/extensions/user-data.ts +29 -2
  202. package/src/engine/factories.ts +8 -49
  203. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
  204. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  205. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  206. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  207. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  208. package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
  209. package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
  210. package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
  211. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
  212. package/src/engine/feature-ast/extractors/events.ts +322 -0
  213. package/src/engine/feature-ast/extractors/handlers.ts +234 -0
  214. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  215. package/src/engine/feature-ast/extractors/index.ts +34 -31
  216. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  217. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  218. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  219. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  220. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  221. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  222. package/src/engine/feature-ast/index.ts +2 -4
  223. package/src/engine/feature-ast/parse.ts +130 -23
  224. package/src/engine/feature-ast/patch.ts +39 -50
  225. package/src/engine/feature-ast/patcher.ts +37 -38
  226. package/src/engine/feature-ast/patterns.ts +50 -62
  227. package/src/engine/feature-ast/render.ts +67 -44
  228. package/src/engine/feature-builder-state.ts +168 -0
  229. package/src/engine/feature-config-events-jobs.ts +403 -0
  230. package/src/engine/feature-entity-handlers.ts +208 -0
  231. package/src/engine/feature-manifest.ts +2 -1
  232. package/src/engine/feature-ui-extensions.ts +500 -0
  233. package/src/engine/field-access.ts +13 -2
  234. package/src/engine/field-helpers.ts +31 -0
  235. package/src/engine/handler-helpers.ts +26 -0
  236. package/src/engine/hook-helpers.ts +16 -0
  237. package/src/engine/index.ts +23 -7
  238. package/src/engine/membership-roles.ts +13 -0
  239. package/src/engine/object-form.ts +27 -0
  240. package/src/engine/ownership.ts +26 -79
  241. package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
  242. package/src/engine/pattern-library/library.ts +44 -1152
  243. package/src/engine/pattern-library/mixed-schemas.ts +450 -0
  244. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  245. package/src/engine/pattern-library/shared-fields.ts +75 -0
  246. package/src/engine/pattern-library/static-schemas.ts +456 -0
  247. package/src/engine/pipeline.ts +6 -11
  248. package/src/engine/registry-facade.ts +362 -0
  249. package/src/engine/registry-ingest.ts +478 -0
  250. package/src/engine/registry-state.ts +388 -0
  251. package/src/engine/registry-validate.ts +642 -0
  252. package/src/engine/registry.ts +78 -1658
  253. package/src/engine/run-pipeline.ts +1 -1
  254. package/src/engine/schema-builder.ts +1 -0
  255. package/src/engine/screen-helpers.ts +54 -0
  256. package/src/engine/soft-delete-cleanup.ts +6 -2
  257. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  258. package/src/engine/steps/_duration-utils.ts +2 -0
  259. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  260. package/src/engine/tier-resolver-extension.ts +3 -2
  261. package/src/engine/types/config.ts +2 -482
  262. package/src/engine/types/define-handler.ts +2 -0
  263. package/src/engine/types/entity-handlers.ts +2 -0
  264. package/src/engine/types/event-type-map.ts +1 -37
  265. package/src/engine/types/feature.ts +2 -972
  266. package/src/engine/types/fields.ts +2 -675
  267. package/src/engine/types/handlers.ts +2 -774
  268. package/src/engine/types/hooks.ts +2 -184
  269. package/src/engine/types/http-route.ts +1 -54
  270. package/src/engine/types/identifiers.ts +1 -47
  271. package/src/engine/types/index.ts +86 -40
  272. package/src/engine/types/nav.ts +2 -63
  273. package/src/engine/types/ownership.ts +2 -0
  274. package/src/engine/types/projection.ts +2 -138
  275. package/src/engine/types/relations.ts +1 -51
  276. package/src/engine/types/screen.ts +2 -574
  277. package/src/engine/types/step.ts +2 -334
  278. package/src/engine/types/target-ref.ts +1 -21
  279. package/src/engine/types/tree-node.ts +1 -132
  280. package/src/engine/types/workspace.ts +2 -49
  281. package/src/engine/validate-projection-allowlist.ts +6 -6
  282. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
  283. package/src/entrypoint/index.ts +49 -6
  284. package/src/errors/classes.ts +22 -1
  285. package/src/errors/field-issue.ts +0 -3
  286. package/src/errors/index.ts +1 -1
  287. package/src/errors/write-error-info.ts +10 -22
  288. package/src/es-ops/README.md +1 -1
  289. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  290. package/src/es-ops/context.ts +4 -2
  291. package/src/es-ops/types.ts +8 -1
  292. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  293. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  294. package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
  295. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  296. package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
  297. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
  298. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  299. package/src/event-store/admin-api.ts +11 -4
  300. package/src/event-store/errors.ts +2 -35
  301. package/src/event-store/event-store.ts +64 -78
  302. package/src/event-store/events-schema.ts +11 -13
  303. package/src/event-store/index.ts +18 -3
  304. package/src/event-store/rebuild-dead-letter.ts +111 -0
  305. package/src/event-store/snapshot.ts +63 -40
  306. package/src/event-store/types.ts +2 -0
  307. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  308. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  309. package/src/files/__tests__/files.integration.test.ts +24 -0
  310. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  311. package/src/files/__tests__/local-provider.test.ts +31 -0
  312. package/src/files/__tests__/provider-resolver.test.ts +70 -0
  313. package/src/files/__tests__/write-stream.test.ts +13 -0
  314. package/src/files/file-handle.ts +2 -19
  315. package/src/files/file-ref-entity.ts +2 -2
  316. package/src/files/file-routes.ts +13 -0
  317. package/src/files/index.ts +1 -1
  318. package/src/files/local-provider.ts +27 -8
  319. package/src/files/provider-resolver.ts +31 -20
  320. package/src/files/types.ts +13 -55
  321. package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
  322. package/src/i18n/required-surface-keys.ts +120 -1
  323. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  324. package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
  325. package/src/jobs/job-runner.ts +82 -12
  326. package/src/logging/types.ts +1 -7
  327. package/src/migrations/pending-rebuilds.ts +1 -1
  328. package/src/observability/__tests__/observability.integration.test.ts +4 -1
  329. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  330. package/src/observability/index.ts +2 -0
  331. package/src/observability/noop-provider.ts +0 -9
  332. package/src/observability/recording-tracer.ts +0 -12
  333. package/src/observability/standard-metrics.ts +64 -2
  334. package/src/observability/types/index.ts +1 -29
  335. package/src/observability/types/metric.ts +1 -56
  336. package/src/observability/types/provider.ts +1 -32
  337. package/src/observability/types/span.ts +1 -64
  338. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  339. package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
  340. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  341. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  342. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
  343. package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
  344. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  345. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
  346. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  347. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  348. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
  349. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  350. package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
  351. package/src/pipeline/dispatch-batch.ts +187 -0
  352. package/src/pipeline/dispatch-query.ts +165 -0
  353. package/src/pipeline/dispatch-shared.ts +826 -0
  354. package/src/pipeline/dispatch-stream.ts +90 -0
  355. package/src/pipeline/dispatch-write.ts +451 -0
  356. package/src/pipeline/dispatcher-utils.ts +1 -1
  357. package/src/pipeline/dispatcher.ts +44 -1372
  358. package/src/pipeline/entity-cache.ts +2 -33
  359. package/src/pipeline/event-consumer-state.ts +30 -2
  360. package/src/pipeline/event-dispatcher-admin.ts +293 -0
  361. package/src/pipeline/event-dispatcher-delivery.ts +305 -0
  362. package/src/pipeline/event-dispatcher.ts +85 -542
  363. package/src/pipeline/index.ts +2 -0
  364. package/src/pipeline/msp-rebuild.ts +42 -3
  365. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  366. package/src/pipeline/projection-rebuild.ts +105 -3
  367. package/src/pipeline/system-hooks.ts +144 -1
  368. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  369. package/src/rate-limit/resolver.ts +16 -32
  370. package/src/schema-cli.ts +76 -16
  371. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
  372. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  373. package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
  374. package/src/search/index.ts +6 -0
  375. package/src/search/meilisearch-adapter.ts +13 -12
  376. package/src/search/reindex-entity.ts +177 -0
  377. package/src/search/types.ts +1 -39
  378. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  379. package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
  380. package/src/secrets/__tests__/envelope.test.ts +1 -1
  381. package/src/secrets/dek-cache.ts +26 -5
  382. package/src/secrets/envelope-cipher.ts +53 -0
  383. package/src/secrets/envelope.ts +5 -3
  384. package/src/secrets/index.ts +13 -1
  385. package/src/secrets/stored-envelope.ts +46 -0
  386. package/src/secrets/types.ts +2 -162
  387. package/src/stack/__tests__/event-collector.test.ts +42 -0
  388. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
  389. package/src/stack/db.ts +2 -1
  390. package/src/stack/push-entity-projection-tables.ts +4 -3
  391. package/src/stack/redis.ts +8 -0
  392. package/src/stack/request-helper.ts +38 -2
  393. package/src/stack/table-helpers.ts +6 -4
  394. package/src/stack/test-stack.ts +249 -131
  395. package/src/testing/__tests__/late-bound.test.ts +32 -0
  396. package/src/testing/__tests__/wait-for.test.ts +59 -0
  397. package/src/testing/boot-validator-fixture.ts +121 -0
  398. package/src/testing/e2e-generator.ts +8 -0
  399. package/src/testing/file-provider-contract.ts +104 -0
  400. package/src/testing/handler-context.ts +3 -1
  401. package/src/testing/index.ts +5 -0
  402. package/src/testing/late-bound.ts +5 -3
  403. package/src/testing/mutable-master-key-provider.ts +29 -3
  404. package/src/testing/wait-for.ts +3 -0
  405. package/src/testing/without-ambient-temporal.ts +14 -0
  406. package/src/time/__tests__/tz-dateline.test.ts +8 -10
  407. package/src/time/geo-tz.ts +1 -32
  408. package/src/time/index.ts +1 -0
  409. package/src/time/legacy-date.ts +18 -0
  410. package/src/time/polyfill.ts +21 -38
  411. package/src/time/tz-context.ts +44 -85
  412. package/src/ui-types/app-schema.ts +12 -0
  413. package/src/ui-types/index.ts +20 -7
  414. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  415. package/src/utils/safe-json.ts +13 -1
  416. package/src/__tests__/raw-table.integration.test.ts +0 -116
  417. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  418. package/src/db/__tests__/encryption.test.ts +0 -39
  419. package/src/db/encryption.ts +0 -39
  420. package/src/db/row-helpers.ts +0 -4
  421. package/src/engine/__tests__/raw-table.test.ts +0 -150
  422. package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
  423. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
  424. package/src/engine/feature-ast/extractors/round4.ts +0 -1366
@@ -1,3 +1,4 @@
1
+ import type { EntityCache } from "@cosmicdrift/kumiko-types/entity-cache";
1
2
  import type Redis from "ioredis";
2
3
  import type { EntityId, TenantId } from "../engine/types/identifiers";
3
4
  import { RedisKeys } from "./redis-keys";
@@ -23,39 +24,7 @@ function parseCached(raw: string): Record<string, unknown> | null {
23
24
  }
24
25
  }
25
26
 
26
- export type EntityCache = {
27
- /** Get a single cached entity. Returns null on miss. */
28
- get(
29
- tenantId: TenantId,
30
- entityName: string,
31
- id: EntityId,
32
- ): Promise<Record<string, unknown> | null>;
33
-
34
- /** Get multiple cached entities. Returns a Map of id → data (misses are absent). */
35
- mget(
36
- tenantId: TenantId,
37
- entityName: string,
38
- ids: readonly EntityId[],
39
- ): Promise<Map<EntityId, Record<string, unknown>>>;
40
-
41
- /** Cache a single entity. */
42
- set(
43
- tenantId: TenantId,
44
- entityName: string,
45
- id: EntityId,
46
- data: Record<string, unknown>,
47
- ): Promise<void>;
48
-
49
- /** Cache multiple entities at once. */
50
- mset(
51
- tenantId: TenantId,
52
- entityName: string,
53
- entries: ReadonlyArray<{ id: EntityId; data: Record<string, unknown> }>,
54
- ): Promise<void>;
55
-
56
- /** Invalidate a single cached entity. */
57
- del(tenantId: TenantId, entityName: string, id: EntityId): Promise<void>;
58
- };
27
+ export type { EntityCache } from "@cosmicdrift/kumiko-types/entity-cache";
59
28
 
60
29
  export type EntityCacheOptions = {
61
30
  ttlSeconds?: number;
@@ -10,6 +10,7 @@ import {
10
10
  sql,
11
11
  text,
12
12
  } from "../db/dialect";
13
+ import { alterTableAddColumn } from "../db/queries/ddl";
13
14
  import { tableExists } from "../db/schema-inspection";
14
15
  import { unsafePushTables } from "../stack";
15
16
 
@@ -72,6 +73,14 @@ export const eventConsumerStateTable = pgTable(
72
73
  .default(sql`0`),
73
74
  status: text("status").notNull().default("idle"),
74
75
  attempts: integer("attempts").notNull().default(0),
76
+ // Counts automatic dead→idle revivals since the last delivery that
77
+ // advanced the cursor (event-dispatcher.ts's cooldown re-arm, see
78
+ // acquireConsumerState). A poison event never advances the cursor, so
79
+ // this climbs to maxRearmCount and then stays dead permanently. A
80
+ // successful delivery resets it to 0 (proof the consumer isn't
81
+ // poisoned), as does a manual restartConsumer()/enableConsumer()/
82
+ // skipPoisonEvent() — an operator vouching the consumer is healthy.
83
+ rearmCount: integer("rearm_count").notNull().default(0),
75
84
  lastError: text("last_error"),
76
85
  updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql`now()`),
77
86
  },
@@ -99,7 +108,26 @@ export type ConsumerStatus = (typeof ConsumerStatuses)[keyof typeof ConsumerStat
99
108
  //
100
109
  // guard:dup-ok — intentionale Parallele zu createProjectionStateTable; symmetrische State-Tabellen by design
101
110
  export async function createEventConsumerStateTable(db: DbConnection): Promise<void> {
102
- // skip: table already exists — bootstrap is called from multiple paths
103
- if (await tableExists(db, "public.kumiko_event_consumers")) return;
111
+ // skip: table already exists — bootstrap is called from multiple paths.
112
+ // Still check for columns added after the table's first deploy (e.g.
113
+ // rearm_count) so an older DB catches up without a dedicated migration.
114
+ if (await tableExists(db, "public.kumiko_event_consumers")) {
115
+ // ADD COLUMN IF NOT EXISTS is idempotent and race-safe on its own —
116
+ // no need to pre-check columnNamesOf first. Two instances booting
117
+ // concurrently against the same DB (this table is explicitly
118
+ // multi-instance) both ALTER, neither crashes on "column already
119
+ // exists" (#1362).
120
+ await alterTableAddColumn(
121
+ db,
122
+ "kumiko_event_consumers",
123
+ "rearm_count",
124
+ "integer",
125
+ " DEFAULT 0",
126
+ " NOT NULL",
127
+ /* ifNotExists */ true,
128
+ );
129
+ // skip: table (+ any missing column) is already up to date
130
+ return;
131
+ }
104
132
  await unsafePushTables(db, { kumikoEventConsumers: eventConsumerStateTable });
105
133
  }
@@ -0,0 +1,293 @@
1
+ import type { DbConnection, DbTx } from "../db/connection";
2
+ import {
3
+ advanceConsumerPastEventReturning,
4
+ updateConsumerStatusReturning,
5
+ } from "../db/queries/event-consumer";
6
+ import { selectNextEventIdAfter } from "../db/queries/event-store";
7
+ import { coerceRow, extractTableInfo, selectMany } from "../db/query";
8
+ import { getEventsHighWaterMark } from "../event-store";
9
+ import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
10
+ import type { ConsumerStateRow, ConsumerStateRowShape } from "./event-dispatcher-delivery";
11
+
12
+ // --- Ops recovery surface ---
13
+ //
14
+ // These are intentionally verb-distinct; each maps to a CLI sub-command.
15
+ // They all target a single consumer row by name. Every call returns the
16
+ // state after the write so the CLI can echo what actually changed.
17
+ //
18
+ // Semantics:
19
+ // restartConsumer status="dead" → "idle", attempts=0, lastError=null.
20
+ // Cursor unchanged → next pass retries the SAME event
21
+ // that poisoned the consumer. For transient failures.
22
+ // disableConsumer status=* → "disabled". Dispatcher skips this consumer
23
+ // until enableConsumer() flips it back.
24
+ // enableConsumer status="disabled" → "idle". No-op on any other state.
25
+ // skipPoisonEvent cursor advances past the first event after the
26
+ // current cursor (the one that's failing). attempts=0,
27
+ // lastError=null, status="idle". For events that will
28
+ // never succeed (broken payload, removed feature code).
29
+
30
+ function normalizeConsumerState(row: ConsumerStateRowShape): ConsumerRecoveryState {
31
+ return {
32
+ name: row.name,
33
+ instanceId: row.instanceId,
34
+ status: row.status,
35
+ lastProcessedEventId: row.lastProcessedEventId,
36
+ attempts: row.attempts,
37
+ rearmCount: row.rearmCount,
38
+ lastError: row.lastError,
39
+ updatedAt: row.updatedAt,
40
+ };
41
+ }
42
+
43
+ export type ConsumerRecoveryState = {
44
+ readonly name: string;
45
+ readonly instanceId: string;
46
+ readonly status: string;
47
+ readonly lastProcessedEventId: bigint;
48
+ readonly attempts: number;
49
+ readonly rearmCount: number;
50
+ readonly lastError: string | null;
51
+ readonly updatedAt: Temporal.Instant;
52
+ };
53
+
54
+ // Ops calls default to the SHARED_INSTANCE_SENTINEL row — that's the only
55
+ // row shared-delivery consumers have, so legacy CLI invocations without
56
+ // --instance-id keep working. Per-instance consumers require an explicit
57
+ // instanceId: picking one of N shards arbitrarily ("first row wins") or
58
+ // mutating all shards simultaneously ("bounce every instance") are both
59
+ // worse than a loud missing-arg error on the CLI.
60
+ async function requireConsumerRow(
61
+ db: DbConnection,
62
+ name: string,
63
+ instanceId: string,
64
+ ): Promise<ConsumerStateRowShape> {
65
+ const [row] = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable, {
66
+ name,
67
+ instanceId,
68
+ });
69
+ if (!row) {
70
+ throw new Error(
71
+ `Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
72
+ `For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`,
73
+ );
74
+ }
75
+ return row;
76
+ }
77
+
78
+ async function applyConsumerStatusTransition(
79
+ db: DbConnection,
80
+ name: string,
81
+ instanceId: string,
82
+ targetStatus: "idle" | "disabled",
83
+ ): Promise<ConsumerRecoveryState> {
84
+ const raw = await updateConsumerStatusReturning(db, name, instanceId, targetStatus);
85
+ const updated =
86
+ raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
87
+ if (!updated) {
88
+ throw new Error(
89
+ `Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
90
+ );
91
+ }
92
+ return normalizeConsumerState(updated);
93
+ }
94
+
95
+ export async function restartConsumer(
96
+ db: DbConnection,
97
+ name: string,
98
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
99
+ ): Promise<ConsumerRecoveryState> {
100
+ const before = await requireConsumerRow(db, name, instanceId);
101
+ if (before.status !== "dead") {
102
+ throw new Error(
103
+ `Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`,
104
+ );
105
+ }
106
+ return applyConsumerStatusTransition(db, name, instanceId, "idle");
107
+ }
108
+
109
+ export async function disableConsumer(
110
+ db: DbConnection,
111
+ name: string,
112
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
113
+ ): Promise<ConsumerRecoveryState> {
114
+ await requireConsumerRow(db, name, instanceId);
115
+ return applyConsumerStatusTransition(db, name, instanceId, "disabled");
116
+ }
117
+
118
+ export async function enableConsumer(
119
+ db: DbConnection,
120
+ name: string,
121
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
122
+ ): Promise<ConsumerRecoveryState> {
123
+ const before = await requireConsumerRow(db, name, instanceId);
124
+ if (before.status !== "disabled") {
125
+ throw new Error(
126
+ `Consumer "${name}" (instance_id="${instanceId}") is not disabled (status="${before.status}"). Enable only flips disabled → idle; use "restart" for a dead consumer.`,
127
+ );
128
+ }
129
+ return applyConsumerStatusTransition(db, name, instanceId, "idle");
130
+ }
131
+
132
+ // skipPoisonEvent advances the cursor past the first event after the
133
+ // current cursor. Single TX so concurrent dispatcher passes can't double-
134
+ // advance. If no event exists past the cursor, there is nothing to skip —
135
+ // treat as idempotent no-op (cursor already at head).
136
+ export async function skipPoisonEvent(
137
+ db: DbConnection,
138
+ name: string,
139
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
140
+ ): Promise<ConsumerRecoveryState & { readonly skippedEventId: bigint | null }> {
141
+ const before = await requireConsumerRow(db, name, instanceId);
142
+ return db.begin(async (tx: DbTx) => {
143
+ const poisonId = await selectNextEventIdAfter(tx, before.lastProcessedEventId);
144
+ if (poisonId === null) {
145
+ const [unchanged] = await selectMany<ConsumerStateRow>(tx, eventConsumerStateTable, {
146
+ name,
147
+ instanceId,
148
+ });
149
+ if (!unchanged)
150
+ throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished — retry.`);
151
+ return { ...normalizeConsumerState(unchanged), skippedEventId: null };
152
+ }
153
+ const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId);
154
+ const updated =
155
+ raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
156
+ if (!updated)
157
+ throw new Error(
158
+ `Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`,
159
+ );
160
+ return { ...normalizeConsumerState(updated), skippedEventId: poisonId };
161
+ });
162
+ }
163
+
164
+ // Read-only status for one consumer shard — CLI surface.
165
+ export async function getConsumerState(
166
+ db: DbConnection,
167
+ name: string,
168
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
169
+ ): Promise<{
170
+ readonly name: string;
171
+ readonly instanceId: string;
172
+ readonly status: string;
173
+ readonly lastProcessedEventId: bigint;
174
+ readonly attempts: number;
175
+ readonly rearmCount: number;
176
+ readonly lastError: string | null;
177
+ readonly updatedAt: Temporal.Instant;
178
+ } | null> {
179
+ const [row] = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable, {
180
+ name,
181
+ instanceId,
182
+ });
183
+ if (!row) return null;
184
+ return {
185
+ name: row.name,
186
+ instanceId: row.instanceId,
187
+ status: row.status,
188
+ lastProcessedEventId: row.lastProcessedEventId,
189
+ attempts: row.attempts,
190
+ rearmCount: row.rearmCount,
191
+ lastError: row.lastError,
192
+ updatedAt: row.updatedAt,
193
+ };
194
+ }
195
+
196
+ // List every consumer the registry knows about, joined with all shard rows
197
+ // from the state table. One entry per (name, instance_id) shard. Consumers
198
+ // that have never run appear with status="never-run" and instance_id =
199
+ // SHARED_INSTANCE_SENTINEL — a placeholder, because without a running
200
+ // dispatcher we can't know the instance-ids of per-instance consumers yet.
201
+ // Mirrors listProjectionsWithState — the registry (not the DB) is the
202
+ // source-of-truth for which consumer-names exist; the DB is the source-
203
+ // of-truth for which instance-shards have been seen.
204
+ export async function listConsumersWithState(
205
+ db: DbConnection,
206
+ registeredNames: readonly string[],
207
+ ): Promise<
208
+ ReadonlyArray<{
209
+ readonly name: string;
210
+ readonly instanceId: string;
211
+ readonly status: string;
212
+ readonly lastProcessedEventId: bigint;
213
+ readonly attempts: number;
214
+ readonly lastError: string | null;
215
+ }>
216
+ > {
217
+ const stateRows = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable);
218
+ const registered = new Set(registeredNames);
219
+
220
+ // Materialize one output row per (name, instance_id). Registered names
221
+ // without any shard (never-run) get a placeholder row so ops can still
222
+ // see the name exists.
223
+ const out: Array<{
224
+ name: string;
225
+ instanceId: string;
226
+ status: string;
227
+ lastProcessedEventId: bigint;
228
+ attempts: number;
229
+ lastError: string | null;
230
+ }> = [];
231
+
232
+ const seenNames = new Set<string>();
233
+ for (const r of stateRows) {
234
+ if (!registered.has(r.name)) continue; // stale row from an older deploy
235
+ seenNames.add(r.name);
236
+ out.push({
237
+ name: r.name,
238
+ instanceId: r.instanceId,
239
+ status: r.status,
240
+ lastProcessedEventId: r.lastProcessedEventId,
241
+ attempts: r.attempts,
242
+ lastError: r.lastError,
243
+ });
244
+ }
245
+ for (const name of registeredNames) {
246
+ if (seenNames.has(name)) continue;
247
+ out.push({
248
+ name,
249
+ instanceId: SHARED_INSTANCE_SENTINEL,
250
+ status: "never-run",
251
+ lastProcessedEventId: 0n,
252
+ attempts: 0,
253
+ lastError: null,
254
+ });
255
+ }
256
+ return out;
257
+ }
258
+
259
+ export type ConsumerProgress = {
260
+ readonly name: string;
261
+ readonly instanceId: string;
262
+ readonly status: string;
263
+ readonly lastProcessedEventId: bigint;
264
+ readonly attempts: number;
265
+ readonly lastError: string | null;
266
+ // Global MAX(events.id) at query time.
267
+ readonly highWaterMark: bigint;
268
+ // HWM - cursor. 0n when caught-up. Disabled consumers often show high
269
+ // lag intentionally (ops parks them before pruning).
270
+ readonly lag: bigint;
271
+ };
272
+
273
+ // Like listConsumersWithState, but also returns HWM + lag per consumer.
274
+ // Async consumers (MSPs) lag behind inline projections because they run
275
+ // post-commit — lag is the primary signal for backpressure, dead consumers,
276
+ // or dispatcher stalls. Programmatic callers can map the result to a
277
+ // `kumiko_consumer_lag{name}` Prometheus gauge.
278
+ // guard:dup-ok — intentionale Parallele zu getAllProjectionProgress; Consumer ≠ Projection (verschiedene Subsysteme)
279
+ export async function getAllConsumerProgress(
280
+ db: DbConnection,
281
+ registeredNames: readonly string[],
282
+ ): Promise<readonly ConsumerProgress[]> {
283
+ const [consumers, highWaterMark] = await Promise.all([
284
+ listConsumersWithState(db, registeredNames),
285
+ getEventsHighWaterMark(db),
286
+ ]);
287
+
288
+ return consumers.map((c) => ({
289
+ ...c,
290
+ highWaterMark,
291
+ lag: highWaterMark - c.lastProcessedEventId,
292
+ }));
293
+ }
@@ -0,0 +1,305 @@
1
+ // Value-only import, aliased to avoid shadowing the ambient global
2
+ // `Temporal` TYPE that ConsumerStateRow.updatedAt/StoredEventRow.createdAt
3
+ // resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
4
+ import { Temporal as TemporalPolyfill } from "temporal-polyfill";
5
+ import { requestContext } from "../api/request-context";
6
+ import type { DbConnection, DbTx } from "../db/connection";
7
+ import {
8
+ insertConsumerIfAbsent,
9
+ markConsumerProcessing,
10
+ rearmDeadConsumer,
11
+ selectConsumerForUpdateSkipLocked,
12
+ updateConsumerDeliveryOutcome,
13
+ } from "../db/queries/event-consumer";
14
+ import { selectEventsHeadId } from "../db/queries/event-store";
15
+ import { coerceRow, extractTableInfo, selectMany } from "../db/query";
16
+ import type { AppContext } from "../engine/types";
17
+ import { eventsTable, toStoredEvent as rowToStoredEvent } from "../event-store";
18
+ import {
19
+ emitDispatcherError,
20
+ emitEventConsumerLag,
21
+ getFallbackMeter,
22
+ type Meter,
23
+ } from "../observability";
24
+ import {
25
+ ConsumerStatuses,
26
+ eventConsumerStateTable,
27
+ SHARED_INSTANCE_SENTINEL,
28
+ } from "./event-consumer-state";
29
+ import type { EventConsumer } from "./event-dispatcher";
30
+
31
+ // Per-consumer pass mechanics: acquire the state row, fetch pending events,
32
+ // hand them to the consumer's handler in order, persist the outcome. Split
33
+ // out of event-dispatcher.ts so the delivery loop is independently readable
34
+ // from the public lifecycle surface (start/stop/runOnce) and the ops
35
+ // recovery surface (event-dispatcher-admin.ts).
36
+ //
37
+ // Free functions (not closures) — every helper takes an explicit `tx`, none
38
+ // use the outer dispatcher's closure state.
39
+
40
+ export type ConsumerStateRowShape = {
41
+ readonly name: string;
42
+ readonly instanceId: string;
43
+ readonly lastProcessedEventId: bigint;
44
+ readonly status: string;
45
+ readonly attempts: number;
46
+ readonly rearmCount: number;
47
+ readonly lastError: string | null;
48
+ readonly updatedAt: Temporal.Instant;
49
+ };
50
+ export type ConsumerStateRow = ConsumerStateRowShape;
51
+
52
+ export type StoredEventRow = {
53
+ readonly id: bigint;
54
+ readonly aggregateId: string;
55
+ readonly aggregateType: string;
56
+ readonly tenantId: string;
57
+ readonly version: number;
58
+ readonly type: string;
59
+ readonly eventVersion: number;
60
+ readonly payload: Record<string, unknown>;
61
+ readonly metadata: import("../event-store/event-store").EventMetadata;
62
+ readonly createdAt: Temporal.Instant;
63
+ readonly createdBy: string;
64
+ };
65
+
66
+ export type AcquireOutcome =
67
+ | { readonly state: ConsumerStateRow; readonly skip: null }
68
+ | {
69
+ readonly state: null;
70
+ readonly skip: "locked_by_other_instance" | "disabled" | "dead" | "not_registered";
71
+ };
72
+
73
+ // Lock the consumer's state row with SKIP LOCKED. Strict: no in-tx bootstrap.
74
+ // The row must exist — start() pre-registers every consumer up front so
75
+ // prune (event-retention) sees their cursors as soon as the process is up,
76
+ // closing the race where a lazy-bootstrapped consumer's cursor is absent
77
+ // during prune and its events are silently deleted.
78
+ //
79
+ // skip="not_registered" signals a row-missing-despite-start condition.
80
+ // Production shouldn't hit this — it means either start() wasn't called
81
+ // (runOnce() guards against that) or the state row was deleted externally
82
+ // (a test TRUNCATE without subsequent ensureRegistered(), or an operator
83
+ // intervention). Skipping quietly preserves the dispatcher's other
84
+ // consumers and surfaces the issue via the metrics pass-outcome.
85
+ export async function acquireConsumerState(
86
+ tx: DbTx,
87
+ name: string,
88
+ instanceId: string,
89
+ rearmCooldownMs: number,
90
+ maxRearmCount: number,
91
+ ): Promise<AcquireOutcome> {
92
+ const rawState = await selectConsumerForUpdateSkipLocked(tx, name, instanceId);
93
+
94
+ if (!rawState) {
95
+ return { state: null, skip: "not_registered" };
96
+ }
97
+
98
+ const state = coerceRow(rawState, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow;
99
+
100
+ if (!state) {
101
+ // Either the row never existed (no pre-reg, no ensureRegistered) or
102
+ // another instance currently holds the lock with SKIP LOCKED filtering
103
+ // us out. We can't distinguish here in a single query, so return
104
+ // "not_registered" — ops sees a skip-reason instead of silent delivery
105
+ // loss. Under normal operation (start() called, no external tampering)
106
+ // this path is never taken.
107
+ return { state: null, skip: "not_registered" };
108
+ }
109
+
110
+ if (state.status === ConsumerStatuses.disabled) return { state: null, skip: "disabled" };
111
+ if (state.status === ConsumerStatuses.dead) {
112
+ // Bounded auto-revival: a transient failure (e.g. a Meilisearch blip)
113
+ // shouldn't need an operator to notice and run restartConsumer() once
114
+ // the cause is long gone. Cooldown since the last write (the death or
115
+ // a prior re-arm) gates the retry; maxRearmCount stops a poison event
116
+ // from looping forever (re-arm → same event fails → dead → re-arm →
117
+ // ...) — after the cap it stays dead until a human intervenes.
118
+ const cooldownDeadline = TemporalPolyfill.Now.instant().subtract({
119
+ milliseconds: rearmCooldownMs,
120
+ });
121
+ // @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
122
+ // at runtime — state.updatedAt is DB-row-typed against the ambient
123
+ // global, two distinct nominal types across the two .d.ts sources (see
124
+ // event-store.ts).
125
+ const cooldownElapsed =
126
+ TemporalPolyfill.Instant.compare(
127
+ state.updatedAt as unknown as InstanceType<typeof TemporalPolyfill.Instant>,
128
+ cooldownDeadline,
129
+ ) <= 0;
130
+ if (cooldownElapsed && state.rearmCount < maxRearmCount) {
131
+ const rearmed = await rearmDeadConsumer(tx, name, instanceId);
132
+ const rearmedState =
133
+ rearmed &&
134
+ (coerceRow(rearmed, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
135
+ if (rearmedState) return { state: rearmedState, skip: null };
136
+ }
137
+ // Caller (event-dispatcher.ts's processConsumer) emits
138
+ // kumiko_event_consumer_rearm_exhausted_total once per (consumer,
139
+ // instance) transition into this branch — it has the process-lifetime
140
+ // state to dedupe across poll passes that this pure function doesn't.
141
+ return { state: null, skip: "dead" };
142
+ }
143
+ return { state, skip: null };
144
+ }
145
+
146
+ // Shared pre-registration: one row per (consumer, shard), cursor = 0,
147
+ // status = idle. Shared-delivery consumers use SHARED_INSTANCE_SENTINEL;
148
+ // per-instance consumers use the dispatcher's instanceId. Idempotent
149
+ // under restart and concurrent start-calls via ON CONFLICT DO NOTHING
150
+ // on the composite PK — never clobbers an existing cursor.
151
+ export async function preRegisterConsumers(
152
+ db: DbConnection,
153
+ consumers: readonly EventConsumer[],
154
+ dispatcherInstanceId: string | undefined,
155
+ ): Promise<void> {
156
+ for (const consumer of consumers) {
157
+ const instanceId = consumerInstanceId(consumer, dispatcherInstanceId);
158
+ await insertConsumerIfAbsent(db, consumer.name, instanceId);
159
+ }
160
+ }
161
+
162
+ // Resolve the instance_id column value for one consumer on this dispatcher.
163
+ // Shared stays at the sentinel; per-instance rides the dispatcher's id.
164
+ // Throws when a per-instance consumer is registered without an instanceId
165
+ // — missing at boot is the sharp-edge to catch, not at first delivery.
166
+ export function consumerInstanceId(
167
+ consumer: EventConsumer,
168
+ dispatcherInstanceId: string | undefined,
169
+ ): string {
170
+ if (consumer.delivery !== "per-instance") return SHARED_INSTANCE_SENTINEL;
171
+ if (!dispatcherInstanceId) {
172
+ throw new Error(
173
+ `EventConsumer "${consumer.name}" has delivery="per-instance" but the dispatcher was created without an instanceId — ` +
174
+ `pass EventDispatcherOptions.instanceId (typically from ServerOptions.instanceId / KUMIKO_INSTANCE_ID).`,
175
+ );
176
+ }
177
+ return dispatcherInstanceId;
178
+ }
179
+
180
+ // Mark the consumer row as "processing" for ops visibility. The SKIP LOCKED
181
+ // lock already guarantees single-writer semantics; this is purely
182
+ // informational (and resets on commit to idle/dead via persistConsumerOutcome).
183
+ export async function markProcessing(tx: DbTx, name: string, instanceId: string): Promise<void> {
184
+ await markConsumerProcessing(tx, name, instanceId);
185
+ }
186
+
187
+ export async function fetchPendingEvents(
188
+ tx: DbTx,
189
+ cursor: bigint,
190
+ batchSize: number,
191
+ ): Promise<ReadonlyArray<StoredEventRow>> {
192
+ return (await selectMany(
193
+ tx,
194
+ eventsTable,
195
+ { id: { gt: cursor } },
196
+ { orderBy: { col: "id", direction: "asc" }, limit: batchSize },
197
+ )) as ReadonlyArray<StoredEventRow>; // @cast-boundary db-row
198
+ }
199
+
200
+ export type DeliveryOutcome = {
201
+ readonly cursor: bigint;
202
+ readonly attempts: number;
203
+ readonly lastError: string | null;
204
+ readonly deadLettered: boolean;
205
+ readonly processed: number;
206
+ readonly failed: number;
207
+ };
208
+
209
+ // Deliver events to the consumer's handler in events.id order. Halt-on-
210
+ // poison: a throw breaks the loop, the cursor stays at the last successful
211
+ // event, and attempts climb. At the consumer's effectiveMaxAttempts
212
+ // (errorPolicy.maxAttempts ?? maxAttempts) the caller persists status=
213
+ // "dead" and the consumer is parked until ops intervenes (see
214
+ // restartConsumer / skipPoisonEvent).
215
+ export async function deliverEvents(
216
+ consumer: EventConsumer,
217
+ events: ReadonlyArray<StoredEventRow>,
218
+ context: AppContext,
219
+ maxAttempts: number,
220
+ state: ConsumerStateRow,
221
+ ): Promise<DeliveryOutcome> {
222
+ let cursor = state.lastProcessedEventId;
223
+ let attempts = state.attempts;
224
+ let lastError: string | null = state.lastError ?? null;
225
+ let deadLettered = false;
226
+ const effectiveMaxAttempts = consumer.errorPolicy?.maxAttempts ?? maxAttempts;
227
+ let processed = 0;
228
+ let failed = 0;
229
+
230
+ for (const row of events) {
231
+ try {
232
+ // Propagate causation: if the handler calls ctx.appendEvent, the new
233
+ // event should record THIS event as its cause. correlationId is
234
+ // inherited unchanged — it survives the hop across streams by design.
235
+ // requestId falls back to a fresh id because the dispatcher runs
236
+ // outside any HTTP request (background poll), and a stable log-
237
+ // correlation handle is still useful for debugging.
238
+ const stored = rowToStoredEvent(row);
239
+ const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
240
+ const causationId = String(stored.id);
241
+ const requestId = requestContext.generateId();
242
+ await requestContext.run({ requestId, correlationId, causationId }, async () => {
243
+ await consumer.handler(stored, context);
244
+ });
245
+ cursor = row.id;
246
+ attempts = 0;
247
+ lastError = null;
248
+ processed += 1;
249
+ } catch (e) {
250
+ const errMessage = e instanceof Error ? e.message : String(e);
251
+ if (consumer.errorPolicy?.skipApplyErrors) {
252
+ // Best-effort mode: record the error on the skip counter so ops
253
+ // can alert on a spike of skipped events, advance the cursor past
254
+ // the bad event, keep going. The consumer stays "idle", not "dead".
255
+ // Also emit a warn-level log line — the metric tells ops THAT events
256
+ // are being dropped, the log tells them WHICH events. Without this
257
+ // a poisoned-then-skipped event is invisible to forensic search.
258
+ const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
259
+ emitDispatcherError(context.meter ?? getFallbackMeter(), {
260
+ handler: consumer.name,
261
+ errorClass,
262
+ });
263
+ context.log?.warn(
264
+ `event-dispatcher: ${consumer.name} skipped event ${row.id} (${errorClass}): ${errMessage}`,
265
+ );
266
+ cursor = row.id;
267
+ attempts = 0;
268
+ lastError = null;
269
+ failed += 1;
270
+ continue;
271
+ }
272
+ attempts += 1;
273
+ lastError = errMessage;
274
+ failed += 1;
275
+ if (attempts >= effectiveMaxAttempts) deadLettered = true;
276
+ break;
277
+ }
278
+ }
279
+
280
+ return { cursor, attempts, lastError, deadLettered, processed, failed };
281
+ }
282
+
283
+ export async function persistConsumerOutcome(
284
+ tx: DbTx,
285
+ name: string,
286
+ instanceId: string,
287
+ outcome: DeliveryOutcome,
288
+ ): Promise<void> {
289
+ await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
290
+ }
291
+
292
+ // Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
293
+ // consistent with the cursor we just advanced to. `MAX(id)` on the events
294
+ // table is an O(1) reverse-index scan — cheap even under load.
295
+ export async function emitLagFromTx(
296
+ tx: DbTx,
297
+ consumerName: string,
298
+ instanceId: string,
299
+ cursor: bigint,
300
+ meter: Meter,
301
+ ): Promise<void> {
302
+ const head = await selectEventsHeadId(tx);
303
+ const lag = head > cursor ? Number(head - cursor) : 0;
304
+ emitEventConsumerLag(meter, { consumer: consumerName, instanceId }, lag);
305
+ }