@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,39 +1,27 @@
1
- import { requestContext } from "../api/request-context";
2
- import type { DbConnection, DbTx, PgClient } from "../db/connection";
3
- import {
4
- advanceConsumerPastEventReturning,
5
- insertConsumerIfAbsent,
6
- markConsumerProcessing,
7
- selectConsumerForUpdateSkipLocked,
8
- updateConsumerDeliveryOutcome,
9
- updateConsumerStatusReturning,
10
- } from "../db/queries/event-consumer";
11
- import { selectEventsHeadId, selectNextEventIdAfter } from "../db/queries/event-store";
12
- import { coerceRow, extractTableInfo, selectMany } from "../db/query";
1
+ import type { DbTx, PgClient } from "../db/connection";
13
2
  import type { AppContext } from "../engine/types";
14
3
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
4
+ import { EVENTS_PUBSUB_CHANNEL, type StoredEvent } from "../event-store";
15
5
  import {
16
- EVENTS_PUBSUB_CHANNEL,
17
- eventsTable,
18
- getEventsHighWaterMark,
19
- toStoredEvent as rowToStoredEvent,
20
- type StoredEvent,
21
- } from "../event-store";
22
- import {
23
- emitDispatcherError,
24
- emitEventConsumerLag,
25
6
  emitEventConsumerPassOutcome,
7
+ emitEventConsumerRearmExhausted,
26
8
  emitEventDispatcherListenConnected,
27
9
  getFallbackMeter,
28
10
  getFallbackTracer,
29
11
  type Meter,
30
12
  type Tracer,
31
13
  } from "../observability";
14
+ import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
32
15
  import {
33
- ConsumerStatuses,
34
- eventConsumerStateTable,
35
- SHARED_INSTANCE_SENTINEL,
36
- } from "./event-consumer-state";
16
+ acquireConsumerState,
17
+ consumerInstanceId,
18
+ deliverEvents,
19
+ emitLagFromTx,
20
+ fetchPendingEvents,
21
+ markProcessing,
22
+ persistConsumerOutcome,
23
+ preRegisterConsumers,
24
+ } from "./event-dispatcher-delivery";
37
25
 
38
26
  // Async event-dispatcher — the "AsyncDaemon"-pendant for Kumiko.
39
27
  //
@@ -64,6 +52,11 @@ import {
64
52
  // Delivery semantics: **at-least-once**. If a handler runs but the cursor
65
53
  // update fails (crash mid-pass), the same event is delivered again next pass.
66
54
  // Handlers MUST be idempotent.
55
+ //
56
+ // Delivery-loop mechanics (acquire/fetch/deliver/persist) live in
57
+ // event-dispatcher-delivery.ts; the ops recovery surface (restart/disable/
58
+ // enable/skipPoisonEvent/progress) lives in event-dispatcher-admin.ts. This
59
+ // file is the facade: public types + the createEventDispatcher() factory.
67
60
 
68
61
  export type EventConsumerHandler = (event: StoredEvent, ctx: AppContext) => Promise<void>;
69
62
 
@@ -74,6 +67,11 @@ export type EventConsumerHandler = (event: StoredEvent, ctx: AppContext) => Prom
74
67
  // the EventConsumer (see api/server.ts MSP wiring).
75
68
  export type EventConsumerErrorPolicy = {
76
69
  readonly skipApplyErrors?: boolean;
70
+ // Per-consumer override of EventDispatcherOptions.maxAttempts. A consumer
71
+ // that depends on infra which may still be provisioning at boot (search
72
+ // adapter, external APIs) needs more retry headroom than the dispatcher-
73
+ // wide default before it gets dead-lettered.
74
+ readonly maxAttempts?: number;
77
75
  };
78
76
 
79
77
  export type EventConsumer = {
@@ -131,12 +129,23 @@ export type EventDispatcher = {
131
129
  };
132
130
 
133
131
  export type EventDispatcherOptions = {
134
- readonly db: DbConnection;
132
+ readonly db: import("../db/connection").DbConnection;
135
133
  readonly consumers: readonly EventConsumer[];
136
134
  readonly context: AppContext;
137
135
  readonly batchSize?: number;
138
136
  readonly pollIntervalMs?: number;
139
137
  readonly maxAttempts?: number;
138
+ // Bounded auto-revival of a "dead" consumer. A dead consumer whose last
139
+ // write is older than rearmCooldownMs gets reset to idle and retried.
140
+ // rearmCount tracks re-arms since the last delivery that actually
141
+ // advanced the cursor — a poison event never advances it, so it climbs
142
+ // 1→maxRearmCount and then stays dead permanently; a transient failure
143
+ // that later delivers successfully resets it back to 0, so an unrelated
144
+ // future outage gets its own fresh budget. restartConsumer()/
145
+ // enableConsumer()/skipPoisonEvent() also reset it (an operator vouching
146
+ // the consumer is healthy again).
147
+ readonly rearmCooldownMs?: number;
148
+ readonly maxRearmCount?: number;
140
149
  readonly tracer?: Tracer;
141
150
  readonly meter?: Meter;
142
151
  // Identifies THIS dispatcher process in the consumer-state table. Used as
@@ -158,242 +167,8 @@ export type EventDispatcherOptions = {
158
167
  const DEFAULT_BATCH_SIZE = 200;
159
168
  const DEFAULT_POLL_MS = 100;
160
169
  const DEFAULT_MAX_ATTEMPTS = 10;
161
-
162
- // --- processConsumer helpers ---
163
- // Free functions (not closures) so they're independently readable and the
164
- // dispatcher's main pass logic stays under ~50 LOC. Every helper takes an
165
- // explicit `tx` — none of them use the outer dispatcher's closure state.
166
-
167
- type ConsumerStateRowShape = {
168
- readonly name: string;
169
- readonly instanceId: string;
170
- readonly lastProcessedEventId: bigint;
171
- readonly status: string;
172
- readonly attempts: number;
173
- readonly lastError: string | null;
174
- readonly updatedAt: Temporal.Instant;
175
- };
176
- type ConsumerStateRow = ConsumerStateRowShape;
177
-
178
- type StoredEventRow = {
179
- readonly id: bigint;
180
- readonly aggregateId: string;
181
- readonly aggregateType: string;
182
- readonly tenantId: string;
183
- readonly version: number;
184
- readonly type: string;
185
- readonly eventVersion: number;
186
- readonly payload: Record<string, unknown>;
187
- readonly metadata: import("../event-store/event-store").EventMetadata;
188
- readonly createdAt: Temporal.Instant;
189
- readonly createdBy: string;
190
- };
191
-
192
- type AcquireOutcome =
193
- | { readonly state: ConsumerStateRow; readonly skip: null }
194
- | {
195
- readonly state: null;
196
- readonly skip: "locked_by_other_instance" | "disabled" | "dead" | "not_registered";
197
- };
198
-
199
- // Lock the consumer's state row with SKIP LOCKED. Strict: no in-tx bootstrap.
200
- // The row must exist — start() pre-registers every consumer up front so
201
- // prune (event-retention) sees their cursors as soon as the process is up,
202
- // closing the race where a lazy-bootstrapped consumer's cursor is absent
203
- // during prune and its events are silently deleted.
204
- //
205
- // skip="not_registered" signals a row-missing-despite-start condition.
206
- // Production shouldn't hit this — it means either start() wasn't called
207
- // (runOnce() guards against that) or the state row was deleted externally
208
- // (a test TRUNCATE without subsequent ensureRegistered(), or an operator
209
- // intervention). Skipping quietly preserves the dispatcher's other
210
- // consumers and surfaces the issue via the metrics pass-outcome.
211
- async function acquireConsumerState(
212
- tx: DbTx,
213
- name: string,
214
- instanceId: string,
215
- ): Promise<AcquireOutcome> {
216
- const rawState = await selectConsumerForUpdateSkipLocked(tx, name, instanceId);
217
-
218
- if (!rawState) {
219
- return { state: null, skip: "not_registered" };
220
- }
221
-
222
- const state = coerceRow(rawState, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow;
223
-
224
- if (!state) {
225
- // Either the row never existed (no pre-reg, no ensureRegistered) or
226
- // another instance currently holds the lock with SKIP LOCKED filtering
227
- // us out. We can't distinguish here in a single query, so return
228
- // "not_registered" — ops sees a skip-reason instead of silent delivery
229
- // loss. Under normal operation (start() called, no external tampering)
230
- // this path is never taken.
231
- return { state: null, skip: "not_registered" };
232
- }
233
-
234
- if (state.status === ConsumerStatuses.disabled) return { state: null, skip: "disabled" };
235
- if (state.status === ConsumerStatuses.dead) return { state: null, skip: "dead" };
236
- return { state, skip: null };
237
- }
238
-
239
- // Shared pre-registration: one row per (consumer, shard), cursor = 0,
240
- // status = idle. Shared-delivery consumers use SHARED_INSTANCE_SENTINEL;
241
- // per-instance consumers use the dispatcher's instanceId. Idempotent
242
- // under restart and concurrent start-calls via ON CONFLICT DO NOTHING
243
- // on the composite PK — never clobbers an existing cursor.
244
- async function preRegisterConsumers(
245
- db: DbConnection,
246
- consumers: readonly EventConsumer[],
247
- dispatcherInstanceId: string | undefined,
248
- ): Promise<void> {
249
- for (const consumer of consumers) {
250
- const instanceId = consumerInstanceId(consumer, dispatcherInstanceId);
251
- await insertConsumerIfAbsent(db, consumer.name, instanceId);
252
- }
253
- }
254
-
255
- // Resolve the instance_id column value for one consumer on this dispatcher.
256
- // Shared stays at the sentinel; per-instance rides the dispatcher's id.
257
- // Throws when a per-instance consumer is registered without an instanceId
258
- // — missing at boot is the sharp-edge to catch, not at first delivery.
259
- function consumerInstanceId(
260
- consumer: EventConsumer,
261
- dispatcherInstanceId: string | undefined,
262
- ): string {
263
- if (consumer.delivery !== "per-instance") return SHARED_INSTANCE_SENTINEL;
264
- if (!dispatcherInstanceId) {
265
- throw new Error(
266
- `EventConsumer "${consumer.name}" has delivery="per-instance" but the dispatcher was created without an instanceId — ` +
267
- `pass EventDispatcherOptions.instanceId (typically from ServerOptions.instanceId / KUMIKO_INSTANCE_ID).`,
268
- );
269
- }
270
- return dispatcherInstanceId;
271
- }
272
-
273
- // Mark the consumer row as "processing" for ops visibility. The SKIP LOCKED
274
- // lock already guarantees single-writer semantics; this is purely
275
- // informational (and resets on commit to idle/dead via persistConsumerOutcome).
276
- async function markProcessing(tx: DbTx, name: string, instanceId: string): Promise<void> {
277
- await markConsumerProcessing(tx, name, instanceId);
278
- }
279
-
280
- async function fetchPendingEvents(
281
- tx: DbTx,
282
- cursor: bigint,
283
- batchSize: number,
284
- ): Promise<ReadonlyArray<StoredEventRow>> {
285
- return (await selectMany(
286
- tx,
287
- eventsTable,
288
- { id: { gt: cursor } },
289
- { orderBy: { col: "id", direction: "asc" }, limit: batchSize },
290
- )) as ReadonlyArray<StoredEventRow>; // @cast-boundary db-row
291
- }
292
-
293
- type DeliveryOutcome = {
294
- readonly cursor: bigint;
295
- readonly attempts: number;
296
- readonly lastError: string | null;
297
- readonly deadLettered: boolean;
298
- readonly processed: number;
299
- readonly failed: number;
300
- };
301
-
302
- // Deliver events to the consumer's handler in events.id order. Halt-on-
303
- // poison: a throw breaks the loop, the cursor stays at the last successful
304
- // event, and attempts climb. At maxAttempts the caller persists status=
305
- // "dead" and the consumer is parked until ops intervenes (see
306
- // restartConsumer / skipPoisonEvent).
307
- async function deliverEvents(
308
- consumer: EventConsumer,
309
- events: ReadonlyArray<StoredEventRow>,
310
- context: AppContext,
311
- maxAttempts: number,
312
- state: ConsumerStateRow,
313
- ): Promise<DeliveryOutcome> {
314
- let cursor = state.lastProcessedEventId;
315
- let attempts = state.attempts;
316
- let lastError: string | null = state.lastError ?? null;
317
- let deadLettered = false;
318
- let processed = 0;
319
- let failed = 0;
320
-
321
- for (const row of events) {
322
- try {
323
- // Propagate causation: if the handler calls ctx.appendEvent, the new
324
- // event should record THIS event as its cause. correlationId is
325
- // inherited unchanged — it survives the hop across streams by design.
326
- // requestId falls back to a fresh id because the dispatcher runs
327
- // outside any HTTP request (background poll), and a stable log-
328
- // correlation handle is still useful for debugging.
329
- const stored = rowToStoredEvent(row);
330
- const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
331
- const causationId = String(stored.id);
332
- const requestId = requestContext.generateId();
333
- await requestContext.run({ requestId, correlationId, causationId }, async () => {
334
- await consumer.handler(stored, context);
335
- });
336
- cursor = row.id;
337
- attempts = 0;
338
- lastError = null;
339
- processed += 1;
340
- } catch (e) {
341
- const errMessage = e instanceof Error ? e.message : String(e);
342
- if (consumer.errorPolicy?.skipApplyErrors) {
343
- // Best-effort mode: record the error on the skip counter so ops
344
- // can alert on a spike of skipped events, advance the cursor past
345
- // the bad event, keep going. The consumer stays "idle", not "dead".
346
- // Also emit a warn-level log line — the metric tells ops THAT events
347
- // are being dropped, the log tells them WHICH events. Without this
348
- // a poisoned-then-skipped event is invisible to forensic search.
349
- const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
350
- emitDispatcherError(context.meter ?? getFallbackMeter(), {
351
- handler: consumer.name,
352
- errorClass,
353
- });
354
- context.log?.warn(
355
- `event-dispatcher: ${consumer.name} skipped event ${row.id} (${errorClass}): ${errMessage}`,
356
- );
357
- cursor = row.id;
358
- attempts = 0;
359
- lastError = null;
360
- failed += 1;
361
- continue;
362
- }
363
- attempts += 1;
364
- lastError = errMessage;
365
- failed += 1;
366
- if (attempts >= maxAttempts) deadLettered = true;
367
- break;
368
- }
369
- }
370
-
371
- return { cursor, attempts, lastError, deadLettered, processed, failed };
372
- }
373
-
374
- async function persistConsumerOutcome(
375
- tx: DbTx,
376
- name: string,
377
- instanceId: string,
378
- outcome: DeliveryOutcome,
379
- ): Promise<void> {
380
- await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
381
- }
382
-
383
- // Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
384
- // consistent with the cursor we just advanced to. `MAX(id)` on the events
385
- // table is an O(1) reverse-index scan — cheap even under load.
386
- async function emitLagFromTx(
387
- tx: DbTx,
388
- consumerName: string,
389
- instanceId: string,
390
- cursor: bigint,
391
- meter: Meter,
392
- ): Promise<void> {
393
- const head = await selectEventsHeadId(tx);
394
- const lag = head > cursor ? Number(head - cursor) : 0;
395
- emitEventConsumerLag(meter, { consumer: consumerName, instanceId }, lag);
396
- }
170
+ const DEFAULT_REARM_COOLDOWN_MS = 5 * 60_000;
171
+ const DEFAULT_MAX_REARM_COUNT = 3;
397
172
 
398
173
  export function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher {
399
174
  const {
@@ -403,6 +178,8 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
403
178
  batchSize = DEFAULT_BATCH_SIZE,
404
179
  pollIntervalMs = DEFAULT_POLL_MS,
405
180
  maxAttempts = DEFAULT_MAX_ATTEMPTS,
181
+ rearmCooldownMs = DEFAULT_REARM_COOLDOWN_MS,
182
+ maxRearmCount = DEFAULT_MAX_REARM_COUNT,
406
183
  } = options;
407
184
 
408
185
  // Fail-fast on misconfigured per-instance wiring. Catching this at
@@ -425,6 +202,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
425
202
  }
426
203
  const tracer: Tracer = options.tracer ?? getFallbackTracer();
427
204
  const meter: Meter = options.meter ?? getFallbackMeter();
205
+ // Tracks which (consumer, instanceId) pairs already fired
206
+ // kumiko_event_consumer_rearm_exhausted_total, so a consumer stuck dead
207
+ // across many poll passes emits the ops-signal once, not every pass
208
+ // (that would be log/metric spam for a state that hasn't changed).
209
+ // Process-lifetime only — restarting the dispatcher re-arms reporting,
210
+ // which is fine: a fresh process re-observing a still-dead consumer is
211
+ // exactly the "still needs a human" signal ops wants.
212
+ const reportedDeadConsumers = new Set<string>();
428
213
 
429
214
  let running = false;
430
215
  // Separate from `running` on purpose: pre-registration of consumer state
@@ -517,16 +302,42 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
517
302
 
518
303
  try {
519
304
  await db.begin(async (tx: DbTx) => {
520
- const acquired = await acquireConsumerState(tx, consumer.name, instanceId);
305
+ const acquired = await acquireConsumerState(
306
+ tx,
307
+ consumer.name,
308
+ instanceId,
309
+ rearmCooldownMs,
310
+ maxRearmCount,
311
+ );
521
312
  // skip: another instance holds the lock, or the consumer is
522
313
  // disabled/dead. Nothing to deliver this pass.
523
314
  if (acquired.skip !== null) {
524
315
  span.setAttribute("consumer.skip_reason", acquired.skip);
316
+ if (acquired.skip === "dead") {
317
+ const reportKey = `${consumer.name}:${instanceId}`;
318
+ if (!reportedDeadConsumers.has(reportKey)) {
319
+ reportedDeadConsumers.add(reportKey);
320
+ emitEventConsumerRearmExhausted(meter, { consumer: consumer.name, instanceId });
321
+ }
322
+ }
323
+ // skip: skip reason already recorded on the span above (and, for
324
+ // "dead", already emitted as a metric) — nothing left to deliver.
525
325
  return;
526
326
  }
527
- await markProcessing(tx, consumer.name, instanceId);
327
+ // Acquired normally (including via a successful auto-rearm) — clear
328
+ // any prior dead-report so a future exhaustion re-emits instead of
329
+ // staying permanently suppressed by this process's Set.
330
+ reportedDeadConsumers.delete(`${consumer.name}:${instanceId}`);
528
331
 
529
332
  const events = await fetchPendingEvents(tx, acquired.state.lastProcessedEventId, batchSize);
333
+ // skip: nothing to deliver — no markProcessing/persistConsumerOutcome write,
334
+ // so an idle consumer doesn't burn a WAL record on every poll tick.
335
+ if (events.length === 0) {
336
+ span.setAttribute("consumer.skip_reason", "no_pending_events");
337
+ return;
338
+ }
339
+ await markProcessing(tx, consumer.name, instanceId);
340
+
530
341
  const outcome = await deliverEvents(consumer, events, context, maxAttempts, acquired.state);
531
342
  processed = outcome.processed;
532
343
  failed = outcome.failed;
@@ -664,281 +475,13 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
664
475
  };
665
476
  }
666
477
 
667
- // --- Ops recovery surface ---
668
- //
669
- // These are intentionally verb-distinct; each maps to a CLI sub-command.
670
- // They all target a single consumer row by name. Every call returns the
671
- // state after the write so the CLI can echo what actually changed.
672
- //
673
- // Semantics:
674
- // restartConsumer status="dead" → "idle", attempts=0, lastError=null.
675
- // Cursor unchanged → next pass retries the SAME event
676
- // that poisoned the consumer. For transient failures.
677
- // disableConsumer status=* → "disabled". Dispatcher skips this consumer
678
- // until enableConsumer() flips it back.
679
- // enableConsumer status="disabled" → "idle". No-op on any other state.
680
- // skipPoisonEvent cursor advances past the first event after the
681
- // current cursor (the one that's failing). attempts=0,
682
- // lastError=null, status="idle". For events that will
683
- // never succeed (broken payload, removed feature code).
684
-
685
- function normalizeConsumerState(row: ConsumerStateRowShape): ConsumerRecoveryState {
686
- return {
687
- name: row.name,
688
- instanceId: row.instanceId,
689
- status: row.status,
690
- lastProcessedEventId: row.lastProcessedEventId,
691
- attempts: row.attempts,
692
- lastError: row.lastError,
693
- updatedAt: row.updatedAt,
694
- };
695
- }
696
-
697
- export type ConsumerRecoveryState = {
698
- readonly name: string;
699
- readonly instanceId: string;
700
- readonly status: string;
701
- readonly lastProcessedEventId: bigint;
702
- readonly attempts: number;
703
- readonly lastError: string | null;
704
- readonly updatedAt: Temporal.Instant;
705
- };
706
-
707
- // Ops calls default to the SHARED_INSTANCE_SENTINEL row — that's the only
708
- // row shared-delivery consumers have, so legacy CLI invocations without
709
- // --instance-id keep working. Per-instance consumers require an explicit
710
- // instanceId: picking one of N shards arbitrarily ("first row wins") or
711
- // mutating all shards simultaneously ("bounce every instance") are both
712
- // worse than a loud missing-arg error on the CLI.
713
- async function requireConsumerRow(
714
- db: DbConnection,
715
- name: string,
716
- instanceId: string,
717
- ): Promise<ConsumerStateRowShape> {
718
- const [row] = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable, {
719
- name,
720
- instanceId,
721
- });
722
- if (!row) {
723
- throw new Error(
724
- `Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
725
- `For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`,
726
- );
727
- }
728
- return row;
729
- }
730
-
731
- async function applyConsumerStatusTransition(
732
- db: DbConnection,
733
- name: string,
734
- instanceId: string,
735
- targetStatus: "idle" | "disabled",
736
- ): Promise<ConsumerRecoveryState> {
737
- const raw = await updateConsumerStatusReturning(db, name, instanceId, targetStatus);
738
- const updated =
739
- raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
740
- if (!updated) {
741
- throw new Error(
742
- `Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
743
- );
744
- }
745
- return normalizeConsumerState(updated);
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
- return applyConsumerStatusTransition(db, name, instanceId, "idle");
760
- }
761
-
762
- export async function disableConsumer(
763
- db: DbConnection,
764
- name: string,
765
- instanceId: string = SHARED_INSTANCE_SENTINEL,
766
- ): Promise<ConsumerRecoveryState> {
767
- await requireConsumerRow(db, name, instanceId);
768
- return applyConsumerStatusTransition(db, name, instanceId, "disabled");
769
- }
770
-
771
- export async function enableConsumer(
772
- db: DbConnection,
773
- name: string,
774
- instanceId: string = SHARED_INSTANCE_SENTINEL,
775
- ): Promise<ConsumerRecoveryState> {
776
- const before = await requireConsumerRow(db, name, instanceId);
777
- if (before.status !== "disabled") {
778
- throw new Error(
779
- `Consumer "${name}" (instance_id="${instanceId}") is not disabled (status="${before.status}"). Enable only flips disabled → idle; use "restart" for a dead consumer.`,
780
- );
781
- }
782
- return applyConsumerStatusTransition(db, name, instanceId, "idle");
783
- }
784
-
785
- // skipPoisonEvent advances the cursor past the first event after the
786
- // current cursor. Single TX so concurrent dispatcher passes can't double-
787
- // advance. If no event exists past the cursor, there is nothing to skip —
788
- // treat as idempotent no-op (cursor already at head).
789
- export async function skipPoisonEvent(
790
- db: DbConnection,
791
- name: string,
792
- instanceId: string = SHARED_INSTANCE_SENTINEL,
793
- ): Promise<ConsumerRecoveryState & { readonly skippedEventId: bigint | null }> {
794
- const before = await requireConsumerRow(db, name, instanceId);
795
- return db.begin(async (tx: DbTx) => {
796
- const poisonId = await selectNextEventIdAfter(tx, before.lastProcessedEventId);
797
- if (poisonId === null) {
798
- const [unchanged] = await selectMany<ConsumerStateRow>(tx, eventConsumerStateTable, {
799
- name,
800
- instanceId,
801
- });
802
- if (!unchanged)
803
- throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished — retry.`);
804
- return { ...normalizeConsumerState(unchanged), skippedEventId: null };
805
- }
806
- const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId);
807
- const updated =
808
- raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
809
- if (!updated)
810
- throw new Error(
811
- `Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`,
812
- );
813
- return { ...normalizeConsumerState(updated), skippedEventId: poisonId };
814
- });
815
- }
816
-
817
- // Read-only status for one consumer shard — CLI surface.
818
- export async function getConsumerState(
819
- db: DbConnection,
820
- name: string,
821
- instanceId: string = SHARED_INSTANCE_SENTINEL,
822
- ): Promise<{
823
- readonly name: string;
824
- readonly instanceId: string;
825
- readonly status: string;
826
- readonly lastProcessedEventId: bigint;
827
- readonly attempts: number;
828
- readonly lastError: string | null;
829
- readonly updatedAt: Temporal.Instant;
830
- } | null> {
831
- const [row] = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable, {
832
- name,
833
- instanceId,
834
- });
835
- if (!row) return null;
836
- return {
837
- name: row.name,
838
- instanceId: row.instanceId,
839
- status: row.status,
840
- lastProcessedEventId: row.lastProcessedEventId,
841
- attempts: row.attempts,
842
- lastError: row.lastError,
843
- updatedAt: row.updatedAt,
844
- };
845
- }
846
-
847
- // List every consumer the registry knows about, joined with all shard rows
848
- // from the state table. One entry per (name, instance_id) shard. Consumers
849
- // that have never run appear with status="never-run" and instance_id =
850
- // SHARED_INSTANCE_SENTINEL — a placeholder, because without a running
851
- // dispatcher we can't know the instance-ids of per-instance consumers yet.
852
- // Mirrors listProjectionsWithState — the registry (not the DB) is the
853
- // source-of-truth for which consumer-names exist; the DB is the source-
854
- // of-truth for which instance-shards have been seen.
855
- export async function listConsumersWithState(
856
- db: DbConnection,
857
- registeredNames: readonly string[],
858
- ): Promise<
859
- ReadonlyArray<{
860
- readonly name: string;
861
- readonly instanceId: string;
862
- readonly status: string;
863
- readonly lastProcessedEventId: bigint;
864
- readonly attempts: number;
865
- readonly lastError: string | null;
866
- }>
867
- > {
868
- const stateRows = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable);
869
- const registered = new Set(registeredNames);
870
-
871
- // Materialize one output row per (name, instance_id). Registered names
872
- // without any shard (never-run) get a placeholder row so ops can still
873
- // see the name exists.
874
- const out: Array<{
875
- name: string;
876
- instanceId: string;
877
- status: string;
878
- lastProcessedEventId: bigint;
879
- attempts: number;
880
- lastError: string | null;
881
- }> = [];
882
-
883
- const seenNames = new Set<string>();
884
- for (const r of stateRows) {
885
- if (!registered.has(r.name)) continue; // stale row from an older deploy
886
- seenNames.add(r.name);
887
- out.push({
888
- name: r.name,
889
- instanceId: r.instanceId,
890
- status: r.status,
891
- lastProcessedEventId: r.lastProcessedEventId,
892
- attempts: r.attempts,
893
- lastError: r.lastError,
894
- });
895
- }
896
- for (const name of registeredNames) {
897
- if (seenNames.has(name)) continue;
898
- out.push({
899
- name,
900
- instanceId: SHARED_INSTANCE_SENTINEL,
901
- status: "never-run",
902
- lastProcessedEventId: 0n,
903
- attempts: 0,
904
- lastError: null,
905
- });
906
- }
907
- return out;
908
- }
909
-
910
- export type ConsumerProgress = {
911
- readonly name: string;
912
- readonly instanceId: string;
913
- readonly status: string;
914
- readonly lastProcessedEventId: bigint;
915
- readonly attempts: number;
916
- readonly lastError: string | null;
917
- // Global MAX(events.id) at query time.
918
- readonly highWaterMark: bigint;
919
- // HWM - cursor. 0n when caught-up. Disabled consumers often show high
920
- // lag intentionally (ops parks them before pruning).
921
- readonly lag: bigint;
922
- };
923
-
924
- // Like listConsumersWithState, but also returns HWM + lag per consumer.
925
- // Async consumers (MSPs) lag behind inline projections because they run
926
- // post-commit — lag is the primary signal for backpressure, dead consumers,
927
- // or dispatcher stalls. Programmatic callers can map the result to a
928
- // `kumiko_consumer_lag{name}` Prometheus gauge.
929
- // guard:dup-ok — intentionale Parallele zu getAllProjectionProgress; Consumer ≠ Projection (verschiedene Subsysteme)
930
- export async function getAllConsumerProgress(
931
- db: DbConnection,
932
- registeredNames: readonly string[],
933
- ): Promise<readonly ConsumerProgress[]> {
934
- const [consumers, highWaterMark] = await Promise.all([
935
- listConsumersWithState(db, registeredNames),
936
- getEventsHighWaterMark(db),
937
- ]);
938
-
939
- return consumers.map((c) => ({
940
- ...c,
941
- highWaterMark,
942
- lag: highWaterMark - c.lastProcessedEventId,
943
- }));
944
- }
478
+ export type { ConsumerProgress, ConsumerRecoveryState } from "./event-dispatcher-admin";
479
+ export {
480
+ disableConsumer,
481
+ enableConsumer,
482
+ getAllConsumerProgress,
483
+ getConsumerState,
484
+ listConsumersWithState,
485
+ restartConsumer,
486
+ skipPoisonEvent,
487
+ } from "./event-dispatcher-admin";