@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,147 @@
1
+ // Projection-Rebuild Performance — NOT a perf gate, a "not-broken" gate.
2
+ //
3
+ // Asserts the current rebuildProjection() pipeline (registry + state-table
4
+ // + status-lifecycle wrapper) still moves bulk events at a sane rate. The
5
+ // real performance number is what we observe in isolation: 14–15k events/s
6
+ // on this hardware. The threshold below is intentionally loose because
7
+ // vitest runs integration suites in parallel — other files hammer the same
8
+ // Postgres at the same time, and an I/O-bound rebuild shares bandwidth.
9
+ //
10
+ // Threshold: 5000 events/s. Picked so a 2× regression on a real bottleneck
11
+ // (e.g. accidental N+1 in the apply-loop, missing index on events.id, a
12
+ // stray await in the hot path) trips the test, while normal suite-load
13
+ // jitter does not. If this ever flakes in CI, drop to 3000 — the goal is
14
+ // "catastrophic regression detector", not "perf SLO".
15
+
16
+ import { sql } from "drizzle-orm";
17
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
18
+ import {
19
+ integer as drizzleInteger,
20
+ table as drizzlePgTable,
21
+ uuid as drizzleUuid,
22
+ } from "../../db/dialect";
23
+ import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
24
+ import type { ProjectionDefinition } from "../../engine/types";
25
+ import { createEventsTable } from "../../event-store";
26
+ import { createProjectionStateTable, rebuildProjection } from "../../pipeline";
27
+ import { createTestDb, pushTables, type TestDb, TestUsers } from "../../stack";
28
+ import { generateId as uuid } from "../../utils";
29
+
30
+ // Counter projection: every task.created bumps a counter, every
31
+ // task.updated is a no-op. Enough to exercise the apply path —
32
+ // rebuild cost is dominated by event iteration + apply dispatch,
33
+ // not the projection state shape.
34
+ const taskCountTable = drizzlePgTable("read_perf_rebuild_task_count", {
35
+ tenantId: drizzleUuid("tenant_id").primaryKey(),
36
+ count: drizzleInteger("count").notNull().default(0),
37
+ });
38
+
39
+ const taskCountProjection: ProjectionDefinition = {
40
+ name: "task-count",
41
+ source: "task",
42
+ table: taskCountTable,
43
+ apply: {
44
+ "task.created": async (event, tx) => {
45
+ await tx
46
+ .insert(taskCountTable)
47
+ .values({ tenantId: event.tenantId, count: 1 })
48
+ .onConflictDoUpdate({
49
+ target: taskCountTable.tenantId,
50
+ set: { count: sql`${taskCountTable.count} + 1` },
51
+ });
52
+ },
53
+ "task.updated": async (_event, _tx) => {
54
+ // No-op apply — measuring event-iteration overhead, not per-event
55
+ // DB roundtrips. 10k events/s with one row-update per event would
56
+ // be an I/O-bound test, not a rebuild-throughput test.
57
+ },
58
+ },
59
+ };
60
+
61
+ const taskEntity = createEntity({
62
+ table: "perf_rebuild_tasks",
63
+ fields: { title: createTextField({ required: true }) },
64
+ });
65
+
66
+ const feature = defineFeature("perfrebuild", (r) => {
67
+ r.entity("task", taskEntity);
68
+ r.projection(taskCountProjection);
69
+ });
70
+
71
+ const admin = TestUsers.admin;
72
+ let testDb: TestDb;
73
+ const registry = createRegistry([feature]);
74
+ const qualifiedProjectionName = "perfrebuild:projection:task-count";
75
+
76
+ beforeAll(async () => {
77
+ testDb = await createTestDb();
78
+ await createEventsTable(testDb.db);
79
+ await createProjectionStateTable(testDb.db);
80
+ await pushTables(testDb.db, { perf_rebuild_task_count: taskCountTable });
81
+ });
82
+
83
+ afterAll(async () => {
84
+ await testDb.cleanup();
85
+ });
86
+
87
+ beforeEach(async () => {
88
+ await testDb.db.execute(
89
+ sql`TRUNCATE kumiko_events, read_perf_rebuild_task_count, kumiko_projections RESTART IDENTITY CASCADE`,
90
+ );
91
+ });
92
+
93
+ // Bulk-seed via SQL — sequential append() calls would take minutes.
94
+ // Measures rebuild throughput on a finished stream, not the seed phase.
95
+ // Produces count aggregates × depth events per aggregate.
96
+ async function seedEvents(count: number, depth: number): Promise<void> {
97
+ const userId = uuid();
98
+ // v1 creates
99
+ await testDb.db.execute(sql`
100
+ INSERT INTO kumiko_events (aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
101
+ SELECT gen_random_uuid(), 'task', ${admin.tenantId}::uuid, 1, 'task.created',
102
+ jsonb_build_object('title', 'Task ' || gs.n),
103
+ jsonb_build_object('userId', ${userId}::text),
104
+ ${userId}::text
105
+ FROM generate_series(1, ${count}) AS gs(n);
106
+ `);
107
+ // v2..depth updates
108
+ for (let v = 2; v <= depth; v++) {
109
+ await testDb.db.execute(sql`
110
+ INSERT INTO kumiko_events (aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
111
+ SELECT e.aggregate_id, 'task', ${admin.tenantId}::uuid, ${v}, 'task.updated',
112
+ jsonb_build_object('title', 'Task v' || ${v}),
113
+ jsonb_build_object('userId', ${userId}::text),
114
+ ${userId}::text
115
+ FROM kumiko_events e
116
+ WHERE e.aggregate_type = 'task' AND e.version = ${v - 1};
117
+ `);
118
+ }
119
+ }
120
+
121
+ describe("rebuildProjection performance — Gate A", () => {
122
+ test("rebuild rate >= 3k events/sec under suite-parallel-load (10000 events)", async () => {
123
+ // 2000 aggregates × 5 events = 10000 events
124
+ await seedEvents(2000, 5);
125
+
126
+ const start = performance.now();
127
+ const result = await rebuildProjection(qualifiedProjectionName, {
128
+ db: testDb.db,
129
+ registry,
130
+ });
131
+ const durationMs = performance.now() - start;
132
+
133
+ expect(result.eventsProcessed).toBe(10_000);
134
+ const rate = result.eventsProcessed / (durationMs / 1000);
135
+ console.log(
136
+ ` Rebuild: ${result.eventsProcessed} events in ${durationMs.toFixed(1)}ms = ${Math.round(rate)} events/s`,
137
+ );
138
+
139
+ // Budget 3k events/s under suite-parallel-load. Isolated runs on dev
140
+ // hardware see ~14k events/s; parallel-load drops it 3-4x (Docker-PG
141
+ // contention, Vitest worker concurrency). The gate catches real
142
+ // regressions (~40% drop to <2k) without daily false positives. If
143
+ // you see this flake below 3k, profile `rebuildProjection` — don't
144
+ // just lower the budget further.
145
+ expect(rate).toBeGreaterThanOrEqual(3_000);
146
+ });
147
+ });
@@ -0,0 +1,551 @@
1
+ // Projection rebuild — the load-bearing claim of the whole projections API.
2
+ // "Projections are rebuildable read-models" has to actually work: replaying
3
+ // the event log must produce the exact same state as live apply().
4
+ //
5
+ // Tests here:
6
+ // - rebuild from empty state matches live-applied state
7
+ // - rebuild after data-corruption fixes the projection
8
+ // - rebuild preserves atomicity (throw mid-replay → status=failed + old
9
+ // rows intact)
10
+ // - status lifecycle (idle → rebuilding → idle on success, → failed on throw)
11
+ // - never-rebuilt projection has sensible default state
12
+
13
+ import { eq, sql } from "drizzle-orm";
14
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
15
+ import {
16
+ integer as drizzleInteger,
17
+ table as drizzlePgTable,
18
+ uuid as drizzleUuid,
19
+ } from "../../db/dialect";
20
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
21
+ import { buildDrizzleTable } from "../../db/table-builder";
22
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
23
+ import {
24
+ createEntity,
25
+ createRegistry,
26
+ createTextField,
27
+ defineApply,
28
+ defineFeature,
29
+ } from "../../engine";
30
+ import type { ProjectionDefinition } from "../../engine/types";
31
+ import { createEventsTable } from "../../event-store";
32
+ import {
33
+ createProjectionStateTable,
34
+ getAllProjectionProgress,
35
+ getProjectionState,
36
+ listProjectionsWithState,
37
+ rebuildProjection,
38
+ } from "../../pipeline";
39
+ import { createEntityTable, createTestDb, pushTables, type TestDb, TestUsers } from "../../stack";
40
+
41
+ // --- Test fixtures ---
42
+
43
+ const itemEntity = createEntity({
44
+ table: "read_rebuild_items",
45
+ fields: {
46
+ groupId: createTextField({ required: true }),
47
+ name: createTextField({ required: true }),
48
+ },
49
+ softDelete: true,
50
+ });
51
+ const itemTable = buildDrizzleTable("rebuild-item", itemEntity);
52
+
53
+ const itemsPerGroupTable = drizzlePgTable("read_rebuild_items_per_group", {
54
+ groupId: drizzleUuid("group_id").primaryKey(),
55
+ tenantId: drizzleUuid("tenant_id").notNull(),
56
+ itemCount: drizzleInteger("item_count").notNull().default(0),
57
+ });
58
+
59
+ async function bump(tx: unknown, groupId: string, tenantId: string, delta: number): Promise<void> {
60
+ // biome-ignore lint/suspicious/noExplicitAny: tx is DbRunner
61
+ await (tx as any)
62
+ .insert(itemsPerGroupTable)
63
+ .values({ groupId, tenantId, itemCount: delta })
64
+ .onConflictDoUpdate({
65
+ target: itemsPerGroupTable.groupId,
66
+ set: { itemCount: sql`${itemsPerGroupTable.itemCount} + ${delta}` },
67
+ });
68
+ }
69
+
70
+ type ItemCreated = { groupId: string };
71
+ type ItemRestoreOrDelete = { previous: { groupId: string } };
72
+
73
+ const itemsPerGroupProjection: ProjectionDefinition = {
74
+ name: "items-per-group",
75
+ source: "rebuild-item",
76
+ table: itemsPerGroupTable,
77
+ apply: {
78
+ "rebuild-item.created": defineApply<ItemCreated>(async (event, tx) => {
79
+ await bump(tx, event.payload.groupId, event.tenantId, 1);
80
+ }),
81
+ "rebuild-item.deleted": defineApply<ItemRestoreOrDelete>(async (event, tx) => {
82
+ await bump(tx, event.payload.previous.groupId, event.tenantId, -1);
83
+ }),
84
+ "rebuild-item.restored": defineApply<ItemRestoreOrDelete>(async (event, tx) => {
85
+ await bump(tx, event.payload.previous.groupId, event.tenantId, 1);
86
+ }),
87
+ },
88
+ };
89
+
90
+ const feature = defineFeature("rebuildtest", (r) => {
91
+ r.entity("rebuild-item", itemEntity);
92
+ r.projection(itemsPerGroupProjection);
93
+ });
94
+
95
+ const admin = TestUsers.admin;
96
+ let testDb: TestDb;
97
+ let tdb: TenantDb;
98
+ const registry = createRegistry([feature]);
99
+ const qualifiedProjectionName = "rebuildtest:projection:items-per-group";
100
+
101
+ // Drizzle identifier for the executor.
102
+ const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "rebuild-item" });
103
+
104
+ beforeAll(async () => {
105
+ testDb = await createTestDb();
106
+ await createEntityTable(testDb.db, itemEntity, "rebuild-item");
107
+ await createEventsTable(testDb.db);
108
+ await createProjectionStateTable(testDb.db);
109
+ await pushTables(testDb.db, { rebuildItemsPerGroup: itemsPerGroupTable });
110
+ tdb = createTenantDb(testDb.db, admin.tenantId);
111
+ });
112
+
113
+ afterAll(async () => {
114
+ await testDb.cleanup();
115
+ });
116
+
117
+ beforeEach(async () => {
118
+ await testDb.db.execute(
119
+ sql`TRUNCATE kumiko_events, read_rebuild_items, read_rebuild_items_per_group, kumiko_projections RESTART IDENTITY CASCADE`,
120
+ );
121
+ });
122
+
123
+ // --- Live-apply helper: use the dispatcher pipeline so projections fire.
124
+ // For rebuild-only tests we can bypass live apply and just append events
125
+ // directly — the point of rebuild is to reconstruct state from events alone.
126
+
127
+ async function appendCreatedEvent(groupId: string, name: string): Promise<void> {
128
+ // Use the executor directly — this fires events + the entity row, but
129
+ // NOT the projection (pipeline not wired). Perfect for "live has no
130
+ // projection state, rebuild reconstructs it" scenarios.
131
+ await executor.create({ groupId, name }, admin, tdb);
132
+ }
133
+
134
+ async function getCount(groupId: string): Promise<number | undefined> {
135
+ const [row] = await testDb.db
136
+ .select()
137
+ .from(itemsPerGroupTable)
138
+ .where(eq(itemsPerGroupTable.groupId, groupId));
139
+ return row?.itemCount;
140
+ }
141
+
142
+ describe("rebuildProjection — happy path", () => {
143
+ test("replays events and produces correct counter state", async () => {
144
+ const group = "00000000-0000-4000-8000-000000000001";
145
+ await appendCreatedEvent(group, "item1");
146
+ await appendCreatedEvent(group, "item2");
147
+ await appendCreatedEvent(group, "item3");
148
+
149
+ // Projection table is empty — pipeline wasn't wired in these writes.
150
+ expect(await getCount(group)).toBeUndefined();
151
+
152
+ const result = await rebuildProjection(qualifiedProjectionName, {
153
+ db: testDb.db,
154
+ registry,
155
+ });
156
+
157
+ expect(result.projection).toBe(qualifiedProjectionName);
158
+ expect(result.eventsProcessed).toBe(3);
159
+ expect(result.lastProcessedEventId).toBeGreaterThan(0n);
160
+
161
+ // Counter now reflects all three creates.
162
+ expect(await getCount(group)).toBe(3);
163
+ });
164
+
165
+ test("rebuild wipes existing state before replay (no double-count)", async () => {
166
+ const group = "00000000-0000-4000-8000-000000000002";
167
+ await appendCreatedEvent(group, "a");
168
+ await appendCreatedEvent(group, "b");
169
+
170
+ // Seed the projection table with a stale/wrong value.
171
+ await testDb.db
172
+ .insert(itemsPerGroupTable)
173
+ .values({ groupId: group, tenantId: admin.tenantId, itemCount: 999 });
174
+
175
+ const result = await rebuildProjection(qualifiedProjectionName, {
176
+ db: testDb.db,
177
+ registry,
178
+ });
179
+
180
+ expect(result.eventsProcessed).toBe(2);
181
+ // Not 999+2, not 999 — TRUNCATE + replay.
182
+ expect(await getCount(group)).toBe(2);
183
+ });
184
+
185
+ test("handles events across multiple groups and aggregate IDs", async () => {
186
+ const groupA = "00000000-0000-4000-8000-000000000010";
187
+ const groupB = "00000000-0000-4000-8000-000000000011";
188
+
189
+ await appendCreatedEvent(groupA, "a1");
190
+ await appendCreatedEvent(groupB, "b1");
191
+ await appendCreatedEvent(groupA, "a2");
192
+ await appendCreatedEvent(groupA, "a3");
193
+ await appendCreatedEvent(groupB, "b2");
194
+
195
+ const result = await rebuildProjection(qualifiedProjectionName, {
196
+ db: testDb.db,
197
+ registry,
198
+ });
199
+
200
+ expect(result.eventsProcessed).toBe(5);
201
+ expect(await getCount(groupA)).toBe(3);
202
+ expect(await getCount(groupB)).toBe(2);
203
+ });
204
+
205
+ test("rebuild on empty event log is a no-op with 0 events processed", async () => {
206
+ const result = await rebuildProjection(qualifiedProjectionName, {
207
+ db: testDb.db,
208
+ registry,
209
+ });
210
+ expect(result.eventsProcessed).toBe(0);
211
+ expect(result.lastProcessedEventId).toBe(0n);
212
+ });
213
+ });
214
+
215
+ describe("rebuildProjection — state table lifecycle", () => {
216
+ test("writes state row with status=idle + lastRebuildAt after success", async () => {
217
+ const group = "00000000-0000-4000-8000-000000000020";
218
+ await appendCreatedEvent(group, "one");
219
+
220
+ // Before: no state row.
221
+ expect(await getProjectionState(testDb.db, qualifiedProjectionName)).toBeNull();
222
+
223
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
224
+
225
+ const state = await getProjectionState(testDb.db, qualifiedProjectionName);
226
+ expect(state?.status).toBe("idle");
227
+ expect(state?.lastProcessedEventId).toBeGreaterThan(0n);
228
+ expect(state?.lastRebuildAt).not.toBeNull();
229
+ expect(state?.lastError).toBeNull();
230
+ });
231
+
232
+ test("subsequent rebuild overwrites state row (status + timestamp)", async () => {
233
+ const group = "00000000-0000-4000-8000-000000000021";
234
+ await appendCreatedEvent(group, "first");
235
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
236
+ const first = await getProjectionState(testDb.db, qualifiedProjectionName);
237
+
238
+ // Wait a tick so timestamp difference is visible.
239
+ await new Promise((r) => setTimeout(r, 20));
240
+ await appendCreatedEvent(group, "second");
241
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
242
+
243
+ const second = await getProjectionState(testDb.db, qualifiedProjectionName);
244
+ if (!first?.lastRebuildAt || !second?.lastRebuildAt) throw new Error("missing lastRebuildAt");
245
+ expect(Temporal.Instant.compare(second.lastRebuildAt, first.lastRebuildAt)).toBeGreaterThan(0);
246
+ expect(second?.lastProcessedEventId).toBeGreaterThan(first?.lastProcessedEventId ?? 0n);
247
+ });
248
+ });
249
+
250
+ describe("rebuildProjection — error path", () => {
251
+ test("apply throw rolls TRUNCATE + partial replay back, marks status=failed", async () => {
252
+ const group = "00000000-0000-4000-8000-000000000030";
253
+ await appendCreatedEvent(group, "keeper-1");
254
+ await appendCreatedEvent(group, "keeper-2");
255
+
256
+ // First rebuild succeeds — leaves counter at 2.
257
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
258
+ expect(await getCount(group)).toBe(2);
259
+
260
+ // Construct a broken registry where apply("rebuild-item.created") throws.
261
+ const brokenFeature = defineFeature("brokentest", (r) => {
262
+ r.entity("rebuild-item", itemEntity);
263
+ r.projection({
264
+ ...itemsPerGroupProjection,
265
+ name: "items-per-group",
266
+ apply: {
267
+ "rebuild-item.created": async () => {
268
+ throw new Error("boom");
269
+ },
270
+ },
271
+ });
272
+ });
273
+ const brokenRegistry = createRegistry([brokenFeature]);
274
+ const brokenName = "brokentest:projection:items-per-group";
275
+
276
+ // Rebuild throws.
277
+ await expect(
278
+ rebuildProjection(brokenName, { db: testDb.db, registry: brokenRegistry }),
279
+ ).rejects.toThrow("boom");
280
+
281
+ // Old counter rows are gone (TRUNCATE is inside the TX but this is a
282
+ // DIFFERENT projection). Verify our original projection's rows WERE
283
+ // preserved because the broken rebuild targets a different name.
284
+ expect(await getCount(group)).toBe(2);
285
+
286
+ // State of the broken projection is "failed" with the error message.
287
+ const state = await getProjectionState(testDb.db, brokenName);
288
+ expect(state?.status).toBe("failed");
289
+ expect(state?.lastError).toContain("boom");
290
+ });
291
+
292
+ test("broken rebuild of EXISTING projection keeps OLD rows intact", async () => {
293
+ const group = "00000000-0000-4000-8000-000000000031";
294
+ await appendCreatedEvent(group, "a");
295
+ await appendCreatedEvent(group, "b");
296
+ await appendCreatedEvent(group, "c");
297
+
298
+ // First rebuild leaves counter at 3.
299
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
300
+ expect(await getCount(group)).toBe(3);
301
+
302
+ // Now attempt a rebuild with a broken apply under the SAME projection name.
303
+ const brokenFeature = defineFeature("rebuildtest", (r) => {
304
+ r.entity("rebuild-item", itemEntity);
305
+ r.projection({
306
+ ...itemsPerGroupProjection,
307
+ apply: {
308
+ "rebuild-item.created": async () => {
309
+ throw new Error("poisoned");
310
+ },
311
+ },
312
+ });
313
+ });
314
+ const brokenRegistry = createRegistry([brokenFeature]);
315
+
316
+ await expect(
317
+ rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry: brokenRegistry }),
318
+ ).rejects.toThrow("poisoned");
319
+
320
+ // CRITICAL: the old counter rows survive. TRUNCATE happened INSIDE the
321
+ // transaction, so the rollback restored them. Without this the rebuild
322
+ // would be worse than not rebuilding at all.
323
+ expect(await getCount(group)).toBe(3);
324
+
325
+ // State reflects the failure.
326
+ const state = await getProjectionState(testDb.db, qualifiedProjectionName);
327
+ expect(state?.status).toBe("failed");
328
+ expect(state?.lastError).toContain("poisoned");
329
+ });
330
+
331
+ test("unknown projection name throws with helpful message", async () => {
332
+ await expect(rebuildProjection("nonexistent", { db: testDb.db, registry })).rejects.toThrow(
333
+ /not registered/,
334
+ );
335
+ });
336
+ });
337
+
338
+ describe("listProjectionsWithState", () => {
339
+ test("lists every registered projection with combined state info", async () => {
340
+ // Before any rebuild: state field indicates never-rebuilt.
341
+ const before = await listProjectionsWithState(testDb.db, registry);
342
+ expect(before).toHaveLength(1);
343
+ expect(before[0]?.name).toBe(qualifiedProjectionName);
344
+ expect(before[0]?.status).toBe("never-rebuilt");
345
+ expect(before[0]?.sources).toEqual(["rebuild-item"]);
346
+
347
+ // After rebuild: status reflects DB state.
348
+ await appendCreatedEvent("00000000-0000-4000-8000-000000000040", "x");
349
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
350
+
351
+ const after = await listProjectionsWithState(testDb.db, registry);
352
+ expect(after[0]?.status).toBe("idle");
353
+ expect(after[0]?.lastRebuildAt).not.toBeNull();
354
+ });
355
+ });
356
+
357
+ describe("getAllProjectionProgress", () => {
358
+ test("computes lag = highWaterMark - cursor for caught-up projection", async () => {
359
+ // Empty event-log → HWM=0n, lag=0n, projection never-rebuilt → cursor=0n.
360
+ const empty = await getAllProjectionProgress(testDb.db, registry);
361
+ expect(empty[0]?.highWaterMark).toBe(0n);
362
+ expect(empty[0]?.lag).toBe(0n);
363
+
364
+ // Seed some events but skip rebuild → HWM advances, cursor stays 0n,
365
+ // lag = HWM. This is the "behind" state an ops dashboard sees before
366
+ // someone triggers a rebuild.
367
+ await appendCreatedEvent("00000000-0000-4000-8000-000000000060", "a");
368
+ await appendCreatedEvent("00000000-0000-4000-8000-000000000061", "b");
369
+ await appendCreatedEvent("00000000-0000-4000-8000-000000000062", "c");
370
+
371
+ const behind = await getAllProjectionProgress(testDb.db, registry);
372
+ expect(behind[0]?.highWaterMark).toBe(3n);
373
+ expect(behind[0]?.lastProcessedEventId).toBe(0n);
374
+ expect(behind[0]?.lag).toBe(3n);
375
+
376
+ // Nach rebuild: cursor = HWM, lag wieder 0.
377
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
378
+ const caughtUp = await getAllProjectionProgress(testDb.db, registry);
379
+ expect(caughtUp[0]?.highWaterMark).toBe(3n);
380
+ expect(caughtUp[0]?.lastProcessedEventId).toBe(3n);
381
+ expect(caughtUp[0]?.lag).toBe(0n);
382
+ });
383
+ });
384
+
385
+ describe("rebuildProjection — metrics callback", () => {
386
+ test("invokes onMetrics with the RebuildResult on success", async () => {
387
+ const group = "00000000-0000-4000-8000-000000000050";
388
+ await appendCreatedEvent(group, "a");
389
+ await appendCreatedEvent(group, "b");
390
+
391
+ const calls: Array<{
392
+ projection: string;
393
+ eventsProcessed: number;
394
+ durationMs: number;
395
+ }> = [];
396
+ await rebuildProjection(qualifiedProjectionName, {
397
+ db: testDb.db,
398
+ registry,
399
+ onMetrics: (r) =>
400
+ calls.push({
401
+ projection: r.projection,
402
+ eventsProcessed: r.eventsProcessed,
403
+ durationMs: r.durationMs,
404
+ }),
405
+ });
406
+
407
+ expect(calls).toHaveLength(1);
408
+ expect(calls[0]?.projection).toBe(qualifiedProjectionName);
409
+ expect(calls[0]?.eventsProcessed).toBe(2);
410
+ expect(calls[0]?.durationMs).toBeGreaterThanOrEqual(0);
411
+ });
412
+ });
413
+
414
+ describe("rebuildProjection — meter emission", () => {
415
+ test("emits success=true metric + events counter on happy path", async () => {
416
+ const { RecordingMeter } = await import("../../observability/recording-meter");
417
+ const { registerStandardMetrics } = await import("../../observability/standard-metrics");
418
+
419
+ const group = "00000000-0000-4000-8000-000000000060";
420
+ await appendCreatedEvent(group, "a");
421
+ await appendCreatedEvent(group, "b");
422
+ await appendCreatedEvent(group, "c");
423
+
424
+ const events: Array<{
425
+ type: string;
426
+ name: string;
427
+ value: number;
428
+ labels: Record<string, string | number> | undefined;
429
+ }> = [];
430
+ const meter = new RecordingMeter((e) =>
431
+ events.push({
432
+ type: e.type,
433
+ name: e.name,
434
+ value: e.value,
435
+ labels: e.labels as Record<string, string | number> | undefined,
436
+ }),
437
+ );
438
+ registerStandardMetrics(meter);
439
+
440
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry, meter });
441
+
442
+ const duration = events.find((e) => e.name === "kumiko_projection_rebuild_duration_seconds");
443
+ expect(duration).toBeDefined();
444
+ expect(duration?.type).toBe("histogram.observe");
445
+ expect(duration?.labels?.["projection"]).toBe(qualifiedProjectionName);
446
+ expect(duration?.labels?.["success"]).toBe("true");
447
+ expect(duration?.value).toBeGreaterThanOrEqual(0);
448
+
449
+ const counter = events.find((e) => e.name === "kumiko_projection_rebuild_events_total");
450
+ expect(counter).toBeDefined();
451
+ expect(counter?.type).toBe("counter.inc");
452
+ expect(counter?.value).toBe(3);
453
+ expect(counter?.labels?.["projection"]).toBe(qualifiedProjectionName);
454
+ });
455
+
456
+ test("emits success=false metric when apply throws", async () => {
457
+ const { RecordingMeter } = await import("../../observability/recording-meter");
458
+ const { registerStandardMetrics } = await import("../../observability/standard-metrics");
459
+
460
+ const group = "00000000-0000-4000-8000-000000000061";
461
+ await appendCreatedEvent(group, "a");
462
+
463
+ const brokenFeature = defineFeature("failmeter", (r) => {
464
+ r.entity("rebuild-item", itemEntity);
465
+ r.projection({
466
+ ...itemsPerGroupProjection,
467
+ apply: {
468
+ "rebuild-item.created": async () => {
469
+ throw new Error("metric-failure-probe");
470
+ },
471
+ },
472
+ });
473
+ });
474
+ const brokenRegistry = createRegistry([brokenFeature]);
475
+
476
+ const events: Array<{
477
+ type: string;
478
+ name: string;
479
+ value: number;
480
+ labels: Record<string, string | number> | undefined;
481
+ }> = [];
482
+ const meter = new RecordingMeter((e) =>
483
+ events.push({
484
+ type: e.type,
485
+ name: e.name,
486
+ value: e.value,
487
+ labels: e.labels as Record<string, string | number> | undefined,
488
+ }),
489
+ );
490
+ registerStandardMetrics(meter);
491
+
492
+ await expect(
493
+ rebuildProjection("failmeter:projection:items-per-group", {
494
+ db: testDb.db,
495
+ registry: brokenRegistry,
496
+ meter,
497
+ }),
498
+ ).rejects.toThrow("metric-failure-probe");
499
+
500
+ const duration = events.find((e) => e.name === "kumiko_projection_rebuild_duration_seconds");
501
+ expect(duration).toBeDefined();
502
+ expect(duration?.labels?.["success"]).toBe("false");
503
+ expect(duration?.labels?.["projection"]).toBe("failmeter:projection:items-per-group");
504
+ });
505
+ });
506
+
507
+ describe("rebuildProjection — cancellation", () => {
508
+ test("pre-aborted signal: rebuild throws, TRUNCATE rolls back, projection state preserved", async () => {
509
+ // Setup: events on the log + a clean rebuild → projection has known
510
+ // counter state. Then call rebuildProjection with a pre-aborted
511
+ // controller. The first throwIfAborted() inside the apply loop
512
+ // throws, the TX rolls back, and the projection row from the prior
513
+ // good rebuild is still there.
514
+ //
515
+ // Why pre-aborted instead of mid-replay: the apply hook is wired in
516
+ // the projection definition at registry-build-time, so injecting
517
+ // "abort after event N" requires a separate registered projection.
518
+ // This test pins the rollback semantics — a separate test would be
519
+ // needed to exercise mid-loop abort, but the rollback path is the
520
+ // same code so the value-add is small.
521
+ const group = "00000000-0000-4000-8000-0000000000c1";
522
+ for (let i = 0; i < 10; i++) {
523
+ await appendCreatedEvent(group, `cancel-${i}`);
524
+ }
525
+
526
+ await rebuildProjection(qualifiedProjectionName, {
527
+ db: testDb.db,
528
+ registry,
529
+ });
530
+ const before = await getCount(group);
531
+ expect(before).toBe(10);
532
+
533
+ const controller = new AbortController();
534
+ controller.abort();
535
+
536
+ let thrown: unknown;
537
+ try {
538
+ await rebuildProjection(qualifiedProjectionName, {
539
+ db: testDb.db,
540
+ registry,
541
+ signal: controller.signal,
542
+ });
543
+ } catch (e) {
544
+ thrown = e;
545
+ }
546
+ expect((thrown as Error).name).toBe("AbortError");
547
+
548
+ const after = await getCount(group);
549
+ expect(after).toBe(before);
550
+ });
551
+ });