@cosmicdrift/kumiko-framework 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (388) hide show
  1. package/README.md +159 -0
  2. package/package.json +91 -0
  3. package/src/__tests__/anonymous-access.integration.ts +325 -0
  4. package/src/__tests__/error-contract.integration.ts +435 -0
  5. package/src/__tests__/field-access.integration.ts +269 -0
  6. package/src/__tests__/full-stack.integration.ts +914 -0
  7. package/src/__tests__/ownership.integration.ts +449 -0
  8. package/src/__tests__/reference-data.integration.ts +198 -0
  9. package/src/__tests__/transition-guard.integration.ts +340 -0
  10. package/src/api/__tests__/api.test.ts +337 -0
  11. package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
  13. package/src/api/__tests__/batch.integration.ts +404 -0
  14. package/src/api/__tests__/body-limit.test.ts +88 -0
  15. package/src/api/__tests__/csrf-middleware.test.ts +97 -0
  16. package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
  17. package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
  18. package/src/api/__tests__/nested-write.integration.ts +213 -0
  19. package/src/api/__tests__/readiness.test.ts +76 -0
  20. package/src/api/__tests__/request-id-middleware.test.ts +72 -0
  21. package/src/api/__tests__/sse-broker.test.ts +58 -0
  22. package/src/api/__tests__/sse-route.test.ts +112 -0
  23. package/src/api/anonymous-cookie.ts +60 -0
  24. package/src/api/api-constants.ts +64 -0
  25. package/src/api/auth-middleware.ts +418 -0
  26. package/src/api/auth-routes.ts +982 -0
  27. package/src/api/csrf-middleware.ts +77 -0
  28. package/src/api/index.ts +31 -0
  29. package/src/api/jwt.ts +66 -0
  30. package/src/api/observability-middleware.ts +89 -0
  31. package/src/api/readiness.ts +132 -0
  32. package/src/api/request-context.ts +49 -0
  33. package/src/api/request-id-middleware.ts +50 -0
  34. package/src/api/route-registrars.ts +195 -0
  35. package/src/api/routes.ts +135 -0
  36. package/src/api/server.ts +640 -0
  37. package/src/api/sse-broker.ts +71 -0
  38. package/src/api/sse-route.ts +62 -0
  39. package/src/api/tokens.ts +16 -0
  40. package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
  41. package/src/db/__tests__/compound-types.test.ts +114 -0
  42. package/src/db/__tests__/connection-options.test.ts +68 -0
  43. package/src/db/__tests__/cursor.test.ts +41 -0
  44. package/src/db/__tests__/db-helpers.test.ts +369 -0
  45. package/src/db/__tests__/dialect-instant.test.ts +50 -0
  46. package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
  47. package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
  48. package/src/db/__tests__/encryption.test.ts +39 -0
  49. package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
  50. package/src/db/__tests__/event-store-executor.integration.ts +235 -0
  51. package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
  52. package/src/db/__tests__/located-timestamp.test.ts +184 -0
  53. package/src/db/__tests__/money.test.ts +199 -0
  54. package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
  55. package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
  56. package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
  57. package/src/db/__tests__/row-helpers.test.ts +59 -0
  58. package/src/db/__tests__/schema-migration.integration.ts +273 -0
  59. package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
  60. package/src/db/__tests__/table-builder-required.test.ts +216 -0
  61. package/src/db/__tests__/tenant-db.integration.ts +606 -0
  62. package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
  63. package/src/db/apply-entity-event.ts +188 -0
  64. package/src/db/assert-exists-in.ts +59 -0
  65. package/src/db/compound-types.ts +47 -0
  66. package/src/db/connection.ts +104 -0
  67. package/src/db/cursor.ts +83 -0
  68. package/src/db/dialect.ts +109 -0
  69. package/src/db/eagerload.ts +174 -0
  70. package/src/db/encryption.ts +39 -0
  71. package/src/db/event-store-executor.ts +906 -0
  72. package/src/db/index.ts +55 -0
  73. package/src/db/located-timestamp.ts +114 -0
  74. package/src/db/money.ts +120 -0
  75. package/src/db/pg-error.ts +46 -0
  76. package/src/db/reference-data.ts +77 -0
  77. package/src/db/row-helpers.ts +53 -0
  78. package/src/db/schema-inspection.ts +25 -0
  79. package/src/db/table-builder.ts +475 -0
  80. package/src/db/tenant-db.ts +434 -0
  81. package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
  82. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
  83. package/src/engine/__tests__/boot-validator.test.ts +1865 -0
  84. package/src/engine/__tests__/build-app-schema.test.ts +154 -0
  85. package/src/engine/__tests__/claim-keys.test.ts +274 -0
  86. package/src/engine/__tests__/config-helpers.test.ts +236 -0
  87. package/src/engine/__tests__/effective-features.test.ts +86 -0
  88. package/src/engine/__tests__/engine.test.ts +1461 -0
  89. package/src/engine/__tests__/entity-handlers.test.ts +274 -0
  90. package/src/engine/__tests__/event-helpers.test.ts +68 -0
  91. package/src/engine/__tests__/extends-registrar.test.ts +159 -0
  92. package/src/engine/__tests__/factories-long-text.test.ts +84 -0
  93. package/src/engine/__tests__/factories-time.test.ts +158 -0
  94. package/src/engine/__tests__/field-predicates.test.ts +48 -0
  95. package/src/engine/__tests__/hook-phases.test.ts +132 -0
  96. package/src/engine/__tests__/identifiers.test.ts +35 -0
  97. package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
  98. package/src/engine/__tests__/nav.test.ts +267 -0
  99. package/src/engine/__tests__/ownership.test.ts +421 -0
  100. package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
  101. package/src/engine/__tests__/projection-helpers.test.ts +62 -0
  102. package/src/engine/__tests__/projection.test.ts +191 -0
  103. package/src/engine/__tests__/qualified-name.test.ts +264 -0
  104. package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
  105. package/src/engine/__tests__/run-in.test.ts +38 -0
  106. package/src/engine/__tests__/schema-builder.test.ts +380 -0
  107. package/src/engine/__tests__/screen.test.ts +408 -0
  108. package/src/engine/__tests__/state-machine.test.ts +148 -0
  109. package/src/engine/__tests__/system-user.test.ts +57 -0
  110. package/src/engine/__tests__/validation-hooks.test.ts +71 -0
  111. package/src/engine/access.ts +23 -0
  112. package/src/engine/boot-validator.ts +1528 -0
  113. package/src/engine/build-app-schema.ts +125 -0
  114. package/src/engine/config-helpers.ts +115 -0
  115. package/src/engine/constants.ts +85 -0
  116. package/src/engine/create-app.ts +98 -0
  117. package/src/engine/define-feature.ts +702 -0
  118. package/src/engine/define-handler.ts +78 -0
  119. package/src/engine/define-roles.ts +19 -0
  120. package/src/engine/effective-features.ts +87 -0
  121. package/src/engine/entity-handlers.ts +364 -0
  122. package/src/engine/event-helpers.ts +73 -0
  123. package/src/engine/factories.ts +328 -0
  124. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
  125. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
  126. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
  127. package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
  128. package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
  129. package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
  130. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
  131. package/src/engine/feature-ast/extractors.ts +2562 -0
  132. package/src/engine/feature-ast/index.ts +105 -0
  133. package/src/engine/feature-ast/parse.ts +369 -0
  134. package/src/engine/feature-ast/patch.ts +525 -0
  135. package/src/engine/feature-ast/patcher.ts +518 -0
  136. package/src/engine/feature-ast/patterns.ts +434 -0
  137. package/src/engine/feature-ast/render.ts +602 -0
  138. package/src/engine/feature-ast/source-location.ts +45 -0
  139. package/src/engine/field-access.ts +120 -0
  140. package/src/engine/index.ts +254 -0
  141. package/src/engine/ownership.ts +337 -0
  142. package/src/engine/parse-ref-target.ts +22 -0
  143. package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
  144. package/src/engine/pattern-library/index.ts +24 -0
  145. package/src/engine/pattern-library/library.ts +1117 -0
  146. package/src/engine/pattern-library/types.ts +255 -0
  147. package/src/engine/projection-helpers.ts +85 -0
  148. package/src/engine/qualified-name.ts +122 -0
  149. package/src/engine/read-claim.ts +31 -0
  150. package/src/engine/registry.ts +1325 -0
  151. package/src/engine/resolve-config-or-param.ts +153 -0
  152. package/src/engine/run-in.ts +29 -0
  153. package/src/engine/schema-builder.ts +175 -0
  154. package/src/engine/screen-filter-ops.ts +51 -0
  155. package/src/engine/state-machine.ts +70 -0
  156. package/src/engine/system-user.ts +32 -0
  157. package/src/engine/types/config.ts +306 -0
  158. package/src/engine/types/event-type-map.ts +37 -0
  159. package/src/engine/types/feature.ts +574 -0
  160. package/src/engine/types/fields.ts +422 -0
  161. package/src/engine/types/handlers.ts +742 -0
  162. package/src/engine/types/hooks.ts +142 -0
  163. package/src/engine/types/http-route.ts +54 -0
  164. package/src/engine/types/identifiers.ts +47 -0
  165. package/src/engine/types/index.ts +208 -0
  166. package/src/engine/types/nav.ts +46 -0
  167. package/src/engine/types/projection.ts +132 -0
  168. package/src/engine/types/relations.ts +51 -0
  169. package/src/engine/types/screen.ts +452 -0
  170. package/src/engine/types/workspace.ts +42 -0
  171. package/src/engine/validation.ts +33 -0
  172. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
  173. package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
  174. package/src/entrypoint/index.ts +442 -0
  175. package/src/errors/__tests__/classes.test.ts +371 -0
  176. package/src/errors/__tests__/write-failures.test.ts +109 -0
  177. package/src/errors/classes.ts +249 -0
  178. package/src/errors/i18n/de.yaml +83 -0
  179. package/src/errors/i18n/en.yaml +80 -0
  180. package/src/errors/index.ts +41 -0
  181. package/src/errors/kumiko-error.ts +67 -0
  182. package/src/errors/reasons.ts +36 -0
  183. package/src/errors/serialize.ts +136 -0
  184. package/src/errors/transition-details.ts +30 -0
  185. package/src/errors/write-error-info.ts +123 -0
  186. package/src/errors/zod-bridge.ts +49 -0
  187. package/src/event-store/__tests__/admin-api.integration.ts +361 -0
  188. package/src/event-store/__tests__/event-store.integration.ts +584 -0
  189. package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
  190. package/src/event-store/__tests__/perf.integration.ts +255 -0
  191. package/src/event-store/__tests__/snapshot.integration.ts +267 -0
  192. package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
  193. package/src/event-store/__tests__/upcaster.integration.ts +460 -0
  194. package/src/event-store/admin-api.ts +257 -0
  195. package/src/event-store/archive.ts +106 -0
  196. package/src/event-store/errors.ts +35 -0
  197. package/src/event-store/event-store.ts +405 -0
  198. package/src/event-store/events-schema.ts +90 -0
  199. package/src/event-store/index.ts +50 -0
  200. package/src/event-store/snapshot.ts +210 -0
  201. package/src/event-store/upcaster-dead-letter.ts +119 -0
  202. package/src/event-store/upcaster.ts +147 -0
  203. package/src/files/__tests__/content-disposition.test.ts +123 -0
  204. package/src/files/__tests__/file-field-column.integration.ts +103 -0
  205. package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
  206. package/src/files/__tests__/file-handle.test.ts +122 -0
  207. package/src/files/__tests__/files.integration.ts +830 -0
  208. package/src/files/__tests__/storage-tracking.integration.ts +153 -0
  209. package/src/files/content-disposition.ts +55 -0
  210. package/src/files/file-handle.ts +63 -0
  211. package/src/files/file-ref-table.ts +22 -0
  212. package/src/files/file-routes.ts +353 -0
  213. package/src/files/in-memory-provider.ts +62 -0
  214. package/src/files/index.ts +29 -0
  215. package/src/files/local-provider.ts +35 -0
  216. package/src/files/storage-tracking.ts +60 -0
  217. package/src/files/types.ts +118 -0
  218. package/src/i18n/__tests__/i18n.test.ts +72 -0
  219. package/src/i18n/index.ts +29 -0
  220. package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
  221. package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
  222. package/src/jobs/__tests__/jobs.integration.ts +566 -0
  223. package/src/jobs/index.ts +2 -0
  224. package/src/jobs/job-runner.ts +574 -0
  225. package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
  226. package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
  227. package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
  228. package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
  229. package/src/lifecycle/index.ts +13 -0
  230. package/src/lifecycle/lifecycle.ts +160 -0
  231. package/src/lifecycle/signal-handlers.ts +62 -0
  232. package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
  233. package/src/logging/index.ts +3 -0
  234. package/src/logging/pino-logger.ts +64 -0
  235. package/src/logging/types.ts +7 -0
  236. package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
  237. package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
  238. package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
  239. package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
  240. package/src/migrations/index.ts +28 -0
  241. package/src/migrations/projection-detection.ts +149 -0
  242. package/src/migrations/rebuild-marker.ts +64 -0
  243. package/src/migrations/schema-drift.ts +395 -0
  244. package/src/observability/__tests__/console-provider.test.ts +67 -0
  245. package/src/observability/__tests__/metric-validator.test.ts +87 -0
  246. package/src/observability/__tests__/noop-provider.test.ts +82 -0
  247. package/src/observability/__tests__/observability.integration.ts +559 -0
  248. package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
  249. package/src/observability/__tests__/recording-meter.test.ts +101 -0
  250. package/src/observability/__tests__/recording-tracer.test.ts +110 -0
  251. package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
  252. package/src/observability/console-provider.ts +130 -0
  253. package/src/observability/context.ts +26 -0
  254. package/src/observability/fallback.ts +34 -0
  255. package/src/observability/ids.ts +25 -0
  256. package/src/observability/index.ts +79 -0
  257. package/src/observability/metric-validator.ts +86 -0
  258. package/src/observability/metrics-handle.ts +56 -0
  259. package/src/observability/noop-provider.ts +146 -0
  260. package/src/observability/prometheus-meter.ts +284 -0
  261. package/src/observability/recording-meter.ts +156 -0
  262. package/src/observability/recording-tracer.ts +198 -0
  263. package/src/observability/redis-wrapper.ts +132 -0
  264. package/src/observability/sensitive-filter.ts +108 -0
  265. package/src/observability/standard-metrics.ts +213 -0
  266. package/src/observability/types/index.ts +29 -0
  267. package/src/observability/types/metric.ts +56 -0
  268. package/src/observability/types/provider.ts +32 -0
  269. package/src/observability/types/span.ts +64 -0
  270. package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
  271. package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
  272. package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
  273. package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
  274. package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
  275. package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
  276. package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
  277. package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
  278. package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
  279. package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
  280. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
  281. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
  282. package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
  283. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
  284. package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
  285. package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
  286. package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
  287. package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
  288. package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
  289. package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
  290. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
  291. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
  292. package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
  293. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
  294. package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
  295. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
  296. package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
  297. package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
  298. package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
  299. package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
  300. package/src/pipeline/append-event-core.ts +117 -0
  301. package/src/pipeline/auth-claims-resolver.ts +103 -0
  302. package/src/pipeline/cascade-handler.ts +113 -0
  303. package/src/pipeline/dispatcher.ts +1585 -0
  304. package/src/pipeline/distributed-lock.ts +37 -0
  305. package/src/pipeline/entity-cache.ts +113 -0
  306. package/src/pipeline/event-consumer-state.ts +108 -0
  307. package/src/pipeline/event-dedup.ts +23 -0
  308. package/src/pipeline/event-dispatcher.ts +1016 -0
  309. package/src/pipeline/event-retention.ts +154 -0
  310. package/src/pipeline/idempotency.ts +76 -0
  311. package/src/pipeline/index.ts +66 -0
  312. package/src/pipeline/lifecycle-pipeline.ts +409 -0
  313. package/src/pipeline/msp-rebuild.ts +242 -0
  314. package/src/pipeline/multi-stream-apply-context.ts +115 -0
  315. package/src/pipeline/projection-rebuild.ts +334 -0
  316. package/src/pipeline/projection-state.ts +72 -0
  317. package/src/pipeline/projections-runner.ts +56 -0
  318. package/src/pipeline/redis-keys.ts +11 -0
  319. package/src/pipeline/system-hooks.ts +190 -0
  320. package/src/random/__tests__/generate.test.ts +149 -0
  321. package/src/random/generate.ts +141 -0
  322. package/src/random/index.ts +8 -0
  323. package/src/random/words.ts +392 -0
  324. package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
  325. package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
  326. package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
  327. package/src/rate-limit/bucket.ts +36 -0
  328. package/src/rate-limit/index.ts +14 -0
  329. package/src/rate-limit/middleware.ts +152 -0
  330. package/src/rate-limit/resolver.ts +267 -0
  331. package/src/redis/__tests__/redis-options.test.ts +54 -0
  332. package/src/redis/index.ts +74 -0
  333. package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
  334. package/src/search/__tests__/search-adapter.test.ts +256 -0
  335. package/src/search/in-memory-adapter.ts +123 -0
  336. package/src/search/index.ts +12 -0
  337. package/src/search/meilisearch-adapter.ts +106 -0
  338. package/src/search/types.ts +39 -0
  339. package/src/secrets/__tests__/dek-cache.test.ts +213 -0
  340. package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
  341. package/src/secrets/__tests__/envelope.test.ts +74 -0
  342. package/src/secrets/__tests__/leak-guard.test.ts +92 -0
  343. package/src/secrets/__tests__/rotation.test.ts +149 -0
  344. package/src/secrets/dek-cache.ts +116 -0
  345. package/src/secrets/env-master-key-provider.ts +162 -0
  346. package/src/secrets/envelope.ts +55 -0
  347. package/src/secrets/index.ts +19 -0
  348. package/src/secrets/leak-guard.ts +87 -0
  349. package/src/secrets/rotation.ts +34 -0
  350. package/src/secrets/types.ts +107 -0
  351. package/src/stack/db.ts +104 -0
  352. package/src/stack/event-collector.ts +23 -0
  353. package/src/stack/index.ts +32 -0
  354. package/src/stack/redis.ts +44 -0
  355. package/src/stack/request-helper.ts +168 -0
  356. package/src/stack/table-helpers.ts +104 -0
  357. package/src/stack/test-stack.ts +357 -0
  358. package/src/stack/test-users.ts +37 -0
  359. package/src/testing/__tests__/e2e-generator.test.ts +230 -0
  360. package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
  361. package/src/testing/access-assertions.ts +15 -0
  362. package/src/testing/assertions.ts +35 -0
  363. package/src/testing/e2e-generator.ts +465 -0
  364. package/src/testing/expect-error.ts +25 -0
  365. package/src/testing/handler-context.ts +125 -0
  366. package/src/testing/http-cookies.ts +52 -0
  367. package/src/testing/index.ts +41 -0
  368. package/src/testing/late-bound.ts +39 -0
  369. package/src/testing/mutable-master-key-provider.ts +31 -0
  370. package/src/testing/observability-recorder.ts +54 -0
  371. package/src/testing/shared-entities.ts +49 -0
  372. package/src/testing/utils.ts +1 -0
  373. package/src/testing/wait-for.ts +31 -0
  374. package/src/time/__tests__/polyfill.test.ts +73 -0
  375. package/src/time/__tests__/tz-context.test.ts +121 -0
  376. package/src/time/index.ts +21 -0
  377. package/src/time/polyfill.ts +70 -0
  378. package/src/time/tz-context.ts +107 -0
  379. package/src/ui-types/app-schema.ts +57 -0
  380. package/src/ui-types/index.ts +65 -0
  381. package/src/utils/__tests__/assert.test.ts +17 -0
  382. package/src/utils/__tests__/env-parse.test.ts +54 -0
  383. package/src/utils/assert.ts +18 -0
  384. package/src/utils/env-parse.ts +16 -0
  385. package/src/utils/ids.ts +16 -0
  386. package/src/utils/index.ts +5 -0
  387. package/src/utils/safe-json.ts +30 -0
  388. package/src/utils/serialization.ts +7 -0
@@ -0,0 +1,220 @@
1
+ // Dispatcher lifecycle + observability pins:
2
+ //
3
+ // 1. buildServer returns a live eventDispatcher when consumers are wired.
4
+ // 2. dispatcher.start() delivers without explicit runOnce; a handler
5
+ // slower than pollIntervalMs doesn't queue overlapping passes
6
+ // (passInFlight serialisation).
7
+ // 3. kumiko_event_consumer_lag_events is emitted per pass.
8
+ //
9
+ // History: this file originally also tested r.postEvent's tenant-scoped
10
+ // ctx.db wrap (E.1 "wiring"). Those tests were removed with r.postEvent in
11
+ // E.2 — MSP apply runs against a raw DbRunner and propagates event.tenantId
12
+ // via payload, not via a wrapped DB handle. Tenant-isolation-via-MSP is
13
+ // tested in multi-stream-projection.integration.ts.
14
+
15
+ import { afterEach, beforeAll, describe, expect, test } from "vitest";
16
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
17
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
18
+ import { defineFeature } from "../../engine";
19
+ import type { StoredEvent } from "../../event-store";
20
+ import {
21
+ DEFAULT_SENSITIVE_CONFIG,
22
+ type MetricEvent,
23
+ type ObservabilityProvider,
24
+ RecordingMeter,
25
+ RecordingTracer,
26
+ } from "../../observability";
27
+ import {
28
+ createEntityTable,
29
+ resetEventStore,
30
+ setupTestStack,
31
+ type TestStack,
32
+ TestUsers,
33
+ } from "../../stack";
34
+ import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
35
+
36
+ // --- Test fixtures ---
37
+
38
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
39
+ entityName: "widget",
40
+ });
41
+
42
+ // Capture what the handler sees so we can assert on delivery. Reset in
43
+ // afterEach.
44
+ type Observation = {
45
+ event: StoredEvent;
46
+ };
47
+ let observations: Observation[] = [];
48
+ // A handler that sleeps a controllable amount of time. Drives the
49
+ // slow-handler / passInFlight test.
50
+ let slowHandlerDelayMs = 0;
51
+ let slowHandlerInvocations: Array<{ start: number; end: number }> = [];
52
+
53
+ const wiringFeature = defineFeature("wiring", (r) => {
54
+ r.entity("widget", sharedWidgetEntity);
55
+
56
+ r.multiStreamProjection({
57
+ name: "observer",
58
+ apply: {
59
+ "widget.created": async (event) => {
60
+ observations.push({ event });
61
+ },
62
+ },
63
+ });
64
+
65
+ r.multiStreamProjection({
66
+ name: "slow-observer",
67
+ apply: {
68
+ "widget.created": async () => {
69
+ const start = Date.now();
70
+ if (slowHandlerDelayMs > 0) {
71
+ await new Promise((resolve) => setTimeout(resolve, slowHandlerDelayMs));
72
+ }
73
+ slowHandlerInvocations.push({ start, end: Date.now() });
74
+ },
75
+ },
76
+ });
77
+ });
78
+
79
+ const admin = TestUsers.admin;
80
+ let stack: TestStack;
81
+ let tdb: TenantDb;
82
+
83
+ beforeAll(async () => {
84
+ stack = await setupTestStack({
85
+ features: [wiringFeature],
86
+ systemHooks: [],
87
+ });
88
+ await createEntityTable(stack.db, sharedWidgetEntity, "widget");
89
+ tdb = createTenantDb(stack.db, admin.tenantId);
90
+ });
91
+
92
+ afterEach(async () => {
93
+ observations = [];
94
+ slowHandlerDelayMs = 0;
95
+ slowHandlerInvocations = [];
96
+ await resetEventStore(stack, ["read_widgets"]);
97
+ });
98
+
99
+ async function appendWidget(name: string): Promise<void> {
100
+ await executor.create({ name }, admin, tdb);
101
+ }
102
+
103
+ // --- Tests ---
104
+
105
+ describe("E.1 — buildServer event-dispatcher wiring", () => {
106
+ test("stack.eventDispatcher is wired when consumers exist", () => {
107
+ // Regression guard against the D.5 bug where the outbox wiring was
108
+ // removed and the dispatcher wiring wasn't added back.
109
+ expect(stack.eventDispatcher).toBeDefined();
110
+ });
111
+ });
112
+
113
+ describe("E.1 — .start() lifecycle + slow handler", () => {
114
+ test("started dispatcher delivers events without an explicit runOnce", async () => {
115
+ await stack.eventDispatcher?.start();
116
+ try {
117
+ await appendWidget("started-delivery");
118
+
119
+ // pollIntervalMs in the test-stack is 50ms. Give the timer a few
120
+ // ticks to observe the event.
121
+ await waitFor(() => observations.length >= 1, 2000);
122
+ expect(observations).toHaveLength(1);
123
+ expect(observations[0]?.event.payload["name"]).toBe("started-delivery");
124
+ } finally {
125
+ await stack.eventDispatcher?.stop();
126
+ }
127
+ });
128
+
129
+ test("slow handler doesn't queue overlapping passes (passInFlight serialises)", async () => {
130
+ // 250ms handler >> 50ms pollIntervalMs — without passInFlight, the
131
+ // setInterval would start a new pass every 50ms on top of the one in
132
+ // flight. passInFlight must coalesce them. We verify: no two passes
133
+ // ran concurrently.
134
+ slowHandlerDelayMs = 250;
135
+
136
+ await stack.eventDispatcher?.start();
137
+ try {
138
+ await appendWidget("slow-1");
139
+ await appendWidget("slow-2");
140
+ await appendWidget("slow-3");
141
+
142
+ // Wait until all 3 slow-observer invocations have completed.
143
+ await waitFor(() => slowHandlerInvocations.length >= 3, 5000);
144
+
145
+ // Check: no invocation overlapped with the next — every pass
146
+ // finished before the following one started. passInFlight does
147
+ // its job.
148
+ const sorted = [...slowHandlerInvocations].sort((a, b) => a.start - b.start);
149
+ for (let i = 1; i < sorted.length; i++) {
150
+ const prev = sorted[i - 1];
151
+ const curr = sorted[i];
152
+ if (!prev || !curr) continue;
153
+ expect(curr.start).toBeGreaterThanOrEqual(prev.end);
154
+ }
155
+ } finally {
156
+ await stack.eventDispatcher?.stop();
157
+ }
158
+ });
159
+ });
160
+
161
+ describe("E.1 — consumer-lag metric", () => {
162
+ test("kumiko_event_consumer_lag_events is emitted per pass", async () => {
163
+ // Build a dedicated stack with a RecordingMeter so we can read back
164
+ // exactly which gauge events the dispatcher emitted.
165
+ const metricEvents: MetricEvent[] = [];
166
+ const meter = new RecordingMeter((e) => metricEvents.push(e));
167
+ const tracer = new RecordingTracer({
168
+ sensitiveConfig: DEFAULT_SENSITIVE_CONFIG,
169
+ onSpanEnd: () => {},
170
+ });
171
+ const recordingProvider: ObservabilityProvider = {
172
+ name: "recording",
173
+ meter,
174
+ tracer,
175
+ shutdown: async () => {},
176
+ };
177
+
178
+ const recStack = await setupTestStack({
179
+ features: [wiringFeature],
180
+ systemHooks: [],
181
+ observability: recordingProvider,
182
+ });
183
+ try {
184
+ await createEntityTable(recStack.db, sharedWidgetEntity, "widget");
185
+ const recTdb = createTenantDb(recStack.db, admin.tenantId);
186
+ await executor.create({ name: "lag-check" }, admin, recTdb);
187
+
188
+ await recStack.eventDispatcher?.runOnce();
189
+
190
+ const lagGauges = metricEvents.filter(
191
+ (e) => e.type === "gauge.set" && e.name === "kumiko_event_consumer_lag_events",
192
+ );
193
+ expect(lagGauges.length).toBeGreaterThan(0);
194
+ // The cursor should be at head after a single pass: lag == 0.
195
+ const lastPerConsumer = new Map<string, MetricEvent>();
196
+ for (const ev of lagGauges) {
197
+ const consumer = (ev.labels?.["consumer"] ?? "") as string;
198
+ lastPerConsumer.set(consumer, ev);
199
+ }
200
+ for (const ev of lastPerConsumer.values()) {
201
+ expect(ev.value).toBe(0);
202
+ }
203
+ } finally {
204
+ await recStack.cleanup();
205
+ }
206
+ });
207
+ });
208
+
209
+ // --- Helpers ---
210
+
211
+ async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
212
+ const start = Date.now();
213
+ while (Date.now() - start < timeoutMs) {
214
+ if (predicate()) return;
215
+ await new Promise((r) => setTimeout(r, 20));
216
+ }
217
+ if (!predicate()) {
218
+ throw new Error(`waitFor: predicate never became true within ${timeoutMs}ms`);
219
+ }
220
+ }
@@ -0,0 +1,423 @@
1
+ // E.5 — Multi-instance claims. The whole reason the dispatcher was designed
2
+ // around SELECT FOR UPDATE SKIP LOCKED is that in production there are N
3
+ // dispatcher processes, not 1. If two dispatchers try to advance the same
4
+ // consumer in parallel, SKIP LOCKED must guarantee: **exactly one** drives
5
+ // the pass, the other no-ops, zero duplicate delivery.
6
+ //
7
+ // The existing event-dispatcher.integration.ts runs everything single-
8
+ // instance. These tests pin the cross-process claims:
9
+ //
10
+ // 1. Two dispatchers on the same DB + same consumer name: handler is
11
+ // called exactly once per event (no duplicate delivery).
12
+ // 2. Two dispatchers with different consumer names: both progress
13
+ // independently — one slow consumer doesn't starve the other.
14
+ // 3. A consumer joining with 2000 events in the backlog catches up
15
+ // across multiple passes without replaying events or exploding.
16
+ //
17
+ // Note: these tests assert on behaviour that is not observable in
18
+ // single-instance runs. Any regression in the locking strategy shows up
19
+ // here first.
20
+
21
+ import { afterEach, beforeAll, describe, expect, test } from "vitest";
22
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
23
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
24
+ import { defineFeature } from "../../engine";
25
+ import { eventsTable, type StoredEvent } from "../../event-store";
26
+ import {
27
+ createEventDispatcher,
28
+ type EventConsumer,
29
+ type EventDispatcher,
30
+ getConsumerState,
31
+ } from "../../pipeline";
32
+ import {
33
+ createEntityTable,
34
+ resetEventStore,
35
+ setupTestStack,
36
+ type TestStack,
37
+ TestUsers,
38
+ } from "../../stack";
39
+ import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
40
+ import { generateId } from "../../utils";
41
+
42
+ // --- Fixture ---
43
+
44
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
45
+ entityName: "widget",
46
+ });
47
+
48
+ // A trivial feature — the dispatchers built by the tests use the same
49
+ // consumer name ("multi:consumer:echo") via direct createEventDispatcher
50
+ // calls (no r.multiStreamProjection registration on this stack, since the
51
+ // test-stack would then auto-wire a subscriber we don't want in the
52
+ // multi-instance setup).
53
+ const multiFeature = defineFeature("multi", (r) => {
54
+ r.entity("widget", sharedWidgetEntity);
55
+ });
56
+
57
+ const admin = TestUsers.admin;
58
+ let stack: TestStack;
59
+ let tdb: TenantDb;
60
+
61
+ beforeAll(async () => {
62
+ stack = await setupTestStack({
63
+ features: [multiFeature],
64
+ systemHooks: [],
65
+ });
66
+ await createEntityTable(stack.db, sharedWidgetEntity, "widget");
67
+ tdb = createTenantDb(stack.db, admin.tenantId);
68
+ });
69
+
70
+ afterEach(async () => {
71
+ await resetEventStore(stack, ["read_widgets"]);
72
+ });
73
+
74
+ async function appendWidget(name: string): Promise<void> {
75
+ await executor.create({ name }, admin, tdb);
76
+ }
77
+
78
+ // Bulk-seed N widget.created events directly into the events table.
79
+ // Used by the backlog test where the seed phase would otherwise dominate
80
+ // runtime (2000 sequential executor.create = 2000 DB round-trips).
81
+ // The dispatcher only reads from events — bypassing the projections-table
82
+ // write is safe here; we're testing cursor catch-up, not the executor.
83
+ async function bulkSeedWidgetCreated(count: number, namePrefix: string): Promise<void> {
84
+ const rows = Array.from({ length: count }, (_, i) => ({
85
+ aggregateId: generateId(),
86
+ aggregateType: "widget",
87
+ tenantId: admin.tenantId,
88
+ version: 1,
89
+ type: "widget.created",
90
+ payload: { name: `${namePrefix}${i}` },
91
+ metadata: { userId: admin.id },
92
+ createdBy: admin.id,
93
+ }));
94
+ await stack.db.insert(eventsTable).values(rows);
95
+ }
96
+
97
+ function buildDispatcherWith(consumers: readonly EventConsumer[]): EventDispatcher {
98
+ return createEventDispatcher({
99
+ db: stack.db,
100
+ consumers,
101
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
102
+ // Tight batch + poll so the test doesn't hinge on timing in .start();
103
+ // we drive everything through runOnce() for determinism.
104
+ batchSize: 200,
105
+ pollIntervalMs: 5000,
106
+ });
107
+ }
108
+
109
+ // --- Tests ---
110
+
111
+ describe("E.5 — SKIP LOCKED: exactly-once delivery across dispatchers", () => {
112
+ test("two dispatchers, same consumer name: each event delivered exactly once", async () => {
113
+ // Shared name — both dispatchers race for the same row in
114
+ // kumiko_event_consumers. SKIP LOCKED must ensure only one wins.
115
+ const name = "multi:consumer:echo-same";
116
+ const seen: StoredEvent[] = [];
117
+
118
+ // Two consumers with the SAME name but distinct capture sides —
119
+ // mimics two different processes running the same subscriber code.
120
+ const consumerA: EventConsumer = {
121
+ name,
122
+ handler: async (event) => {
123
+ seen.push(event);
124
+ },
125
+ };
126
+ const consumerB: EventConsumer = { ...consumerA };
127
+
128
+ const dispA = buildDispatcherWith([consumerA]);
129
+ const dispB = buildDispatcherWith([consumerB]);
130
+ // Strict pre-reg: both dispatchers share the same consumer name so
131
+ // ON-CONFLICT-DO-NOTHING collapses to a single row — which is exactly
132
+ // the shared state the SKIP-LOCKED race is about.
133
+ await dispA.ensureRegistered();
134
+ await dispB.ensureRegistered();
135
+
136
+ // Seed a known, large-enough batch.
137
+ const count = 30;
138
+ for (let i = 0; i < count; i++) {
139
+ await appendWidget(`event-${i}`);
140
+ }
141
+
142
+ // Race both dispatchers. One will acquire the lock; the other's
143
+ // SELECT FOR UPDATE SKIP LOCKED returns nothing and it bails out of
144
+ // this pass. Run a handful of passes so stragglers (if any) get
145
+ // another shot.
146
+ for (let pass = 0; pass < 5; pass++) {
147
+ await Promise.all([dispA.runOnce(), dispB.runOnce()]);
148
+ }
149
+
150
+ // Each of the 30 events should appear exactly once across the
151
+ // combined `seen` array. Duplicate delivery would show up as >30.
152
+ expect(seen).toHaveLength(count);
153
+ const names = seen.map((e) => e.payload["name"]).sort();
154
+ const expected = Array.from({ length: count }, (_, i) => `event-${i}`).sort();
155
+ expect(names).toEqual(expected);
156
+
157
+ const finalState = await getConsumerState(stack.db, name);
158
+ expect(finalState?.lastProcessedEventId).toBe(BigInt(count));
159
+ expect(finalState?.status).toBe("idle");
160
+ });
161
+
162
+ test("different consumer names: one slow consumer does not starve the other", async () => {
163
+ // Two independent consumer rows, both driven on the same DB via two
164
+ // dispatcher instances. A slow handler on one side must not block the
165
+ // fast side.
166
+ const fastName = "multi:consumer:fast";
167
+ const slowName = "multi:consumer:slow";
168
+ const fastSeen: StoredEvent[] = [];
169
+ const slowSeen: StoredEvent[] = [];
170
+
171
+ const fast: EventConsumer = {
172
+ name: fastName,
173
+ handler: async (event) => {
174
+ fastSeen.push(event);
175
+ },
176
+ };
177
+ const slow: EventConsumer = {
178
+ name: slowName,
179
+ handler: async (event) => {
180
+ await new Promise((r) => setTimeout(r, 30));
181
+ slowSeen.push(event);
182
+ },
183
+ };
184
+
185
+ const dispA = buildDispatcherWith([fast]);
186
+ const dispB = buildDispatcherWith([slow]);
187
+ await dispA.ensureRegistered();
188
+ await dispB.ensureRegistered();
189
+
190
+ const count = 10;
191
+ for (let i = 0; i < count; i++) {
192
+ await appendWidget(`concurrent-${i}`);
193
+ }
194
+
195
+ // A tight race: fast dispatcher finishes its pass quickly; slow
196
+ // dispatcher is still processing. Neither should see the other's
197
+ // events (different consumer names = different rows, different
198
+ // cursors).
199
+ await Promise.all([dispA.runOnce(), dispB.runOnce()]);
200
+
201
+ expect(fastSeen).toHaveLength(count);
202
+ expect(slowSeen).toHaveLength(count);
203
+
204
+ const fastState = await getConsumerState(stack.db, fastName);
205
+ const slowState = await getConsumerState(stack.db, slowName);
206
+ expect(fastState?.lastProcessedEventId).toBe(BigInt(count));
207
+ expect(slowState?.lastProcessedEventId).toBe(BigInt(count));
208
+ });
209
+ });
210
+
211
+ describe("E.5 — cursor-lag catch-up", () => {
212
+ test("a consumer joining with a 500-event backlog catches up across multiple passes", async () => {
213
+ const name = "multi:consumer:late-joiner";
214
+ const seen: StoredEvent[] = [];
215
+ const consumer: EventConsumer = {
216
+ name,
217
+ handler: async (event) => {
218
+ seen.push(event);
219
+ },
220
+ };
221
+
222
+ // Seed events BEFORE the consumer first runs. Matches a deploy scenario
223
+ // where a new subscriber is added to a feature and starts from cursor=0
224
+ // against a populated events table. 500 events × batchSize=100 = 5 passes
225
+ // — still "multiple" and exercises the cursor-advance loop.
226
+ const count = 500;
227
+ await bulkSeedWidgetCreated(count, "backlog-");
228
+ // State row does not exist yet — this dispatcher is constructed inside
229
+ // the test, not via setupTestStack's auto-ensureRegistered.
230
+ expect(await getConsumerState(stack.db, name)).toBeNull();
231
+
232
+ const disp = createEventDispatcher({
233
+ db: stack.db,
234
+ consumers: [consumer],
235
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
236
+ batchSize: 100,
237
+ pollIntervalMs: 5000,
238
+ });
239
+ // Strict pre-reg before the first pass — mirrors a production boot
240
+ // where start() runs before any runOnce(). Post-ensureRegistered the
241
+ // cursor row exists at 0 with the 500-event backlog ahead.
242
+ await disp.ensureRegistered();
243
+ const bootState = await getConsumerState(stack.db, name);
244
+ expect(bootState?.lastProcessedEventId).toBe(0n);
245
+
246
+ // batchSize = 100 → 5 passes cover 500 events. Run 8 to leave headroom;
247
+ // the 6th+ passes should be no-ops.
248
+ for (let pass = 0; pass < 8; pass++) {
249
+ const result = await disp.runOnce();
250
+ if (result.byConsumer[name]?.processed === 0) break;
251
+ }
252
+
253
+ // All events delivered, in order, exactly once.
254
+ expect(seen).toHaveLength(count);
255
+ for (let i = 0; i < count; i++) {
256
+ expect(seen[i]?.payload["name"]).toBe(`backlog-${i}`);
257
+ }
258
+
259
+ const finalState = await getConsumerState(stack.db, name);
260
+ expect(finalState?.lastProcessedEventId).toBe(BigInt(count));
261
+ expect(finalState?.status).toBe("idle");
262
+ });
263
+ });
264
+
265
+ // Welle 2.7 — per-instance delivery. Inverse of the shared test above:
266
+ // with delivery="per-instance", each dispatcher gets its OWN cursor row
267
+ // (via instance_id), so both dispatchers MUST deliver every event. SSE
268
+ // broadcast in split-deploy is the canonical use-case.
269
+ describe("Welle 2.7 — per-instance delivery: every dispatcher sees every event", () => {
270
+ test("two dispatchers with different instanceIds, same consumer name: both deliver every event", async () => {
271
+ const name = "multi:consumer:per-instance-echo";
272
+ const seenA: StoredEvent[] = [];
273
+ const seenB: StoredEvent[] = [];
274
+
275
+ const makeConsumer = (seen: StoredEvent[]): EventConsumer => ({
276
+ name,
277
+ delivery: "per-instance",
278
+ handler: async (event) => {
279
+ seen.push(event);
280
+ },
281
+ });
282
+
283
+ const dispA = createEventDispatcher({
284
+ db: stack.db,
285
+ consumers: [makeConsumer(seenA)],
286
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
287
+ instanceId: "instance-A",
288
+ batchSize: 200,
289
+ pollIntervalMs: 5000,
290
+ });
291
+ const dispB = createEventDispatcher({
292
+ db: stack.db,
293
+ consumers: [makeConsumer(seenB)],
294
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
295
+ instanceId: "instance-B",
296
+ batchSize: 200,
297
+ pollIntervalMs: 5000,
298
+ });
299
+ await dispA.ensureRegistered();
300
+ await dispB.ensureRegistered();
301
+
302
+ const count = 20;
303
+ for (let i = 0; i < count; i++) {
304
+ await appendWidget(`pi-${i}`);
305
+ }
306
+
307
+ // Run both dispatchers. Unlike shared delivery (race → exactly one
308
+ // wins), per-instance means both cursors advance independently.
309
+ await Promise.all([dispA.runOnce(), dispB.runOnce()]);
310
+
311
+ expect(seenA).toHaveLength(count);
312
+ expect(seenB).toHaveLength(count);
313
+
314
+ // Each instance has its own row with its own cursor.
315
+ const stateA = await getConsumerState(stack.db, name, "instance-A");
316
+ const stateB = await getConsumerState(stack.db, name, "instance-B");
317
+ expect(stateA?.lastProcessedEventId).toBe(BigInt(count));
318
+ expect(stateB?.lastProcessedEventId).toBe(BigInt(count));
319
+ expect(stateA?.instanceId).toBe("instance-A");
320
+ expect(stateB?.instanceId).toBe("instance-B");
321
+
322
+ // The shared sentinel row MUST NOT exist — per-instance consumers
323
+ // never write the default shard. If a bug ever routed per-instance
324
+ // writes to `__shared__`, this would silently collapse N instances'
325
+ // cursors into one and regress to shared semantics.
326
+ const stateShared = await getConsumerState(stack.db, name);
327
+ expect(stateShared).toBeNull();
328
+ });
329
+
330
+ test("mixed delivery: shared consumer stays exactly-once, per-instance consumer delivers to every dispatcher", async () => {
331
+ const sharedName = "multi:consumer:mixed-shared";
332
+ const perInstanceName = "multi:consumer:mixed-per-instance";
333
+
334
+ const sharedSeen: StoredEvent[] = [];
335
+ const perInstA: StoredEvent[] = [];
336
+ const perInstB: StoredEvent[] = [];
337
+
338
+ // Shared consumer registered on BOTH dispatchers — SKIP LOCKED on the
339
+ // one sentinel row means exactly one of them wins each event.
340
+ const sharedA: EventConsumer = {
341
+ name: sharedName,
342
+ handler: async (e) => void sharedSeen.push(e),
343
+ };
344
+ const sharedB: EventConsumer = { ...sharedA };
345
+
346
+ const perInstanceA: EventConsumer = {
347
+ name: perInstanceName,
348
+ delivery: "per-instance",
349
+ handler: async (e) => void perInstA.push(e),
350
+ };
351
+ const perInstanceB: EventConsumer = {
352
+ name: perInstanceName,
353
+ delivery: "per-instance",
354
+ handler: async (e) => void perInstB.push(e),
355
+ };
356
+
357
+ const dispA = createEventDispatcher({
358
+ db: stack.db,
359
+ consumers: [sharedA, perInstanceA],
360
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
361
+ instanceId: "mixed-A",
362
+ batchSize: 200,
363
+ pollIntervalMs: 5000,
364
+ });
365
+ const dispB = createEventDispatcher({
366
+ db: stack.db,
367
+ consumers: [sharedB, perInstanceB],
368
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
369
+ instanceId: "mixed-B",
370
+ batchSize: 200,
371
+ pollIntervalMs: 5000,
372
+ });
373
+ await dispA.ensureRegistered();
374
+ await dispB.ensureRegistered();
375
+
376
+ const count = 15;
377
+ for (let i = 0; i < count; i++) {
378
+ await appendWidget(`mix-${i}`);
379
+ }
380
+
381
+ // Multiple pass rounds so slow-loser of the SKIP-LOCKED race on the
382
+ // shared consumer still gets a chance to run if the fast-winner left
383
+ // events behind.
384
+ for (let pass = 0; pass < 3; pass++) {
385
+ await Promise.all([dispA.runOnce(), dispB.runOnce()]);
386
+ }
387
+
388
+ // Shared: total across both sides == count (exactly-once globally).
389
+ expect(sharedSeen).toHaveLength(count);
390
+
391
+ // Per-instance: each side gets the FULL set.
392
+ expect(perInstA).toHaveLength(count);
393
+ expect(perInstB).toHaveLength(count);
394
+ });
395
+
396
+ test("creating a dispatcher with a per-instance consumer but no instanceId throws at construction", () => {
397
+ expect(() =>
398
+ createEventDispatcher({
399
+ db: stack.db,
400
+ consumers: [
401
+ {
402
+ name: "multi:consumer:no-instance-id",
403
+ delivery: "per-instance",
404
+ handler: async () => {},
405
+ },
406
+ ],
407
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
408
+ // instanceId deliberately omitted
409
+ }),
410
+ ).toThrow(/delivery="per-instance".+instanceId/);
411
+ });
412
+
413
+ test("instanceId equal to the reserved sentinel is rejected at construction", () => {
414
+ expect(() =>
415
+ createEventDispatcher({
416
+ db: stack.db,
417
+ consumers: [{ name: "x", handler: async () => {} }],
418
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
419
+ instanceId: "__shared__",
420
+ }),
421
+ ).toThrow(/reserved sentinel/);
422
+ });
423
+ });