@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,204 @@
1
+ // Quarantine-Policy für fehlerhafte Upcaster.
2
+ //
3
+ // Die throw-Policy ist bereits in upcaster.integration.ts getestet. Hier:
4
+ // - quarantine schreibt eine dead-letter-Row
5
+ // - der Event wird aus der Result-Liste entfernt
6
+ // - die throw-Policy bleibt unverändert (Regression-Guard)
7
+ // - listDeadLetters filtert per eventType
8
+
9
+ import { eq, sql } from "drizzle-orm";
10
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
11
+ import { createTestDb, type TestDb } from "../../stack";
12
+ import type { StoredEvent } from "../event-store";
13
+ import { createEventsTable, eventsTable } from "../events-schema";
14
+ import { type EventUpcasters, makeUpcastCtx, upcastStoredEvents } from "../upcaster";
15
+ import {
16
+ createUpcasterDeadLetterTable,
17
+ listDeadLetters,
18
+ upcasterDeadLetterTable,
19
+ } from "../upcaster-dead-letter";
20
+
21
+ let testDb: TestDb;
22
+
23
+ const TENANT_ID = "00000000-0000-4000-8000-0000000000aa";
24
+
25
+ function makeEvent(overrides: Partial<StoredEvent> = {}): StoredEvent {
26
+ return {
27
+ id: "1",
28
+ aggregateId: "agg-1",
29
+ aggregateType: "probe",
30
+ tenantId: TENANT_ID as StoredEvent["tenantId"],
31
+ version: 1,
32
+ type: "probe.broken",
33
+ eventVersion: 1,
34
+ payload: { legacy: "value" },
35
+ metadata: {} as StoredEvent["metadata"],
36
+ createdAt: new Date(0) as unknown as StoredEvent["createdAt"],
37
+ createdBy: "system",
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ // Upcaster chain that deliberately throws at v1→v2. Used for the
43
+ // quarantine path; the throw case rides the same wiring but with the
44
+ // default policy.
45
+ const failingUpcasters: EventUpcasters = new Map([
46
+ [
47
+ "probe.broken",
48
+ {
49
+ currentVersion: 2,
50
+ chain: new Map([
51
+ [
52
+ 1,
53
+ async () => {
54
+ throw new Error("payload malformed: missing required field");
55
+ },
56
+ ],
57
+ ]),
58
+ },
59
+ ],
60
+ ]);
61
+
62
+ const passthroughUpcasters: EventUpcasters = new Map([
63
+ [
64
+ "probe.ok",
65
+ {
66
+ currentVersion: 2,
67
+ chain: new Map([
68
+ [
69
+ 1,
70
+ async (payload) => ({
71
+ ...(payload as Record<string, unknown>),
72
+ migrated: true,
73
+ }),
74
+ ],
75
+ ]),
76
+ },
77
+ ],
78
+ ]);
79
+
80
+ beforeAll(async () => {
81
+ testDb = await createTestDb();
82
+ await createEventsTable(testDb.db);
83
+ await createUpcasterDeadLetterTable(testDb.db);
84
+ });
85
+
86
+ afterAll(async () => {
87
+ await testDb.cleanup();
88
+ });
89
+
90
+ afterEach(async () => {
91
+ await testDb.db.delete(upcasterDeadLetterTable);
92
+ await testDb.db.delete(eventsTable);
93
+ });
94
+
95
+ describe("upcaster error-policy: throw (default)", () => {
96
+ test("failing transform propagates the thrown error", async () => {
97
+ const events = [makeEvent()];
98
+ await expect(
99
+ upcastStoredEvents(events, failingUpcasters, makeUpcastCtx(testDb.db, TENANT_ID)),
100
+ ).rejects.toThrow(/payload malformed/);
101
+ });
102
+
103
+ test("failing transform writes NO dead-letter row", async () => {
104
+ const events = [makeEvent()];
105
+ await upcastStoredEvents(events, failingUpcasters, makeUpcastCtx(testDb.db, TENANT_ID)).catch(
106
+ () => {},
107
+ );
108
+ const rows = await listDeadLetters(testDb.db);
109
+ expect(rows).toHaveLength(0);
110
+ });
111
+ });
112
+
113
+ describe("upcaster error-policy: quarantine", () => {
114
+ test("failing transform writes a dead-letter row and is removed from the result list", async () => {
115
+ const ok = makeEvent({
116
+ id: "10",
117
+ type: "probe.ok",
118
+ payload: { value: 42 },
119
+ eventVersion: 1,
120
+ });
121
+ const broken = makeEvent({ id: "11", type: "probe.broken" });
122
+
123
+ // Combined upcaster map — real callers have both registered together.
124
+ const combined: EventUpcasters = new Map([
125
+ ...failingUpcasters.entries(),
126
+ ...passthroughUpcasters.entries(),
127
+ ]);
128
+
129
+ const result = await upcastStoredEvents(
130
+ [ok, broken],
131
+ combined,
132
+ makeUpcastCtx(testDb.db, TENANT_ID),
133
+ { errorPolicy: "quarantine" },
134
+ );
135
+
136
+ // Only the ok event survives; broken landed in dead-letters.
137
+ expect(result).toHaveLength(1);
138
+ expect(result[0]?.id).toBe("10");
139
+ expect(result[0]?.eventVersion).toBe(2);
140
+ expect((result[0]?.payload as { migrated?: boolean }).migrated).toBe(true);
141
+
142
+ const dl = await listDeadLetters(testDb.db);
143
+ expect(dl).toHaveLength(1);
144
+ expect(dl[0]).toMatchObject({
145
+ eventId: "11",
146
+ aggregateId: "agg-1",
147
+ aggregateType: "probe",
148
+ eventType: "probe.broken",
149
+ fromVersion: 1,
150
+ targetVersion: 2,
151
+ });
152
+ expect(dl[0]?.errorMessage).toContain("payload malformed");
153
+ expect(dl[0]?.originalPayload).toEqual({ legacy: "value" });
154
+ });
155
+
156
+ test("listDeadLetters filters by eventType", async () => {
157
+ await upcastStoredEvents(
158
+ [
159
+ makeEvent({ id: "20", type: "probe.broken" }),
160
+ makeEvent({ id: "21", type: "probe.broken" }),
161
+ ],
162
+ failingUpcasters,
163
+ makeUpcastCtx(testDb.db, TENANT_ID),
164
+ { errorPolicy: "quarantine" },
165
+ );
166
+
167
+ // Drop one directly to add noise of a different type.
168
+ await testDb.db.insert(upcasterDeadLetterTable).values({
169
+ eventId: "99",
170
+ tenantId: TENANT_ID,
171
+ aggregateId: "other",
172
+ aggregateType: "other",
173
+ eventType: "other.broken",
174
+ fromVersion: 1,
175
+ targetVersion: 2,
176
+ errorMessage: "unrelated",
177
+ originalPayload: {},
178
+ });
179
+
180
+ const probeOnly = await listDeadLetters(testDb.db, { eventType: "probe.broken" });
181
+ expect(probeOnly).toHaveLength(2);
182
+ expect(probeOnly.every((r) => r.eventType === "probe.broken")).toBe(true);
183
+
184
+ const all = await listDeadLetters(testDb.db);
185
+ expect(all.length).toBeGreaterThanOrEqual(3);
186
+ });
187
+
188
+ test("same event quarantined twice inserts two rows (retry-across-deploys visibility)", async () => {
189
+ const broken = makeEvent({ id: "30", type: "probe.broken" });
190
+
191
+ await upcastStoredEvents([broken], failingUpcasters, makeUpcastCtx(testDb.db, TENANT_ID), {
192
+ errorPolicy: "quarantine",
193
+ });
194
+ await upcastStoredEvents([broken], failingUpcasters, makeUpcastCtx(testDb.db, TENANT_ID), {
195
+ errorPolicy: "quarantine",
196
+ });
197
+
198
+ const rows = await testDb.db
199
+ .select({ c: sql<number>`count(*)::int` })
200
+ .from(upcasterDeadLetterTable)
201
+ .where(eq(upcasterDeadLetterTable.eventId, "30"));
202
+ expect(rows[0]?.c).toBe(2);
203
+ });
204
+ });
@@ -0,0 +1,460 @@
1
+ // B1 — r.eventMigration + event_version routing (Marten upcaster).
2
+ //
3
+ // Covers the two load-bearing claims:
4
+ // 1. Stored v(N) payloads are transparently upgraded to v(current) when
5
+ // read through the upcaster. Projections and any future aggregate
6
+ // loader see the current shape regardless of how old the event is.
7
+ // 2. Boot-time validation refuses incomplete chains. Declaring version=3
8
+ // with only a 1→2 migration fails immediately, so a missing upcaster
9
+ // can never silently hand half-migrated data to consumers.
10
+
11
+ import { sql } from "drizzle-orm";
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
13
+ import { z } from "zod";
14
+ import { integer as pgInteger, table as pgTable, text as pgText } from "../../db/dialect";
15
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
16
+ import { buildDrizzleTable } from "../../db/table-builder";
17
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
18
+ import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
19
+ import type { StoredEvent } from "../../event-store";
20
+ import { rebuildProjection } from "../../pipeline";
21
+ import { createEntityTable, createTestDb, pushTables, type TestDb, TestUsers } from "../../stack";
22
+ import { append, createEventsTable } from "../index";
23
+ import { upcastStoredEvent } from "../upcaster";
24
+
25
+ // --- Fixture entity + projection table ---
26
+
27
+ const orderEntity = createEntity({
28
+ table: "read_upcast_orders",
29
+ fields: {
30
+ customer: createTextField({ required: true }),
31
+ },
32
+ });
33
+ const orderTable = buildDrizzleTable("upcast-order", orderEntity);
34
+
35
+ // Projection stores the UPCAST view: the v3 shape expects `totalCents` (int)
36
+ // even though the earliest writes might have stored `totalEuros` (string).
37
+ const orderSummaryTable = pgTable("read_upcast_order_summary", {
38
+ orderId: pgText("order_id").primaryKey(),
39
+ tenantId: pgText("tenant_id").notNull(),
40
+ totalCents: pgInteger("total_cents").notNull(),
41
+ currency: pgText("currency").notNull(),
42
+ });
43
+
44
+ // --- Feature: version 3 event with v1→v2 and v2→v3 migrations registered ---
45
+
46
+ const orderFeature = defineFeature("upcastshop", (r) => {
47
+ r.entity("upcast-order", orderEntity);
48
+
49
+ // v3 shape: { totalCents: int, currency: string }
50
+ const orderPriced = r.defineEvent(
51
+ "priced",
52
+ z.object({ totalCents: z.number().int(), currency: z.string() }),
53
+ { version: 3 },
54
+ );
55
+
56
+ // v1 → v2: renamed totalEuros → total (kept as string for this step)
57
+ r.eventMigration("priced", 1, 2, (payload) => {
58
+ const p = payload as { totalEuros: string };
59
+ return { total: p.totalEuros, currency: "EUR" };
60
+ });
61
+ // v2 → v3: parse "total" string into integer cents
62
+ r.eventMigration("priced", 2, 3, (payload) => {
63
+ const p = payload as { total: string; currency: string };
64
+ const euros = Number.parseFloat(p.total);
65
+ return { totalCents: Math.round(euros * 100), currency: p.currency };
66
+ });
67
+
68
+ r.projection({
69
+ name: "order-summary",
70
+ source: "upcast-order",
71
+ table: orderSummaryTable,
72
+ apply: {
73
+ [orderPriced.name]: async (event, tx) => {
74
+ const p = event.payload as { totalCents: number; currency: string };
75
+ await tx
76
+ .insert(orderSummaryTable)
77
+ .values({
78
+ orderId: event.aggregateId,
79
+ tenantId: event.tenantId,
80
+ totalCents: p.totalCents,
81
+ currency: p.currency,
82
+ })
83
+ .onConflictDoUpdate({
84
+ target: orderSummaryTable.orderId,
85
+ set: { totalCents: p.totalCents, currency: p.currency },
86
+ });
87
+ },
88
+ },
89
+ });
90
+ });
91
+
92
+ // --- Test scaffolding ---
93
+
94
+ let testDb: TestDb;
95
+ let tdb: TenantDb;
96
+ const admin = TestUsers.admin;
97
+ const registry = createRegistry([orderFeature]);
98
+ const qualifiedProjectionName = "upcastshop:projection:order-summary";
99
+ const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
100
+ entityName: "upcast-order",
101
+ });
102
+
103
+ beforeAll(async () => {
104
+ testDb = await createTestDb();
105
+ await createEntityTable(testDb.db, orderEntity, "upcast-order");
106
+ await createEventsTable(testDb.db);
107
+ const { createProjectionStateTable } = await import("../../pipeline");
108
+ await createProjectionStateTable(testDb.db);
109
+ await pushTables(testDb.db, { upcastOrderSummary: orderSummaryTable });
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_upcast_orders, read_upcast_order_summary, kumiko_projections RESTART IDENTITY CASCADE`,
120
+ );
121
+ });
122
+
123
+ // --- Tests ---
124
+
125
+ describe("upcaster: in-memory transform chain", () => {
126
+ test("v1 payload walks v1→v2→v3 before reaching a consumer", async () => {
127
+ const upcasters = registry.getEventUpcasters();
128
+ const raw: StoredEvent = {
129
+ id: "1",
130
+ aggregateId: "00000000-0000-4000-8000-000000000001",
131
+ aggregateType: "upcast-order",
132
+ tenantId: admin.tenantId,
133
+ version: 1,
134
+ type: "upcastshop:event:priced",
135
+ eventVersion: 1,
136
+ payload: { totalEuros: "19.99" },
137
+ metadata: { userId: admin.id },
138
+ createdAt: Temporal.Now.instant(),
139
+ createdBy: admin.id,
140
+ };
141
+
142
+ const upcast = await upcastStoredEvent(raw, upcasters, {
143
+ db: testDb.db,
144
+ tenantId: admin.tenantId,
145
+ });
146
+
147
+ expect(upcast.eventVersion).toBe(3);
148
+ expect(upcast.payload).toEqual({ totalCents: 1999, currency: "EUR" });
149
+ });
150
+
151
+ test("v2 payload only needs v2→v3 step — chain short-circuits per stored version", async () => {
152
+ const upcasters = registry.getEventUpcasters();
153
+ const raw: StoredEvent = {
154
+ id: "2",
155
+ aggregateId: "00000000-0000-4000-8000-000000000002",
156
+ aggregateType: "upcast-order",
157
+ tenantId: admin.tenantId,
158
+ version: 1,
159
+ type: "upcastshop:event:priced",
160
+ eventVersion: 2,
161
+ payload: { total: "5.00", currency: "USD" },
162
+ metadata: { userId: admin.id },
163
+ createdAt: Temporal.Now.instant(),
164
+ createdBy: admin.id,
165
+ };
166
+
167
+ const upcast = await upcastStoredEvent(raw, upcasters, {
168
+ db: testDb.db,
169
+ tenantId: admin.tenantId,
170
+ });
171
+
172
+ expect(upcast.eventVersion).toBe(3);
173
+ expect(upcast.payload).toEqual({ totalCents: 500, currency: "USD" });
174
+ });
175
+
176
+ test("already-current events pass through unchanged — fast path", async () => {
177
+ const upcasters = registry.getEventUpcasters();
178
+ const raw: StoredEvent = {
179
+ id: "3",
180
+ aggregateId: "00000000-0000-4000-8000-000000000003",
181
+ aggregateType: "upcast-order",
182
+ tenantId: admin.tenantId,
183
+ version: 1,
184
+ type: "upcastshop:event:priced",
185
+ eventVersion: 3,
186
+ payload: { totalCents: 7777, currency: "CHF" },
187
+ metadata: { userId: admin.id },
188
+ createdAt: Temporal.Now.instant(),
189
+ createdBy: admin.id,
190
+ };
191
+
192
+ const upcast = await upcastStoredEvent(raw, upcasters, {
193
+ db: testDb.db,
194
+ tenantId: admin.tenantId,
195
+ });
196
+
197
+ expect(upcast).toBe(raw); // identity — no rebuild allocated
198
+ });
199
+
200
+ test("unknown event types pass through unchanged", async () => {
201
+ const upcasters = registry.getEventUpcasters();
202
+ const raw: StoredEvent = {
203
+ id: "4",
204
+ aggregateId: "00000000-0000-4000-8000-000000000004",
205
+ aggregateType: "upcast-order",
206
+ tenantId: admin.tenantId,
207
+ version: 1,
208
+ type: "some:event:never-declared",
209
+ eventVersion: 1,
210
+ payload: { whatever: true },
211
+ metadata: { userId: admin.id },
212
+ createdAt: Temporal.Now.instant(),
213
+ createdBy: admin.id,
214
+ };
215
+
216
+ const upcast = await upcastStoredEvent(raw, upcasters, {
217
+ db: testDb.db,
218
+ tenantId: admin.tenantId,
219
+ });
220
+
221
+ expect(upcast).toBe(raw);
222
+ });
223
+ });
224
+
225
+ describe("upcaster: projection rebuild walks the chain on replay", () => {
226
+ test("rebuild produces current-shape projection state from mixed v1/v2/v3 events", async () => {
227
+ const ord1 = "00000000-0000-4000-8000-00000000aaaa";
228
+ const ord2 = "00000000-0000-4000-8000-00000000bbbb";
229
+ const ord3 = "00000000-0000-4000-8000-00000000cccc";
230
+
231
+ // Seed the entity rows so the FK-less projection stays readable even if
232
+ // future tests add FKs; insert directly, the executor is overkill here.
233
+ await orderExecutor.create({ customer: "c1" }, admin, tdb);
234
+ await orderExecutor.create({ customer: "c2" }, admin, tdb);
235
+ await orderExecutor.create({ customer: "c3" }, admin, tdb);
236
+
237
+ // Append three "priced" events at three different schema versions. The
238
+ // projection apply is only written against v3 — without upcasting, the
239
+ // v1 + v2 events would crash or produce garbage.
240
+ await append(testDb.db, {
241
+ aggregateId: ord1,
242
+ aggregateType: "upcast-order",
243
+ tenantId: admin.tenantId,
244
+ expectedVersion: 0,
245
+ type: "upcastshop:event:priced",
246
+ eventVersion: 1,
247
+ payload: { totalEuros: "10.00" },
248
+ metadata: { userId: admin.id },
249
+ });
250
+ await append(testDb.db, {
251
+ aggregateId: ord2,
252
+ aggregateType: "upcast-order",
253
+ tenantId: admin.tenantId,
254
+ expectedVersion: 0,
255
+ type: "upcastshop:event:priced",
256
+ eventVersion: 2,
257
+ payload: { total: "25.50", currency: "USD" },
258
+ metadata: { userId: admin.id },
259
+ });
260
+ await append(testDb.db, {
261
+ aggregateId: ord3,
262
+ aggregateType: "upcast-order",
263
+ tenantId: admin.tenantId,
264
+ expectedVersion: 0,
265
+ type: "upcastshop:event:priced",
266
+ eventVersion: 3,
267
+ payload: { totalCents: 9900, currency: "CHF" },
268
+ metadata: { userId: admin.id },
269
+ });
270
+
271
+ const result = await rebuildProjection(qualifiedProjectionName, {
272
+ db: testDb.db,
273
+ registry,
274
+ });
275
+ expect(result.eventsProcessed).toBe(3);
276
+
277
+ const rows = await testDb.db
278
+ .select()
279
+ .from(orderSummaryTable)
280
+ .orderBy(orderSummaryTable.orderId);
281
+
282
+ // Ordered by orderId → ord1 (10€ = 1000¢), ord2 ($25.50 = 2550¢), ord3 (9900¢)
283
+ expect(rows).toHaveLength(3);
284
+ const byId = new Map(rows.map((r) => [r.orderId, r]));
285
+ expect(byId.get(ord1)).toMatchObject({ totalCents: 1000, currency: "EUR" });
286
+ expect(byId.get(ord2)).toMatchObject({ totalCents: 2550, currency: "USD" });
287
+ expect(byId.get(ord3)).toMatchObject({ totalCents: 9900, currency: "CHF" });
288
+ });
289
+ });
290
+
291
+ describe("upcaster: async (Marten AsyncOnlyEventUpcaster — DB-Lookups)", () => {
292
+ test("async transform with ctx.db lookup walks chain via projection rebuild", async () => {
293
+ // Reference data table that the async upcaster looks up — simulates the
294
+ // typical case "v2 needs to enrich payload with current snapshot of a
295
+ // reference dataset". We seed a known row and assert the rebuilt
296
+ // projection has the enriched value.
297
+ const customerSegments = pgTable("upcast_async_customer_segments", {
298
+ customerId: pgText("customer_id").primaryKey(),
299
+ segment: pgText("segment").notNull(),
300
+ });
301
+ await pushTables(testDb.db, { upcastAsyncCustomerSegments: customerSegments });
302
+ await testDb.db
303
+ .insert(customerSegments)
304
+ .values({ customerId: "c-async-1", segment: "PREMIUM" });
305
+
306
+ const asyncSummary = pgTable("upcast_async_summary", {
307
+ orderId: pgText("order_id").primaryKey(),
308
+ customerId: pgText("customer_id").notNull(),
309
+ segment: pgText("segment").notNull(),
310
+ });
311
+ await pushTables(testDb.db, { upcastAsyncSummary: asyncSummary });
312
+
313
+ // Feature with async upcaster v1 → v2: enrich payload with segment from DB.
314
+ const asyncFeature = defineFeature("upcastasync", (r) => {
315
+ r.entity("upcast-async-order", orderEntity);
316
+ const placed = r.defineEvent(
317
+ "placed",
318
+ z.object({ customerId: z.string(), segment: z.string() }),
319
+ { version: 2 },
320
+ );
321
+
322
+ r.eventMigration("placed", 1, 2, async (payload, ctx) => {
323
+ const p = payload as { customerId: string };
324
+ const [row] = await ctx.db
325
+ .select()
326
+ .from(customerSegments)
327
+ .where(sql`${customerSegments.customerId} = ${p.customerId}`);
328
+ return { customerId: p.customerId, segment: row?.segment ?? "UNKNOWN" };
329
+ });
330
+
331
+ r.projection({
332
+ name: "async-summary",
333
+ source: "upcast-async-order",
334
+ table: asyncSummary,
335
+ apply: {
336
+ [placed.name]: async (event, tx) => {
337
+ const p = event.payload as { customerId: string; segment: string };
338
+ await tx.insert(asyncSummary).values({
339
+ orderId: event.aggregateId,
340
+ customerId: p.customerId,
341
+ segment: p.segment,
342
+ });
343
+ },
344
+ },
345
+ });
346
+ });
347
+
348
+ const asyncRegistry = createRegistry([asyncFeature]);
349
+
350
+ // Stream: one v1 event without segment + one v2 event with segment.
351
+ // The upcaster must lift v1 to v2 via DB lookup on customer_segments.
352
+ const orderId1 = "00000000-0000-4000-8000-00000000ddd1";
353
+ const orderId2 = "00000000-0000-4000-8000-00000000ddd2";
354
+ await append(testDb.db, {
355
+ aggregateId: orderId1,
356
+ aggregateType: "upcast-async-order",
357
+ tenantId: admin.tenantId,
358
+ expectedVersion: 0,
359
+ type: "upcastasync:event:placed",
360
+ eventVersion: 1,
361
+ payload: { customerId: "c-async-1" },
362
+ metadata: { userId: admin.id },
363
+ });
364
+ await append(testDb.db, {
365
+ aggregateId: orderId2,
366
+ aggregateType: "upcast-async-order",
367
+ tenantId: admin.tenantId,
368
+ expectedVersion: 0,
369
+ type: "upcastasync:event:placed",
370
+ eventVersion: 2,
371
+ payload: { customerId: "c-async-2", segment: "STANDARD" },
372
+ metadata: { userId: admin.id },
373
+ });
374
+
375
+ const result = await rebuildProjection("upcastasync:projection:async-summary", {
376
+ db: testDb.db,
377
+ registry: asyncRegistry,
378
+ });
379
+ expect(result.eventsProcessed).toBe(2);
380
+
381
+ const rows = await testDb.db.select().from(asyncSummary).orderBy(asyncSummary.orderId);
382
+ expect(rows).toHaveLength(2);
383
+ const byId = new Map(rows.map((r) => [r.orderId, r]));
384
+ // v1 → v2 via async DB lookup → segment from customer_segments.
385
+ expect(byId.get(orderId1)?.segment).toBe("PREMIUM");
386
+ // v2 already current → passes through unchanged.
387
+ expect(byId.get(orderId2)?.segment).toBe("STANDARD");
388
+ });
389
+ });
390
+
391
+ describe("upcaster: boot-time validation", () => {
392
+ test("defineEvent with version=N and only partial migrations fails at registry build", () => {
393
+ const incomplete = defineFeature("holes", (r) => {
394
+ r.entity("hole-order", orderEntity);
395
+ r.defineEvent("bad", z.object({ v3: z.string() }), { version: 3 });
396
+ // Only 1→2 registered — the 2→3 gap must be rejected.
397
+ r.eventMigration("bad", 1, 2, (p) => p);
398
+ });
399
+ expect(() => createRegistry([incomplete])).toThrow(/v2.*v3|covers the step v2/);
400
+ });
401
+
402
+ test("migration declared but no defineEvent → rejected", () => {
403
+ const orphan = defineFeature("orphans", (r) => {
404
+ r.entity("orph-order", orderEntity);
405
+ r.eventMigration("ghost", 1, 2, (p) => p);
406
+ });
407
+ expect(() => createRegistry([orphan])).toThrow(/no r\.defineEvent/i);
408
+ });
409
+
410
+ test("migration toVersion > defineEvent version → rejected", () => {
411
+ const future = defineFeature("future", (r) => {
412
+ r.entity("future-order", orderEntity);
413
+ r.defineEvent("early", z.object({ x: z.number() }), { version: 1 });
414
+ r.eventMigration("early", 1, 2, (p) => p);
415
+ });
416
+ expect(() => createRegistry([future])).toThrow(/declares only version 1/);
417
+ });
418
+
419
+ test("non-contiguous (1→2 and 3→4 without 2→3) → rejected", () => {
420
+ const gaps = defineFeature("gaps", (r) => {
421
+ r.entity("gap-order", orderEntity);
422
+ r.defineEvent("jumpy", z.object({ v: z.number() }), { version: 4 });
423
+ r.eventMigration("jumpy", 1, 2, (p) => p);
424
+ r.eventMigration("jumpy", 3, 4, (p) => p);
425
+ });
426
+ expect(() => createRegistry([gaps])).toThrow(/v2.*v3|covers the step v2/);
427
+ });
428
+ });
429
+
430
+ describe("upcaster: registrar input validation", () => {
431
+ test("r.eventMigration rejects multi-step jumps", () => {
432
+ expect(() =>
433
+ defineFeature("bigstep", (r) => {
434
+ r.entity("bigstep-order", orderEntity);
435
+ r.defineEvent("biz", z.object({ x: z.number() }), { version: 3 });
436
+ r.eventMigration("biz", 1, 3, (p) => p);
437
+ }),
438
+ ).toThrow(/single-step/);
439
+ });
440
+
441
+ test("r.eventMigration rejects duplicate step", () => {
442
+ expect(() =>
443
+ defineFeature("dupestep", (r) => {
444
+ r.entity("dup-order", orderEntity);
445
+ r.defineEvent("dup", z.object({ x: z.number() }), { version: 2 });
446
+ r.eventMigration("dup", 1, 2, (p) => p);
447
+ r.eventMigration("dup", 1, 2, (p) => p);
448
+ }),
449
+ ).toThrow(/already registered/);
450
+ });
451
+
452
+ test("r.defineEvent rejects non-positive version", () => {
453
+ expect(() =>
454
+ defineFeature("badver", (r) => {
455
+ r.entity("badver-order", orderEntity);
456
+ r.defineEvent("neg", z.object({ x: z.number() }), { version: 0 });
457
+ }),
458
+ ).toThrow(/positive integer/);
459
+ });
460
+ });