@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,123 @@
1
+ // E.4 — PG LISTEN/NOTIFY wake-up. Without this, delivery latency is
2
+ // bounded below by pollIntervalMs (default 100ms, test-stack 50ms). With
3
+ // LISTEN, event-store.append fires `pg_notify` on commit and any
4
+ // subscribed dispatcher wakes immediately — latency becomes TCP
5
+ // round-trip, typically sub-millisecond on localhost.
6
+ //
7
+ // The polling timer stays on as a safety net for dropped subscriptions
8
+ // and crashes between commit and wake. These tests pin:
9
+ //
10
+ // 1. NOTIFY → runOnce fires faster than one pollInterval.
11
+ // 2. The dispatcher starts cleanly when pgClient is wired and stops
12
+ // without leaking the LISTEN connection.
13
+
14
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
15
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
16
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
17
+ import { defineFeature } from "../../engine";
18
+ import { createEntityTable, setupTestStack, type TestStack, TestUsers } from "../../stack";
19
+ import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
20
+
21
+ // --- Fixture ---
22
+
23
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
24
+ entityName: "widget",
25
+ });
26
+
27
+ const deliveryTimes: number[] = [];
28
+
29
+ const listenFeature = defineFeature("listen", (r) => {
30
+ r.entity("widget", sharedWidgetEntity);
31
+
32
+ r.multiStreamProjection({
33
+ name: "latency-probe",
34
+ apply: {
35
+ "widget.created": async () => {
36
+ deliveryTimes.push(Date.now());
37
+ },
38
+ },
39
+ });
40
+ });
41
+
42
+ const admin = TestUsers.admin;
43
+ let stack: TestStack;
44
+ let tdb: TenantDb;
45
+
46
+ beforeAll(async () => {
47
+ stack = await setupTestStack({
48
+ features: [listenFeature],
49
+ systemHooks: [],
50
+ });
51
+ await createEntityTable(stack.db, sharedWidgetEntity, "widget");
52
+ tdb = createTenantDb(stack.db, admin.tenantId);
53
+ });
54
+
55
+ afterAll(async () => {
56
+ // setupTestStack's cleanup handles eventDispatcher.stop(), which in
57
+ // turn unlistens.
58
+ });
59
+
60
+ // --- Tests ---
61
+
62
+ describe("E.4 — PG NOTIFY/LISTEN wake-up", () => {
63
+ test("NOTIFY on commit triggers runOnce faster than one pollInterval", async () => {
64
+ // pollIntervalMs in the test-stack is 50ms. If LISTEN works, delivery
65
+ // lands within a few ms of commit; if LISTEN is broken, it takes up
66
+ // to pollIntervalMs. Use a generous upper bound that still discriminates:
67
+ // if the timer drives delivery, the gap between append and delivery
68
+ // is 25–50ms on average. If LISTEN drives it, it's sub-10ms.
69
+ deliveryTimes.length = 0;
70
+
71
+ await stack.eventDispatcher?.start();
72
+ try {
73
+ const appendedAt = Date.now();
74
+ await executor.create({ name: "latency-test" }, admin, tdb);
75
+
76
+ // Wait up to 500ms, then check latency.
77
+ const deadline = Date.now() + 500;
78
+ while (Date.now() < deadline && deliveryTimes.length === 0) {
79
+ await new Promise((r) => setTimeout(r, 5));
80
+ }
81
+ expect(deliveryTimes).toHaveLength(1);
82
+
83
+ const latencyMs = (deliveryTimes[0] ?? 0) - appendedAt;
84
+ // LISTEN should beat the polling timer comfortably. Originally 40ms
85
+ // (LISTEN typical: <10ms; pollInterval: 50ms). ARM self-hosted runner
86
+ // schwankt bei 50-60ms wegen DB-IPC + clock-jitter im poll-loop —
87
+ // bound auf 2× pollInterval erweitert. Discriminierung bleibt:
88
+ // wenn LISTEN ganz broken ist, fällt der 500ms-Wait am `expect
89
+ // (deliveryTimes).toHaveLength(1)`-Check leer.
90
+ expect(latencyMs).toBeLessThan(100);
91
+ } finally {
92
+ await stack.eventDispatcher?.stop();
93
+ }
94
+ });
95
+
96
+ test("dispatcher start/stop cycle with LISTEN attached still delivers after restart", async () => {
97
+ // Repeated start/stop must not leak connections or break LISTEN. After
98
+ // 3 cycles the last .start() should still wake on NOTIFY — if the
99
+ // unlisten handle was mishandled, the subscription would either be
100
+ // stale (LISTEN on a closed connection) or double-registered.
101
+ for (let i = 0; i < 2; i++) {
102
+ await stack.eventDispatcher?.start();
103
+ await stack.eventDispatcher?.stop();
104
+ }
105
+
106
+ deliveryTimes.length = 0;
107
+ await stack.eventDispatcher?.start();
108
+ try {
109
+ const appendedAt = Date.now();
110
+ await executor.create({ name: "restart-probe" }, admin, tdb);
111
+ const deadline = Date.now() + 500;
112
+ while (Date.now() < deadline && deliveryTimes.length === 0) {
113
+ await new Promise((r) => setTimeout(r, 5));
114
+ }
115
+ expect(deliveryTimes).toHaveLength(1);
116
+ // Latency must still be LISTEN-fast (< pollInterval) — if the
117
+ // subscription silently dropped, the timer would deliver at ~50ms.
118
+ expect((deliveryTimes[0] ?? 0) - appendedAt).toBeLessThan(40);
119
+ } finally {
120
+ await stack.eventDispatcher?.stop();
121
+ }
122
+ });
123
+ });
@@ -0,0 +1,202 @@
1
+ // E.9 — Dead-Letter Recovery surface.
2
+ //
3
+ // The dispatcher halts-on-poison: repeated handler throws on the same event
4
+ // mark the consumer "dead" and pause delivery for it. Without an operational
5
+ // recovery surface, "dead" was a terminal state with only raw SQL as an
6
+ // escape hatch. These tests pin the five recovery moves:
7
+ //
8
+ // restartConsumer status=dead → idle, attempts=0, cursor unchanged.
9
+ // Dispatcher retries the failing event on the next pass.
10
+ // disableConsumer status=any → disabled. Dispatcher skips it entirely.
11
+ // enableConsumer status=disabled → idle. Delivery resumes.
12
+ // skipPoisonEvent advances cursor past the first event after the current
13
+ // cursor, resets attempts, status=idle. For events that
14
+ // will never succeed (broken payload, removed code).
15
+ // (list/status are read-only; covered by event-dispatcher wiring tests.)
16
+
17
+ import { afterEach, beforeAll, describe, expect, test } from "vitest";
18
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
19
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
20
+ import { defineFeature } from "../../engine";
21
+ import {
22
+ disableConsumer,
23
+ enableConsumer,
24
+ getConsumerState,
25
+ restartConsumer,
26
+ skipPoisonEvent,
27
+ } from "../../pipeline";
28
+ import {
29
+ createEntityTable,
30
+ resetEventStore,
31
+ setupTestStack,
32
+ type TestStack,
33
+ TestUsers,
34
+ } from "../../stack";
35
+ import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
36
+
37
+ // --- Fixture ---
38
+
39
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
40
+ entityName: "widget",
41
+ });
42
+
43
+ // Names that make the observer throw. Reset in afterEach.
44
+ let poisonNames = new Set<string>();
45
+ let observed: Array<{ name: string }> = [];
46
+
47
+ const recoveryFeature = defineFeature("recoverytest", (r) => {
48
+ r.entity("widget", sharedWidgetEntity);
49
+
50
+ r.multiStreamProjection({
51
+ name: "observer",
52
+ apply: {
53
+ "widget.created": async (event) => {
54
+ const name = event.payload["name"] as string;
55
+ if (poisonNames.has(name)) {
56
+ throw new Error(`poisoned: ${name}`);
57
+ }
58
+ observed.push({ name });
59
+ },
60
+ },
61
+ });
62
+ });
63
+
64
+ const admin = TestUsers.admin;
65
+ const qn = "recoverytest:projection:observer";
66
+ let stack: TestStack;
67
+ let tdb: TenantDb;
68
+
69
+ beforeAll(async () => {
70
+ stack = await setupTestStack({
71
+ features: [recoveryFeature],
72
+ systemHooks: [],
73
+ });
74
+ await createEntityTable(stack.db, sharedWidgetEntity, "widget");
75
+ tdb = createTenantDb(stack.db, admin.tenantId);
76
+ });
77
+
78
+ afterEach(async () => {
79
+ poisonNames = new Set();
80
+ observed = [];
81
+ await resetEventStore(stack, ["read_widgets"]);
82
+ });
83
+
84
+ async function appendWidget(name: string): Promise<void> {
85
+ await executor.create({ name }, admin, tdb);
86
+ }
87
+
88
+ async function driveUntilDead(): Promise<void> {
89
+ // Default maxAttempts is 10. Run enough passes to exhaust and land on dead.
90
+ for (let i = 0; i < 10; i++) {
91
+ await stack.eventDispatcher?.runOnce();
92
+ }
93
+ }
94
+
95
+ // --- Tests ---
96
+
97
+ describe("E.9 — restartConsumer", () => {
98
+ test("revives a dead consumer: status=idle, attempts=0, cursor unchanged, handler retried next pass", async () => {
99
+ poisonNames.add("poison");
100
+ await appendWidget("poison");
101
+ await driveUntilDead();
102
+
103
+ const deadState = await getConsumerState(stack.db, qn);
104
+ expect(deadState?.status).toBe("dead");
105
+ expect(deadState?.attempts).toBe(10);
106
+ const cursorBefore = deadState?.lastProcessedEventId;
107
+
108
+ const after = await restartConsumer(stack.db, qn);
109
+ expect(after.status).toBe("idle");
110
+ expect(after.attempts).toBe(0);
111
+ expect(after.lastError).toBeNull();
112
+ // Cursor unchanged — next pass retries the SAME failing event.
113
+ expect(after.lastProcessedEventId).toBe(cursorBefore);
114
+
115
+ // Retry still poisoned (handler still throws) — attempts climbs again.
116
+ await stack.eventDispatcher?.runOnce();
117
+ const afterRetry = await getConsumerState(stack.db, qn);
118
+ expect(afterRetry?.attempts).toBe(1);
119
+ expect(afterRetry?.lastError).toMatch(/poisoned: poison/);
120
+ });
121
+
122
+ test("refuses to restart a healthy consumer (only dead makes sense)", async () => {
123
+ await appendWidget("clean");
124
+ await stack.eventDispatcher?.runOnce();
125
+ const state = await getConsumerState(stack.db, qn);
126
+ expect(state?.status).toBe("idle");
127
+
128
+ await expect(restartConsumer(stack.db, qn)).rejects.toThrow(/not dead/);
129
+ });
130
+ });
131
+
132
+ describe("E.9 — disable / enable", () => {
133
+ test("disabled consumer skips delivery even when new events arrive; enable resumes", async () => {
134
+ await appendWidget("first");
135
+ await stack.eventDispatcher?.runOnce();
136
+ expect(observed.map((o) => o.name)).toEqual(["first"]);
137
+
138
+ await disableConsumer(stack.db, qn);
139
+ const disabled = await getConsumerState(stack.db, qn);
140
+ expect(disabled?.status).toBe("disabled");
141
+
142
+ await appendWidget("while-disabled");
143
+ await stack.eventDispatcher?.runOnce();
144
+ // Still only "first" — disabled consumer didn't pick up "while-disabled".
145
+ expect(observed.map((o) => o.name)).toEqual(["first"]);
146
+
147
+ await enableConsumer(stack.db, qn);
148
+ const enabled = await getConsumerState(stack.db, qn);
149
+ expect(enabled?.status).toBe("idle");
150
+
151
+ await stack.eventDispatcher?.runOnce();
152
+ expect(observed.map((o) => o.name)).toEqual(["first", "while-disabled"]);
153
+ });
154
+
155
+ test("enable on a non-disabled consumer throws (prevents accidental state reset)", async () => {
156
+ await appendWidget("healthy");
157
+ await stack.eventDispatcher?.runOnce();
158
+ await expect(enableConsumer(stack.db, qn)).rejects.toThrow(/not disabled/);
159
+ });
160
+ });
161
+
162
+ describe("E.9 — skipPoisonEvent", () => {
163
+ test("skips past a poisoned event, advances cursor, subsequent events deliver", async () => {
164
+ await appendWidget("before-poison");
165
+ await poisonNames.add("the-poison");
166
+ await appendWidget("the-poison");
167
+ poisonNames.delete("the-poison"); // no-op for current state; only matters for later retries
168
+ await appendWidget("after-poison");
169
+
170
+ // Restore throw-on: the handler keeps throwing on the poison name
171
+ // until we explicitly skip it. Re-add.
172
+ poisonNames.add("the-poison");
173
+ await driveUntilDead();
174
+
175
+ const beforeSkip = await getConsumerState(stack.db, qn);
176
+ expect(beforeSkip?.status).toBe("dead");
177
+ // "before-poison" was delivered successfully, so cursor sits at event 1.
178
+ expect(beforeSkip?.lastProcessedEventId).toBe(1n);
179
+
180
+ const skipResult = await skipPoisonEvent(stack.db, qn);
181
+ expect(skipResult.status).toBe("idle");
182
+ expect(skipResult.attempts).toBe(0);
183
+ expect(skipResult.skippedEventId).toBe(2n);
184
+ expect(skipResult.lastProcessedEventId).toBe(2n);
185
+
186
+ // Now the dispatcher should pick up event 3 ("after-poison").
187
+ await stack.eventDispatcher?.runOnce();
188
+ expect(observed.map((o) => o.name)).toEqual(["before-poison", "after-poison"]);
189
+ });
190
+
191
+ test("no-op when cursor is already at events head", async () => {
192
+ await appendWidget("only-one");
193
+ await stack.eventDispatcher?.runOnce();
194
+ const caught = await getConsumerState(stack.db, qn);
195
+ expect(caught?.lastProcessedEventId).toBe(1n);
196
+
197
+ const skipResult = await skipPoisonEvent(stack.db, qn);
198
+ expect(skipResult.skippedEventId).toBeNull();
199
+ // Cursor did not move.
200
+ expect(skipResult.lastProcessedEventId).toBe(1n);
201
+ });
202
+ });
@@ -0,0 +1,290 @@
1
+ // Second-audit fixes — two latent silent-broken behaviours in the async
2
+ // event-dispatcher that only surface when specific call-sites materialise.
3
+ // Pinned here so regressions fail loudly:
4
+ //
5
+ // 1. Prune-vs-new-consumer race. Before the fix, a fresh deploy that
6
+ // adds a new MSP + simultaneously runs prune could silently delete
7
+ // events before the new consumer ever saw them. Two pieces close
8
+ // this: (a) pre-registering every consumer row on dispatcher.start()
9
+ // so the retention guard sees it, (b) a SHARE-mode table lock in
10
+ // pruneEvents as defence-in-depth.
11
+ //
12
+ // 2. LISTEN-subscription health is now emitted as a gauge
13
+ // (kumiko_event_dispatcher_listen_connected). Ops can see the
14
+ // moment delivery latency regresses from TCP-round-trip to
15
+ // pollIntervalMs.
16
+
17
+ import { eq, sql } from "drizzle-orm";
18
+ import { afterEach, beforeAll, describe, expect, test } from "vitest";
19
+ import { defineFeature } from "../../engine";
20
+ import { eventsTable } from "../../event-store";
21
+ import {
22
+ DEFAULT_SENSITIVE_CONFIG,
23
+ type MetricEvent,
24
+ type ObservabilityProvider,
25
+ RecordingMeter,
26
+ RecordingTracer,
27
+ } from "../../observability";
28
+ import { ConsumerLagError, eventConsumerStateTable, pruneEvents } from "../../pipeline";
29
+ import {
30
+ createEntityTable,
31
+ resetEventStore,
32
+ setupTestStack,
33
+ type TestStack,
34
+ TestUsers,
35
+ } from "../../stack";
36
+ import { sharedWidgetEntity } from "../../testing";
37
+ import { generateId } from "../../utils";
38
+
39
+ // --- Fixture ---
40
+
41
+ const auditFeature = defineFeature("audit", (r) => {
42
+ r.entity("widget", sharedWidgetEntity);
43
+
44
+ // Two MSPs so the dispatcher has two consumer rows to register in the
45
+ // pre-registration tests below. Both no-op — the tests observe the
46
+ // kumiko_event_consumers rows, not the apply handlers.
47
+ r.multiStreamProjection({
48
+ name: "default-scope",
49
+ apply: { "widget.created": async () => {} },
50
+ });
51
+ r.multiStreamProjection({
52
+ name: "system-opt-out",
53
+ apply: { "widget.created": async () => {} },
54
+ });
55
+ });
56
+
57
+ const admin = TestUsers.admin;
58
+ let stack: TestStack;
59
+
60
+ beforeAll(async () => {
61
+ stack = await setupTestStack({
62
+ features: [auditFeature],
63
+ systemHooks: [],
64
+ });
65
+ await createEntityTable(stack.db, sharedWidgetEntity, "widget");
66
+ });
67
+
68
+ afterEach(async () => {
69
+ await resetEventStore(stack, ["read_widgets"]);
70
+ });
71
+
72
+ async function seedOldWidgetEvent(createdAt: Temporal.Instant): Promise<void> {
73
+ await stack.db.insert(eventsTable).values({
74
+ aggregateId: generateId(),
75
+ aggregateType: "widget",
76
+ tenantId: admin.tenantId,
77
+ version: 1,
78
+ type: "widget.created",
79
+ payload: {},
80
+ metadata: { userId: admin.id },
81
+ createdAt,
82
+ createdBy: admin.id,
83
+ });
84
+ }
85
+
86
+ // --- Fix #1 — Prune-vs-new-consumer race (pre-registration) ---
87
+
88
+ describe("Second audit — consumer pre-registration on start()", () => {
89
+ test("start() inserts a state row for every registered consumer", async () => {
90
+ // Before the fix, state rows were created lazily on first runOnce. A
91
+ // deploy that brought up the process AND immediately ran prune (in a
92
+ // separate service) could prune past a consumer that hadn't run yet.
93
+ // Pre-registration closes that window: start() inserts, prune sees
94
+ // every consumer at cursor=0, refuses to prune past them.
95
+ await stack.eventDispatcher?.start();
96
+ try {
97
+ const rows = await stack.db.select().from(eventConsumerStateTable);
98
+ const names = new Set(rows.map((r) => r.name));
99
+
100
+ // The test-stack wires only feature MSP consumers (systemHooks: []),
101
+ // so we expect the two r.multiStreamProjection entries.
102
+ expect(names.has("audit:projection:default-scope")).toBe(true);
103
+ expect(names.has("audit:projection:system-opt-out")).toBe(true);
104
+
105
+ // Every pre-registered row starts at cursor 0 with status=idle.
106
+ for (const row of rows) {
107
+ expect(row.lastProcessedEventId).toBe(0n);
108
+ expect(row.status).toBe("idle");
109
+ }
110
+ } finally {
111
+ await stack.eventDispatcher?.stop();
112
+ }
113
+ });
114
+
115
+ test("pre-registered consumer blocks prune via ConsumerLagError", async () => {
116
+ // The integration-level guarantee: after start(), prune refuses to
117
+ // delete events below any pre-registered consumer's cursor. This is
118
+ // the race fix made deterministic — no Promise.all timing, just the
119
+ // invariant the fix guarantees.
120
+ await seedOldWidgetEvent(Temporal.Now.instant().subtract({ hours: 240 }));
121
+
122
+ await stack.eventDispatcher?.start();
123
+ try {
124
+ await expect(
125
+ pruneEvents(stack.db, { olderThanDays: 1, aggregateTypes: ["widget"] }),
126
+ ).rejects.toBeInstanceOf(ConsumerLagError);
127
+ } finally {
128
+ await stack.eventDispatcher?.stop();
129
+ }
130
+ });
131
+
132
+ test("start() is idempotent — two starts don't duplicate state rows", async () => {
133
+ // ON CONFLICT DO NOTHING guarantees this. Guarded so a buildServer
134
+ // double-start (or process-wide restart) doesn't trip unique-violation
135
+ // or regress the cursor.
136
+ await stack.eventDispatcher?.start();
137
+ await stack.eventDispatcher?.stop();
138
+
139
+ // Advance the cursor explicitly so we can prove the second start
140
+ // doesn't clobber it back to 0.
141
+ await stack.db
142
+ .update(eventConsumerStateTable)
143
+ .set({ lastProcessedEventId: 42n })
144
+ .where(eq(eventConsumerStateTable.name, "audit:projection:default-scope"));
145
+
146
+ await stack.eventDispatcher?.start();
147
+ try {
148
+ const [row] = await stack.db
149
+ .select()
150
+ .from(eventConsumerStateTable)
151
+ .where(eq(eventConsumerStateTable.name, "audit:projection:default-scope"));
152
+ expect(row?.lastProcessedEventId).toBe(42n);
153
+ } finally {
154
+ await stack.eventDispatcher?.stop();
155
+ }
156
+ });
157
+ });
158
+
159
+ // --- Fix #2 — LISTEN observability ---
160
+
161
+ describe("Second audit — LISTEN gauge", () => {
162
+ test("kumiko_event_dispatcher_listen_connected is 1 after start() with pgClient", async () => {
163
+ const metricEvents: MetricEvent[] = [];
164
+ const meter = new RecordingMeter((e) => metricEvents.push(e));
165
+ const tracer = new RecordingTracer({
166
+ sensitiveConfig: DEFAULT_SENSITIVE_CONFIG,
167
+ onSpanEnd: () => {},
168
+ });
169
+ const recordingProvider: ObservabilityProvider = {
170
+ name: "recording",
171
+ meter,
172
+ tracer,
173
+ shutdown: async () => {},
174
+ };
175
+
176
+ const recStack = await setupTestStack({
177
+ features: [auditFeature],
178
+ systemHooks: [],
179
+ observability: recordingProvider,
180
+ });
181
+ try {
182
+ await createEntityTable(recStack.db, sharedWidgetEntity, "widget");
183
+
184
+ await recStack.eventDispatcher?.start();
185
+ try {
186
+ // Filter for gauge.set events on the LISTEN metric. Expect the
187
+ // sequence: 0 (start() resets) → 1 (onlisten callback fires).
188
+ const gauges = metricEvents.filter(
189
+ (e) => e.type === "gauge.set" && e.name === "kumiko_event_dispatcher_listen_connected",
190
+ );
191
+ expect(gauges.length).toBeGreaterThanOrEqual(2);
192
+ expect(gauges[0]?.value).toBe(0);
193
+ expect(gauges[gauges.length - 1]?.value).toBe(1);
194
+ } finally {
195
+ await recStack.eventDispatcher?.stop();
196
+ }
197
+
198
+ // After stop(), the gauge must flip back to 0 so ops can tell the
199
+ // subscription was deliberately torn down (vs. silently dropped).
200
+ const postStop = metricEvents
201
+ .filter(
202
+ (e) => e.type === "gauge.set" && e.name === "kumiko_event_dispatcher_listen_connected",
203
+ )
204
+ .pop();
205
+ expect(postStop?.value).toBe(0);
206
+ } finally {
207
+ await recStack.cleanup();
208
+ }
209
+ });
210
+
211
+ test("onlisten fires again on silent reconnect — gauge flips to 1 a second time", async () => {
212
+ // This is the claim that justifies the onlisten-callback over a
213
+ // simpler .set(1) after `await listen()`: on a dropped TCP, postgres.js
214
+ // re-subscribes automatically and invokes onlisten again, and ops
215
+ // needs to see the recovery window. Proved here by killing the
216
+ // LISTEN backend and observing a second gauge.set(1).
217
+ const metricEvents: MetricEvent[] = [];
218
+ const meter = new RecordingMeter((e) => metricEvents.push(e));
219
+ const tracer = new RecordingTracer({
220
+ sensitiveConfig: DEFAULT_SENSITIVE_CONFIG,
221
+ onSpanEnd: () => {},
222
+ });
223
+ const recordingProvider: ObservabilityProvider = {
224
+ name: "recording",
225
+ meter,
226
+ tracer,
227
+ shutdown: async () => {},
228
+ };
229
+
230
+ const recStack = await setupTestStack({
231
+ features: [auditFeature],
232
+ systemHooks: [],
233
+ observability: recordingProvider,
234
+ });
235
+ try {
236
+ await createEntityTable(recStack.db, sharedWidgetEntity, "widget");
237
+
238
+ await recStack.eventDispatcher?.start();
239
+ try {
240
+ const connectsWithValue1 = (): number =>
241
+ metricEvents.filter(
242
+ (e) =>
243
+ e.type === "gauge.set" &&
244
+ e.name === "kumiko_event_dispatcher_listen_connected" &&
245
+ e.value === 1,
246
+ ).length;
247
+
248
+ // Wait for the initial onlisten (gauge.set 1) to land.
249
+ await waitFor(() => connectsWithValue1() >= 1, 2000);
250
+ const initialConnects = connectsWithValue1();
251
+ expect(initialConnects).toBeGreaterThanOrEqual(1);
252
+
253
+ // Terminate the LISTEN backend. postgres.js runs LISTEN on a
254
+ // dedicated max=1 sub-pool — the backend whose last query is
255
+ // `LISTEN "kumiko_events_new"` and is now idle. pg_terminate_backend
256
+ // on it closes the TCP; postgres.js's onclose handler re-subscribes
257
+ // and fires onlisten again.
258
+ await recStack.db.execute(
259
+ sql`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
260
+ WHERE datname = current_database()
261
+ AND query ILIKE 'listen%'
262
+ AND state = 'idle'
263
+ AND pid <> pg_backend_pid()`,
264
+ );
265
+
266
+ // Wait for the SECOND gauge.set(1) — the reconnect. Generous timeout
267
+ // because postgres.js's reconnect loop includes backoff.
268
+ await waitFor(() => connectsWithValue1() > initialConnects, 10000);
269
+ expect(connectsWithValue1()).toBeGreaterThan(initialConnects);
270
+ } finally {
271
+ await recStack.eventDispatcher?.stop();
272
+ }
273
+ } finally {
274
+ await recStack.cleanup();
275
+ }
276
+ });
277
+ });
278
+
279
+ // --- Helpers ---
280
+
281
+ async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
282
+ const start = Date.now();
283
+ while (Date.now() - start < timeoutMs) {
284
+ if (predicate()) return;
285
+ await new Promise((r) => setTimeout(r, 25));
286
+ }
287
+ if (!predicate()) {
288
+ throw new Error(`waitFor: predicate never became true within ${timeoutMs}ms`);
289
+ }
290
+ }
@@ -0,0 +1,65 @@
1
+ // Strict-precondition contract: runOnce() without a preceding start() /
2
+ // ensureRegistered() must throw, not silently bootstrap. The throw is the
3
+ // dispatcher's last line of defense against the prune-race — if pre-reg
4
+ // is skipped, the state rows are absent, and acquireConsumerState would
5
+ // return skip="not_registered" for every consumer. Failing loudly surfaces
6
+ // the misuse instead of swallowing events.
7
+ //
8
+ // Unit-level because the check happens before any DB call — no pgClient,
9
+ // no event-store schema, no setupTestStack needed.
10
+
11
+ import { describe, expect, test } from "vitest";
12
+ import type { AppContext, Registry } from "../../engine/types";
13
+ import { createEventDispatcher, type EventConsumer } from "../event-dispatcher";
14
+
15
+ function stubContext(): AppContext {
16
+ return {
17
+ db: {} as unknown as AppContext["db"],
18
+ redis: {} as unknown as AppContext["redis"],
19
+ registry: {} as unknown as Registry,
20
+ } as AppContext;
21
+ }
22
+
23
+ describe("event-dispatcher — strict runOnce precondition", () => {
24
+ test("runOnce() before start() throws a clear 'not registered' error", async () => {
25
+ const consumers: EventConsumer[] = [{ name: "noop", handler: async () => {} }];
26
+ const dispatcher = createEventDispatcher({
27
+ db: {} as never,
28
+ consumers,
29
+ context: stubContext(),
30
+ });
31
+
32
+ await expect(dispatcher.runOnce()).rejects.toThrow(/runOnce\(\) called before start\(\)/);
33
+ });
34
+
35
+ test("ensureRegistered() is a valid alternative to start() — runOnce no longer throws", async () => {
36
+ const consumers: EventConsumer[] = [{ name: "noop", handler: async () => {} }];
37
+ let insertCalls = 0;
38
+ const stubDb = {
39
+ insert: () => ({
40
+ values: () => ({ onConflictDoNothing: async () => ++insertCalls }),
41
+ }),
42
+ transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
43
+ fn({
44
+ select: () => ({
45
+ from: () => ({ where: () => ({ for: () => [] }) }),
46
+ }),
47
+ execute: async () => [],
48
+ update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
49
+ }),
50
+ };
51
+ const dispatcher = createEventDispatcher({
52
+ db: stubDb as never,
53
+ consumers,
54
+ context: stubContext(),
55
+ });
56
+
57
+ await dispatcher.ensureRegistered();
58
+ expect(insertCalls).toBe(1);
59
+ // runOnce() no longer throws — it drains against the stubbed transaction.
60
+ await expect(dispatcher.runOnce()).resolves.toMatchObject({
61
+ processed: 0,
62
+ failed: 0,
63
+ });
64
+ });
65
+ });