@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
@@ -2,9 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
4
4
  import type { TenantId } from "../../engine/types/identifiers";
5
+ import { createSecret } from "../../secrets/types";
5
6
  import { createTestUser } from "../../stack";
7
+ import { createRecordingProvider } from "../../testing";
6
8
  import { createDispatcher } from "../dispatcher";
7
9
 
10
+ const streamCleanupState = { closed: false };
11
+
8
12
  const echoFeature = defineFeature("echo", (r) => {
9
13
  r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
10
14
 
@@ -25,6 +29,40 @@ const echoFeature = defineFeature("echo", (r) => {
25
29
  { access: { openToAll: true } },
26
30
  );
27
31
 
32
+ r.streamHandler(
33
+ "item:tail",
34
+ z.object({ count: z.number().int().min(0) }),
35
+ async function* (query) {
36
+ for (let i = 0; i < query.payload.count; i++) {
37
+ yield { i };
38
+ }
39
+ },
40
+ { access: { roles: ["Admin"] } },
41
+ );
42
+
43
+ r.streamHandler(
44
+ "item:tail-leak",
45
+ z.object({}),
46
+ async function* () {
47
+ yield { apiKey: createSecret("leak-me") };
48
+ },
49
+ { access: { roles: ["Admin"] } },
50
+ );
51
+
52
+ r.streamHandler(
53
+ "item:tail-cleanup",
54
+ z.object({}),
55
+ async function* () {
56
+ try {
57
+ yield { i: 0 };
58
+ yield { i: 1 };
59
+ } finally {
60
+ streamCleanupState.closed = true;
61
+ }
62
+ },
63
+ { access: { roles: ["Admin"] } },
64
+ );
65
+
28
66
  r.hook("validation", "item:create", (data) => {
29
67
  if (data["name"] === "forbidden") return [{ field: "name", error: "forbidden_name" }];
30
68
  return null;
@@ -100,6 +138,35 @@ describe("dispatcher.write", () => {
100
138
  expect(result.isSuccess).toBe(true);
101
139
  });
102
140
 
141
+ test("user-bucketed handler without RateLimitResolver → internal_error", async () => {
142
+ // Misconfig: a handler opts into L3 rate-limit but the app never loaded the
143
+ // rate-limiting feature. User buckets cannot skip (unlike ip+no-IP), so we
144
+ // fail loud at first write instead of silently running uncapped.
145
+ const rlFeature = defineFeature("rl-user", (r) => {
146
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
147
+ r.writeHandler(
148
+ "item:create",
149
+ z.object({ name: z.string() }),
150
+ async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
151
+ {
152
+ access: { openToAll: true },
153
+ rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
154
+ },
155
+ );
156
+ });
157
+ const dispatcher = createDispatcher(createRegistry([rlFeature]), {});
158
+ const result = await dispatcher.write(
159
+ "rl-user:write:item:create",
160
+ { name: "should-not-run" },
161
+ createTestUser(),
162
+ );
163
+ expect(result.isSuccess).toBe(false);
164
+ if (!result.isSuccess) {
165
+ expect(result.error.code).toBe("internal_error");
166
+ expect(result.error.message).toMatch(/RateLimitResolver is configured/);
167
+ }
168
+ });
169
+
103
170
  test("ctx.user ist Convenience-Alias auf event.user (gleicher Wert)", async () => {
104
171
  // Pinst dass der Handler auf ctx.user zugreifen kann ohne den
105
172
  // typo-resistenten event.user-Pfad zu nutzen. Identitätsprüfung
@@ -185,6 +252,231 @@ describe("dispatcher.query", () => {
185
252
  });
186
253
  });
187
254
 
255
+ async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
256
+ const out: T[] = [];
257
+ for await (const chunk of gen) out.push(chunk);
258
+ return out;
259
+ }
260
+
261
+ describe("dispatcher.stream", () => {
262
+ test("validates and calls stream handler, yielding chunks in order", async () => {
263
+ const dispatcher = createTestDispatcher();
264
+ const chunks = await collect(
265
+ dispatcher.stream(
266
+ "echo:stream:item:tail",
267
+ { count: 3 },
268
+ createTestUser({ roles: ["Admin"] }),
269
+ ),
270
+ );
271
+
272
+ expect(chunks).toEqual([{ i: 0 }, { i: 1 }, { i: 2 }]);
273
+ });
274
+
275
+ test("gates fire on first pull, not on the stream() call itself", async () => {
276
+ const dispatcher = createTestDispatcher();
277
+ const gen = dispatcher.stream(
278
+ "echo:stream:item:tail",
279
+ { count: -1 },
280
+ createTestUser({ roles: ["Admin"] }),
281
+ );
282
+
283
+ await expect(collect(gen)).rejects.toMatchObject({ code: "validation_error", httpStatus: 400 });
284
+ });
285
+
286
+ test("rejects unauthorized user", async () => {
287
+ const dispatcher = createTestDispatcher();
288
+ const guest = createTestUser({ roles: ["Guest"] });
289
+
290
+ await expect(
291
+ collect(dispatcher.stream("echo:stream:item:tail", { count: 1 }, guest)),
292
+ ).rejects.toMatchObject({ code: "access_denied" });
293
+ });
294
+
295
+ test("throws for unknown stream handler", async () => {
296
+ const dispatcher = createTestDispatcher();
297
+
298
+ await expect(
299
+ collect(dispatcher.stream("nonexistent", {}, createTestUser({ roles: ["Admin"] }))),
300
+ ).rejects.toMatchObject({ code: "not_found", httpStatus: 404 });
301
+ });
302
+
303
+ test("a chunk containing a Secret<> value aborts the stream instead of leaking it", async () => {
304
+ const dispatcher = createTestDispatcher();
305
+
306
+ await expect(
307
+ collect(
308
+ dispatcher.stream("echo:stream:item:tail-leak", {}, createTestUser({ roles: ["Admin"] })),
309
+ ),
310
+ ).rejects.toMatchObject({ message: expect.stringContaining("Secret<> leaked") });
311
+ });
312
+
313
+ test("consumer breaking out of for-await runs the handler generator's finally block", async () => {
314
+ streamCleanupState.closed = false;
315
+ const dispatcher = createTestDispatcher();
316
+ const gen = dispatcher.stream(
317
+ "echo:stream:item:tail-cleanup",
318
+ {},
319
+ createTestUser({ roles: ["Admin"] }),
320
+ );
321
+
322
+ for await (const _chunk of gen) {
323
+ break;
324
+ }
325
+
326
+ expect(streamCleanupState.closed).toBe(true);
327
+ });
328
+
329
+ test("activates the dispatcher span in observabilityContext for every pull, not just the first", async () => {
330
+ // Regression for #1516: runStreamInstrumented used to start the span but
331
+ // never run the stream inside observabilityContext.run(...), so a
332
+ // handler-side startSpan() picked up whatever ALS context happened to be
333
+ // active at that pull (or none) instead of the stream's dispatcher span.
334
+ const provider = createRecordingProvider();
335
+ const nestedFeature = defineFeature("nested", (r) => {
336
+ r.streamHandler(
337
+ "tail",
338
+ z.object({ count: z.number().int().min(0) }),
339
+ async function* (query, handlerCtx) {
340
+ for (let i = 0; i < query.payload.count; i++) {
341
+ const child = handlerCtx.tracer.startSpan("nested.child");
342
+ child.end();
343
+ yield { i };
344
+ }
345
+ },
346
+ { access: { openToAll: true } },
347
+ );
348
+ });
349
+ const dispatcher = createDispatcher(createRegistry([nestedFeature]), {
350
+ tracer: provider.tracer,
351
+ meter: provider.meter,
352
+ });
353
+
354
+ await collect(dispatcher.stream("nested:stream:tail", { count: 3 }, createTestUser()));
355
+
356
+ const dispatcherSpan = provider.spansByName("kumiko.dispatcher.handler")[0]!;
357
+ const childSpans = provider.spansByName("nested.child");
358
+ expect(childSpans).toHaveLength(3);
359
+ for (const child of childSpans) {
360
+ expect(child.parentSpanId).toBe(dispatcherSpan.spanId);
361
+ }
362
+ });
363
+
364
+ test("a close-time error on an otherwise-clean stream is reported, not swallowed", async () => {
365
+ // Regression for #1543: runStreamInstrumented's finally used to
366
+ // catch+discard any error from generator.return() unconditionally, even
367
+ // when nothing else had failed — an abandoned stream whose handler's
368
+ // own cleanup throws (closing a cursor/unsubscribing) was invisible to
369
+ // both the error-counter and the span status.
370
+ const provider = createRecordingProvider();
371
+ const closeFailFeature = defineFeature("closefail", (r) => {
372
+ r.streamHandler(
373
+ "tail",
374
+ z.object({}),
375
+ async function* () {
376
+ try {
377
+ yield { i: 0 };
378
+ yield { i: 1 };
379
+ } finally {
380
+ // biome-ignore lint/correctness/noUnsafeFinally: deliberately simulating a handler whose close-time cleanup fails
381
+ throw new Error("close-boom");
382
+ }
383
+ },
384
+ { access: { openToAll: true } },
385
+ );
386
+ });
387
+ const dispatcher = createDispatcher(createRegistry([closeFailFeature]), {
388
+ tracer: provider.tracer,
389
+ meter: provider.meter,
390
+ });
391
+
392
+ const gen = dispatcher.stream("closefail:stream:tail", {}, createTestUser());
393
+ for await (const _chunk of gen) {
394
+ break;
395
+ }
396
+
397
+ const errorCounters = provider.metricEvents.filter(
398
+ (e) => e.type === "counter.inc" && e.name === "kumiko_dispatcher_handler_errors_total",
399
+ );
400
+ expect(errorCounters).toHaveLength(1);
401
+ const dispatcherSpan = provider.spansByName("kumiko.dispatcher.handler")[0]!;
402
+ expect(dispatcherSpan.status).toBe("error");
403
+ });
404
+
405
+ test("draining a stream to completion emits outcome:completed", async () => {
406
+ // completedNormally=true only when the inner generator finishes on its
407
+ // own — pins the success-path label so a refactor that drops the flag
408
+ // cannot silently reclassify clean streams as aborted (#1551).
409
+ const provider = createRecordingProvider();
410
+ const feature = defineFeature("complete", (r) => {
411
+ r.streamHandler(
412
+ "tail",
413
+ z.object({}),
414
+ async function* () {
415
+ yield { i: 0 };
416
+ yield { i: 1 };
417
+ },
418
+ { access: { openToAll: true } },
419
+ );
420
+ });
421
+ const dispatcher = createDispatcher(createRegistry([feature]), {
422
+ tracer: provider.tracer,
423
+ meter: provider.meter,
424
+ });
425
+
426
+ await collect(dispatcher.stream("complete:stream:tail", {}, createTestUser()));
427
+
428
+ const duration = provider.metricEvents.find(
429
+ (e) =>
430
+ e.type === "histogram.observe" && e.name === "kumiko_dispatcher_handler_duration_seconds",
431
+ );
432
+ expect(duration).toBeDefined();
433
+ expect(duration?.labels).toMatchObject({
434
+ handler: "complete:stream:tail",
435
+ success: "true",
436
+ outcome: "completed",
437
+ });
438
+ });
439
+
440
+ test("generator.return() mid-stream emits outcome:aborted, not completed", async () => {
441
+ // Consumer walk-away (SSE tab-close) must not count as a clean
442
+ // completion — completedNormally stays false when return() interrupts
443
+ // the loop (#1551).
444
+ const provider = createRecordingProvider();
445
+ const feature = defineFeature("abort", (r) => {
446
+ r.streamHandler(
447
+ "tail",
448
+ z.object({}),
449
+ async function* () {
450
+ yield { i: 0 };
451
+ yield { i: 1 };
452
+ yield { i: 2 };
453
+ },
454
+ { access: { openToAll: true } },
455
+ );
456
+ });
457
+ const dispatcher = createDispatcher(createRegistry([feature]), {
458
+ tracer: provider.tracer,
459
+ meter: provider.meter,
460
+ });
461
+
462
+ const gen = dispatcher.stream("abort:stream:tail", {}, createTestUser());
463
+ const first = await gen.next();
464
+ expect(first.value).toEqual({ i: 0 });
465
+ await gen.return(undefined);
466
+
467
+ const duration = provider.metricEvents.find(
468
+ (e) =>
469
+ e.type === "histogram.observe" && e.name === "kumiko_dispatcher_handler_duration_seconds",
470
+ );
471
+ expect(duration).toBeDefined();
472
+ expect(duration?.labels).toMatchObject({
473
+ handler: "abort:stream:tail",
474
+ success: "true",
475
+ outcome: "aborted",
476
+ });
477
+ });
478
+ });
479
+
188
480
  // --- postQuery hooks on standalone (entity-less) queries ---
189
481
 
190
482
  describe("dispatcher.query postQuery hooks", () => {
@@ -333,6 +625,20 @@ describe("dispatcher feature-gate", () => {
333
625
  async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
334
626
  { access: { roles: ["Admin"] } },
335
627
  );
628
+ r.streamHandler(
629
+ "widget:tail",
630
+ z.object({}).passthrough(),
631
+ async function* () {
632
+ // Never completes on its own — the mid-stream toggle-off test
633
+ // relies on the generator staying open past the first yield.
634
+ let i = 0;
635
+ while (true) {
636
+ yield { i };
637
+ i++;
638
+ }
639
+ },
640
+ { access: { openToAll: true } },
641
+ );
336
642
  });
337
643
  }
338
644
 
@@ -368,6 +674,28 @@ describe("dispatcher feature-gate", () => {
368
674
  });
369
675
  });
370
676
 
677
+ test("disabling the feature mid-stream aborts an already-open stream", async () => {
678
+ const registry = createRegistry([toggled()]);
679
+ const disabled = new Set<string>();
680
+ const dispatcher = createDispatcher(
681
+ registry,
682
+ {},
683
+ {
684
+ effectiveFeatures: () => {
685
+ const all = new Set(registry.features.keys());
686
+ for (const d of disabled) all.delete(d);
687
+ return all;
688
+ },
689
+ },
690
+ );
691
+
692
+ const gen = dispatcher.stream("toggled:stream:widget:tail", {}, user);
693
+ expect((await gen.next()).value).toEqual({ i: 0 });
694
+
695
+ disabled.add("toggled");
696
+ await expect(gen.next()).rejects.toThrow(/feature toggled is disabled/);
697
+ });
698
+
371
699
  test("write of disabled feature returns WriteFailure with feature_disabled reason", async () => {
372
700
  const registry = createRegistry([toggled()]);
373
701
  const disabled = new Set<string>(["toggled"]);
@@ -480,7 +808,7 @@ describe("dispatcher feature-gate", () => {
480
808
  r.queryHandler(
481
809
  "check",
482
810
  z.object({}).passthrough(),
483
- async (_event, ctx) => ({ enabled: ctx.hasFeature("toggled") }),
811
+ async (_event, ctx) => ({ enabled: await ctx.hasFeature("toggled") }),
484
812
  { access: { openToAll: true } },
485
813
  );
486
814
  });
@@ -504,6 +832,43 @@ describe("dispatcher feature-gate", () => {
504
832
  enabled: false,
505
833
  });
506
834
  });
835
+
836
+ test("issue #1233: ctx.hasFeature falls back to trialGate for a companion feature the tenant doesn't own", async () => {
837
+ // Regression for the trial-tenant bug: a handler owned by an always-on
838
+ // feature checks a DIFFERENT (trial-tier) feature via ctx.hasFeature.
839
+ // The sync resolver never carries trial features, so without the
840
+ // trialGate fallback this silently reads false for a trial tenant even
841
+ // though the tenant is genuinely in-trial for that companion feature —
842
+ // mirrors packages/bundled-features/src/managed-pages/handlers/branding.query.ts
843
+ // checking MANAGED_PAGES_CSS_FEATURE, which no handler owns.
844
+ const trialTenant = "00000000-0000-4000-8000-0000000000c5" as TenantId;
845
+ const plainTenant = "00000000-0000-4000-8000-0000000000d6" as TenantId;
846
+
847
+ const probe = defineFeature("probe", (r) => {
848
+ r.queryHandler(
849
+ "check",
850
+ z.object({}).passthrough(),
851
+ async (_event, ctx) => ({ enabled: await ctx.hasFeature("premium-companion") }),
852
+ { access: { openToAll: true } },
853
+ );
854
+ });
855
+ const registry = createRegistry([probe]);
856
+ const effectiveFeatures = Object.assign(() => new Set(["probe"]), {
857
+ trialGate: async (tenantId: TenantId, feature: string) =>
858
+ tenantId === trialTenant && feature === "premium-companion",
859
+ });
860
+ const dispatcher = createDispatcher(registry, {}, { effectiveFeatures });
861
+
862
+ const trialUser = createTestUser({ id: "u-c5", tenantId: trialTenant, roles: ["Admin"] });
863
+ const plainUser = createTestUser({ id: "u-d6", tenantId: plainTenant, roles: ["Admin"] });
864
+
865
+ await expect(dispatcher.query("probe:query:check", {}, trialUser)).resolves.toEqual({
866
+ enabled: true,
867
+ });
868
+ await expect(dispatcher.query("probe:query:check", {}, plainUser)).resolves.toEqual({
869
+ enabled: false,
870
+ });
871
+ });
507
872
  });
508
873
 
509
874
  describe("write-handler shape guard", () => {
@@ -0,0 +1,31 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createTestDb, type TestDb } from "../../stack";
3
+ import { createEventConsumerStateTable } from "../event-consumer-state";
4
+
5
+ // #1362: two dispatcher instances booting concurrently against the same DB
6
+ // both see kumiko_event_consumers already present but rearm_count missing,
7
+ // and both ALTER TABLE. Without IF NOT EXISTS the loser crashes on boot
8
+ // with "column already exists" — a real TOCTOU since this table is
9
+ // explicitly multi-instance.
10
+ describe("createEventConsumerStateTable — concurrent boot", () => {
11
+ let testDb: TestDb;
12
+
13
+ beforeAll(async () => {
14
+ testDb = await createTestDb();
15
+ });
16
+
17
+ afterAll(async () => {
18
+ await testDb.cleanup();
19
+ });
20
+
21
+ test("running it twice concurrently does not throw", async () => {
22
+ await createEventConsumerStateTable(testDb.db);
23
+
24
+ await expect(
25
+ Promise.all([
26
+ createEventConsumerStateTable(testDb.db),
27
+ createEventConsumerStateTable(testDb.db),
28
+ ]),
29
+ ).resolves.toBeDefined();
30
+ });
31
+ });
@@ -0,0 +1,126 @@
1
+ // EventConsumerErrorPolicy.maxAttempts lets a consumer override the
2
+ // dispatcher-wide default (kumiko-framework#1349) — a consumer depending on
3
+ // infra that may still be provisioning at boot (search adapter) needs more
4
+ // retry headroom before it gets dead-lettered. deliverEvents is a pure
5
+ // free function, so this is unit-level: no DB, no dispatcher lifecycle.
6
+
7
+ import { beforeAll, describe, expect, test } from "bun:test";
8
+ import type { AppContext } from "../../engine/types";
9
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
10
+ import type { EventConsumer } from "../event-dispatcher";
11
+ import {
12
+ type ConsumerStateRow,
13
+ deliverEvents,
14
+ type StoredEventRow,
15
+ } from "../event-dispatcher-delivery";
16
+ import { createSearchEventConsumer } from "../system-hooks";
17
+
18
+ beforeAll(async () => {
19
+ await ensureTemporalPolyfill();
20
+ });
21
+
22
+ function stubContext(): AppContext {
23
+ return {
24
+ db: {} as unknown as AppContext["db"],
25
+ redis: {} as unknown as AppContext["redis"],
26
+ registry: {} as unknown as AppContext["registry"],
27
+ } as AppContext;
28
+ }
29
+
30
+ function stubEvent(id: bigint): StoredEventRow {
31
+ return {
32
+ id,
33
+ aggregateId: "agg-1",
34
+ aggregateType: "widget",
35
+ tenantId: "tenant-1",
36
+ version: 1,
37
+ type: "widget.created",
38
+ eventVersion: 1,
39
+ payload: {},
40
+ metadata: { userId: "system" },
41
+ createdAt: Temporal.Now.instant(),
42
+ createdBy: "system",
43
+ };
44
+ }
45
+
46
+ function stubState(): ConsumerStateRow {
47
+ return {
48
+ name: "consumer",
49
+ instanceId: "shared",
50
+ lastProcessedEventId: 0n,
51
+ status: "idle",
52
+ attempts: 0,
53
+ rearmCount: 0,
54
+ lastError: null,
55
+ updatedAt: Temporal.Now.instant(),
56
+ };
57
+ }
58
+
59
+ describe("deliverEvents — per-consumer maxAttempts override", () => {
60
+ test("stays alive past the dispatcher-wide default when errorPolicy.maxAttempts is higher", async () => {
61
+ const consumer: EventConsumer = {
62
+ name: "search",
63
+ errorPolicy: { maxAttempts: 20 },
64
+ handler: async () => {
65
+ throw new Error("meilisearch not provisioned yet");
66
+ },
67
+ };
68
+
69
+ const outcome = await deliverEvents(
70
+ consumer,
71
+ [stubEvent(1n)],
72
+ stubContext(),
73
+ /* dispatcher-wide default */ 10,
74
+ { ...stubState(), attempts: 10 },
75
+ );
76
+
77
+ expect(outcome.attempts).toBe(11);
78
+ expect(outcome.deadLettered).toBe(false);
79
+ });
80
+
81
+ test("dead-letters at the override once attempts reach it", async () => {
82
+ const consumer: EventConsumer = {
83
+ name: "search",
84
+ errorPolicy: { maxAttempts: 20 },
85
+ handler: async () => {
86
+ throw new Error("meilisearch not provisioned yet");
87
+ },
88
+ };
89
+
90
+ const outcome = await deliverEvents(consumer, [stubEvent(1n)], stubContext(), 10, {
91
+ ...stubState(),
92
+ attempts: 19,
93
+ });
94
+
95
+ expect(outcome.attempts).toBe(20);
96
+ expect(outcome.deadLettered).toBe(true);
97
+ });
98
+
99
+ test("a consumer without the override still dies at the dispatcher-wide default", async () => {
100
+ const consumer: EventConsumer = {
101
+ name: "plain",
102
+ handler: async () => {
103
+ throw new Error("boom");
104
+ },
105
+ };
106
+
107
+ const outcome = await deliverEvents(consumer, [stubEvent(1n)], stubContext(), 10, {
108
+ ...stubState(),
109
+ attempts: 9,
110
+ });
111
+
112
+ expect(outcome.attempts).toBe(10);
113
+ expect(outcome.deadLettered).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe("createSearchEventConsumer — errorPolicy wiring", () => {
118
+ test("wires more retry headroom than the dispatcher-wide default", () => {
119
+ const consumer = createSearchEventConsumer(
120
+ {} as unknown as Parameters<typeof createSearchEventConsumer>[0],
121
+ {} as unknown as Parameters<typeof createSearchEventConsumer>[1],
122
+ );
123
+
124
+ expect(consumer.errorPolicy?.maxAttempts).toBeGreaterThan(10);
125
+ });
126
+ });