@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,115 @@
1
+ import type { DbRunner } from "../db/connection";
2
+ import type {
3
+ AppendEventArgs,
4
+ AppendEventFn,
5
+ AppendEventUnsafeFn,
6
+ KumikoEventTypeMap,
7
+ Registry,
8
+ TenantId,
9
+ } from "../engine/types";
10
+ import { loadAggregate, loadAggregateAsOf, type StoredEvent } from "../event-store/event-store";
11
+ import { upcastStoredEvents } from "../event-store/upcaster";
12
+ import type { FileContext } from "../files/file-handle";
13
+ import { appendDomainEventCore } from "./append-event-core";
14
+
15
+ // Minimal, read+write surface handed to a MultiStreamProjection's apply()
16
+ // when it needs to produce follow-up events (saga / process-manager
17
+ // pattern). Keeps the MSP feature-decoupled: applies don't reach into
18
+ // handler-bridge (no query/write/writeAs), they just read the aggregate
19
+ // stream and append new events — Marten's session scope for projections.
20
+ //
21
+ // TMap propagates the strict event-type-map (see HandlerContext). Default
22
+ // matches the global KumikoEventTypeMap; runtime-pluggable callers route
23
+ // through appendEventUnsafe.
24
+ export type MultiStreamApplyContext<TMap extends object = KumikoEventTypeMap> = {
25
+ // Append a domain event onto an aggregate stream in the CURRENT tx.
26
+ // Schema-validated, archive-guarded, stream-version derived. Metadata
27
+ // inherits from the triggering event (correlationId) + requestContext
28
+ // (causationId is already set to the triggering event.id by the
29
+ // dispatcher wrap). Strict against KumikoEventTypeMap — same contract
30
+ // as HandlerContext.appendEvent (compile-time-validated payload).
31
+ readonly appendEvent: AppendEventFn<TMap>;
32
+ // Escape hatch for runtime-pluggable events without compile-time
33
+ // augmentation. Same runtime semantics; type-surface is `payload: unknown`.
34
+ readonly appendEventUnsafe: AppendEventUnsafeFn;
35
+ // Read an aggregate stream — useful when a saga needs to inspect the
36
+ // current state of a different aggregate before deciding what to emit.
37
+ readonly loadAggregate: (
38
+ aggregateId: string,
39
+ options?: { readonly asOf?: Temporal.Instant },
40
+ ) => Promise<readonly StoredEvent[]>;
41
+ // Binary storage handle factory, mirrors AppContext.files. Present when
42
+ // the app booted with `files.storageProvider`; undefined otherwise.
43
+ // Post-processing MSPs (resize, EXIF-strip, virus-scan) read bytes via
44
+ // `ctx.files.ref(payload.storageKey).read()` and write derivates via
45
+ // `.derive("thumb").write(...)` — binaries never ride through events.
46
+ readonly files?: FileContext;
47
+ };
48
+
49
+ export type MultiStreamApplyContextDeps = {
50
+ readonly registry: Registry;
51
+ // TX-scoped DbRunner — the same `tx` the applyFn receives as the 2nd
52
+ // arg. ctx.appendEvent + inline-projections run inside this tx so a
53
+ // throw rolls the whole hop back (consumer retries the triggering
54
+ // event on the next pass).
55
+ readonly db: DbRunner;
56
+ // tenantId + userId of the TRIGGERING event. appendEvent stamps these
57
+ // onto the new event so the causal chain stays tenant-consistent and
58
+ // the downstream audit-trail can reconstruct the acting principal.
59
+ readonly tenantId: TenantId;
60
+ readonly userId: string;
61
+ // MSP's owning feature (prefix of its qualified name). Enforced at
62
+ // emit-site: the MSP cannot ctx.appendEvent a type owned by another
63
+ // feature. Cross-feature reactions are fine inbound (this MSP is
64
+ // subscribed to events from any feature), but outbound appends must
65
+ // stay within the MSP's own feature.
66
+ readonly callerFeature?: string;
67
+ // Same FileContext the outer AppContext carries, passed through so
68
+ // MSP applies can reach binaries without another wiring indirection.
69
+ readonly files?: FileContext;
70
+ };
71
+
72
+ export function createMultiStreamApplyContext(
73
+ deps: MultiStreamApplyContextDeps,
74
+ ): MultiStreamApplyContext {
75
+ return {
76
+ ...(deps.files ? { files: deps.files } : {}),
77
+ // @cast-boundary engine-bridge — concrete impl conforms to AppendEventFn overload
78
+ appendEvent: (async (args: AppendEventArgs) => {
79
+ await appendDomainEventCore(
80
+ {
81
+ registry: deps.registry,
82
+ db: deps.db,
83
+ tenantId: deps.tenantId,
84
+ userId: deps.userId,
85
+ callSiteLabel: "MSP-apply ctx.appendEvent",
86
+ ...(deps.callerFeature && { callerFeature: deps.callerFeature }),
87
+ },
88
+ args,
89
+ );
90
+ }) as AppendEventFn,
91
+ appendEventUnsafe: async (args) => {
92
+ await appendDomainEventCore(
93
+ {
94
+ registry: deps.registry,
95
+ db: deps.db,
96
+ tenantId: deps.tenantId,
97
+ userId: deps.userId,
98
+ callSiteLabel: "MSP-apply ctx.appendEventUnsafe",
99
+ ...(deps.callerFeature && { callerFeature: deps.callerFeature }),
100
+ },
101
+ args,
102
+ );
103
+ },
104
+
105
+ loadAggregate: async (aggregateId, options) => {
106
+ const events = options?.asOf
107
+ ? await loadAggregateAsOf(deps.db, aggregateId, deps.tenantId, options.asOf)
108
+ : await loadAggregate(deps.db, aggregateId, deps.tenantId);
109
+ return upcastStoredEvents(events, deps.registry.getEventUpcasters(), {
110
+ db: deps.db,
111
+ tenantId: deps.tenantId,
112
+ });
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,334 @@
1
+ import { asc, eq, getTableName, inArray, sql } from "drizzle-orm";
2
+ import type { DbConnection } from "../db/connection";
3
+ import type { Registry, TenantId } from "../engine/types";
4
+ import {
5
+ eventsTable,
6
+ getEventsHighWaterMark,
7
+ type StoredEvent,
8
+ upcastStoredEvent,
9
+ } from "../event-store";
10
+ import { emitProjectionRebuild } from "../observability/standard-metrics";
11
+ import type { Meter } from "../observability/types/metric";
12
+ import { projectionStateTable } from "./projection-state";
13
+
14
+ // Rebuild a projection from the event log.
15
+ //
16
+ // Mechanics:
17
+ // 1. Lock the projection's state row FOR UPDATE. Concurrent rebuild
18
+ // attempts of the same projection block here instead of racing.
19
+ // 2. Mark status = "rebuilding".
20
+ // 3. TRUNCATE the projection's backing table.
21
+ // 4. Stream events in chronological order, for every apply-key match
22
+ // invoke apply(event, tx). Event-by-event, so two projections of the
23
+ // same source stay semantically identical to the live pipeline.
24
+ // 5. Store the last processed event-id + mark status = "idle".
25
+ //
26
+ // All of that runs in ONE transaction. If apply throws partway through,
27
+ // Postgres rolls back everything — the old projection is still there,
28
+ // status goes back to "idle" via the outer catch, and lastError records
29
+ // what went wrong. A partial/empty projection is never observable.
30
+ //
31
+ // This is an ops-time operation. While a rebuild is in progress, live
32
+ // writes that touch the same projection will also try to insert into the
33
+ // TRUNCATE'd table, triggering either a serialization conflict or (for a
34
+ // new row after TRUNCATE) a noisy conflict. Intended behaviour: rebuild
35
+ // on a quiet entity, or during a deliberate write-pause.
36
+ //
37
+ // Scale limit: single-TX TRUNCATE + replay works as long as your
38
+ // maintenance window absorbs the replay. Effective ceiling depends on
39
+ // payload size, apply() cost, and DB load — measure before trusting it.
40
+ // Beyond that window, plan for a shadow-swap variant. For v1 that's
41
+ // documented as a known boundary in docs/projections.md.
42
+
43
+ export type RebuildResult = {
44
+ readonly projection: string;
45
+ readonly eventsProcessed: number;
46
+ readonly lastProcessedEventId: bigint;
47
+ readonly durationMs: number;
48
+ };
49
+
50
+ type RebuildDeps = {
51
+ readonly db: DbConnection;
52
+ readonly registry: Registry;
53
+ // Optional framework meter. When provided, the runner emits the two
54
+ // projection-rebuild metrics (duration histogram + events counter) on both
55
+ // success and failure paths — the Prometheus-facing surface. CLI callers
56
+ // can leave it undefined and rely on stdout feedback.
57
+ readonly meter?: Meter;
58
+ // Lightweight observation callback for tests that want to assert the
59
+ // RebuildResult without spinning up a full meter. Independent of `meter`.
60
+ readonly onMetrics?: (result: RebuildResult) => void;
61
+ // Cancellation. Checked before each event-apply. The transaction is
62
+ // rolled back on abort — a partial rebuild is never observable. Useful
63
+ // when an HTTP-triggered rebuild needs to honour client disconnect, or
64
+ // when a CLI/Job wraps the rebuild in its own AbortController for ops
65
+ // timeout enforcement.
66
+ readonly signal?: AbortSignal;
67
+ };
68
+
69
+ export async function rebuildProjection(
70
+ projectionName: string,
71
+ deps: RebuildDeps,
72
+ ): Promise<RebuildResult> {
73
+ const { db, registry } = deps;
74
+ const projection = registry.getAllProjections().get(projectionName);
75
+ if (!projection) {
76
+ throw new Error(
77
+ `Projection "${projectionName}" is not registered. Known: ${
78
+ [...registry.getAllProjections().keys()].join(", ") || "(none)"
79
+ }`,
80
+ );
81
+ }
82
+
83
+ const sources = Array.isArray(projection.source) ? projection.source : [projection.source];
84
+ const startedAt = Date.now();
85
+ let eventsProcessed = 0;
86
+ let lastProcessedEventId = 0n;
87
+
88
+ try {
89
+ await db.transaction(async (tx) => {
90
+ // Lock the state row. Use upsert so a never-rebuilt projection also
91
+ // gets a row. FOR UPDATE would need the row to exist — upsert-first
92
+ // keeps it idempotent.
93
+ await tx
94
+ .insert(projectionStateTable)
95
+ .values({ name: projectionName, status: "rebuilding" })
96
+ .onConflictDoUpdate({
97
+ target: projectionStateTable.name,
98
+ set: {
99
+ status: "rebuilding",
100
+ lastError: null,
101
+ updatedAt: sql`now()`,
102
+ },
103
+ });
104
+
105
+ // Wipe the projection table. drizzle-orm's public API doesn't expose
106
+ // TRUNCATE, so we issue raw SQL — but `getTableName()` is the public
107
+ // accessor for the table's registered name, avoiding Symbol.for()
108
+ // internal lookups. The identifier is still quoted defensively.
109
+ const tableName = getTableName(projection.table);
110
+ await tx.execute(sql.raw(`TRUNCATE TABLE ${quoteIdent(tableName)}`));
111
+
112
+ // Stream events in chronological order for every source. The event
113
+ // type filter (inArray(type, validTypes)) prunes events the projection
114
+ // doesn't care about early — important when a single source has more
115
+ // event types than the projection subscribes to.
116
+ const subscribed = Object.keys(projection.apply);
117
+ if (subscribed.length === 0) {
118
+ // nothing to replay, just mark idle — projection exists but doesn't
119
+ // subscribe to any event types on its sources yet.
120
+ } else {
121
+ const events = (await tx
122
+ .select()
123
+ .from(eventsTable)
124
+ .where(
125
+ sql`${inArray(eventsTable.aggregateType, sources)} AND ${inArray(
126
+ eventsTable.type,
127
+ subscribed,
128
+ )}`,
129
+ )
130
+ .orderBy(asc(eventsTable.id))) as ReadonlyArray<typeof eventsTable.$inferSelect>;
131
+
132
+ // Upcasters run at read time: older stored payloads get walked
133
+ // through the registered r.eventMigration chain until their shape
134
+ // matches the current event version. An apply() written against the
135
+ // v3 shape stays oblivious to v1 payloads still on disk.
136
+ const upcasters = registry.getEventUpcasters();
137
+ for (const row of events) {
138
+ deps.signal?.throwIfAborted();
139
+ const raw: StoredEvent = {
140
+ id: String(row.id),
141
+ aggregateId: row.aggregateId,
142
+ aggregateType: row.aggregateType,
143
+ tenantId: row.tenantId,
144
+ version: row.version,
145
+ type: row.type,
146
+ eventVersion: row.eventVersion,
147
+ payload: row.payload,
148
+ metadata: row.metadata,
149
+ createdAt: row.createdAt,
150
+ createdBy: row.createdBy,
151
+ };
152
+ const storedEvent = await upcastStoredEvent(raw, upcasters, {
153
+ db: tx,
154
+ tenantId: row.tenantId as TenantId,
155
+ });
156
+ const applyFn = projection.apply[row.type];
157
+ // skip: apply-key validation ensures every subscribed type has a
158
+ // handler; defensive check against runtime-mutated registry
159
+ if (!applyFn) continue;
160
+ await applyFn(storedEvent, tx);
161
+ eventsProcessed++;
162
+ lastProcessedEventId = row.id;
163
+ }
164
+ }
165
+
166
+ // Finalize state row.
167
+ await tx
168
+ .update(projectionStateTable)
169
+ .set({
170
+ lastProcessedEventId,
171
+ status: "idle",
172
+ lastRebuildAt: sql`now()`,
173
+ lastError: null,
174
+ updatedAt: sql`now()`,
175
+ })
176
+ .where(eq(projectionStateTable.name, projectionName));
177
+ });
178
+ } catch (e) {
179
+ // Outer catch: TX has been rolled back by Postgres already. Record the
180
+ // failure in a SEPARATE write so ops can see what happened — the
181
+ // rolled-back status change is gone, so we write failed+error now.
182
+ const message = e instanceof Error ? e.message : String(e);
183
+ await db
184
+ .insert(projectionStateTable)
185
+ .values({ name: projectionName, status: "failed", lastError: message })
186
+ .onConflictDoUpdate({
187
+ target: projectionStateTable.name,
188
+ set: { status: "failed", lastError: message, updatedAt: sql`now()` },
189
+ });
190
+ // Failure metric: duration until throw, 0 events "delivered" (the replayed
191
+ // rows were rolled back — counting them would overstate live delivery).
192
+ // success=false label distinguishes these in Prom dashboards.
193
+ if (deps.meter) {
194
+ emitProjectionRebuild(
195
+ deps.meter,
196
+ { projection: projectionName, success: false },
197
+ (Date.now() - startedAt) / 1000,
198
+ 0,
199
+ );
200
+ }
201
+ throw e;
202
+ }
203
+
204
+ const result: RebuildResult = {
205
+ projection: projectionName,
206
+ eventsProcessed,
207
+ lastProcessedEventId,
208
+ durationMs: Date.now() - startedAt,
209
+ };
210
+ if (deps.meter) {
211
+ emitProjectionRebuild(
212
+ deps.meter,
213
+ { projection: projectionName, success: true },
214
+ result.durationMs / 1000,
215
+ eventsProcessed,
216
+ );
217
+ }
218
+ deps.onMetrics?.(result);
219
+ return result;
220
+ }
221
+
222
+ // Identifier quoting for raw TRUNCATE. Drizzle doesn't expose a safe helper
223
+ // for table-name interpolation in raw SQL; double-quote + escape double-quote
224
+ // matches Postgres identifier rules.
225
+ function quoteIdent(name: string): string {
226
+ return `"${name.replace(/"/g, '""')}"`;
227
+ }
228
+
229
+ // Read-only status for one projection. Returns null if the projection was
230
+ // registered but never rebuilt (no row yet).
231
+ export async function getProjectionState(
232
+ db: DbConnection,
233
+ projectionName: string,
234
+ ): Promise<{
235
+ readonly name: string;
236
+ readonly status: string;
237
+ readonly lastProcessedEventId: bigint;
238
+ readonly lastRebuildAt: Temporal.Instant | null;
239
+ readonly lastError: string | null;
240
+ readonly updatedAt: Temporal.Instant;
241
+ } | null> {
242
+ const [row] = await db
243
+ .select()
244
+ .from(projectionStateTable)
245
+ .where(eq(projectionStateTable.name, projectionName));
246
+ if (!row) return null;
247
+ return {
248
+ name: row.name,
249
+ status: row.status,
250
+ lastProcessedEventId: row.lastProcessedEventId,
251
+ lastRebuildAt: row.lastRebuildAt,
252
+ lastError: row.lastError,
253
+ updatedAt: row.updatedAt,
254
+ };
255
+ }
256
+
257
+ // List every registered projection with its current state (if any).
258
+ // The registry is the source-of-truth for which projections exist; the
259
+ // state table holds per-projection rebuild info and may be sparse.
260
+ //
261
+ // Implicit-Projections (auto-registered pro r.entity, eine pro entity)
262
+ // werden default ausgefiltert — sie sind als rebuild-Ziele weiter mit
263
+ // `<feature>:projection:<entity>-entity` adressierbar, aber in `kumiko
264
+ // project list` würden sie das Bild dominieren ohne Mehrwert. Mit
265
+ // `{ includeImplicit: true }` opt-in einschalten.
266
+ export async function listProjectionsWithState(
267
+ db: DbConnection,
268
+ registry: Registry,
269
+ options: { readonly includeImplicit?: boolean } = {},
270
+ ): Promise<
271
+ ReadonlyArray<{
272
+ readonly name: string;
273
+ readonly sources: readonly string[];
274
+ readonly status: string;
275
+ readonly lastProcessedEventId: bigint;
276
+ readonly lastRebuildAt: Temporal.Instant | null;
277
+ readonly lastError: string | null;
278
+ }>
279
+ > {
280
+ const projections = registry.getAllProjections();
281
+ const stateRows = await db.select().from(projectionStateTable);
282
+ const stateByName = new Map(stateRows.map((r) => [r.name, r]));
283
+
284
+ return [...projections.values()]
285
+ .filter((proj) => options.includeImplicit === true || !proj.isImplicit)
286
+ .map((proj) => {
287
+ const state = stateByName.get(proj.name);
288
+ const sources = Array.isArray(proj.source) ? proj.source : [proj.source];
289
+ return {
290
+ name: proj.name,
291
+ sources,
292
+ status: state?.status ?? "never-rebuilt",
293
+ lastProcessedEventId: state?.lastProcessedEventId ?? 0n,
294
+ lastRebuildAt: state?.lastRebuildAt ?? null,
295
+ lastError: state?.lastError ?? null,
296
+ };
297
+ });
298
+ }
299
+
300
+ export type ProjectionProgress = {
301
+ readonly name: string;
302
+ readonly sources: readonly string[];
303
+ readonly status: string;
304
+ readonly lastProcessedEventId: bigint;
305
+ readonly lastRebuildAt: Temporal.Instant | null;
306
+ readonly lastError: string | null;
307
+ // Global MAX(events.id) at query time.
308
+ readonly highWaterMark: bigint;
309
+ // HWM - cursor. 0n when caught-up. Cannot be negative (that would mean
310
+ // the projection is ahead of HWM = bug). Used by ops dashboards to
311
+ // visualize projection lag.
312
+ readonly lag: bigint;
313
+ };
314
+
315
+ // Extended variant of listProjectionsWithState that also returns HWM and lag
316
+ // per projection. One extra cheap MAX-aggregate query — no additional
317
+ // roundtrip per projection. Programmatic callers (e.g. a Prometheus gauge
318
+ // exporter) can map the result directly to a `kumiko_projection_lag{name}`
319
+ // gauge.
320
+ export async function getAllProjectionProgress(
321
+ db: DbConnection,
322
+ registry: Registry,
323
+ ): Promise<readonly ProjectionProgress[]> {
324
+ const [projections, highWaterMark] = await Promise.all([
325
+ listProjectionsWithState(db, registry),
326
+ getEventsHighWaterMark(db),
327
+ ]);
328
+
329
+ return projections.map((p) => ({
330
+ ...p,
331
+ highWaterMark,
332
+ lag: highWaterMark - p.lastProcessedEventId,
333
+ }));
334
+ }
@@ -0,0 +1,72 @@
1
+ import { sql } from "drizzle-orm";
2
+ import type { DbConnection } from "../db/connection";
3
+ import { bigint, index, instant, table as pgTable, text } from "../db/dialect";
4
+ import { tableExists } from "../db/schema-inspection";
5
+ import { pushTables } from "../stack";
6
+
7
+ // Framework-level state for every registered projection. One row per qualified
8
+ // projection name. Written by the rebuild machinery; read by the CLI + any
9
+ // status dashboard. Lives alongside the events table as framework infra —
10
+ // user projection tables stay separate and user-owned.
11
+ //
12
+ // Columns:
13
+ // - name: projection's qualified name (feature:projection:shortname)
14
+ // - lastProcessedEventId: the bigserial `events.id` of the most recent
15
+ // event that was applied. Rebuild uses it as the cursor for what's
16
+ // done; live writes DON'T currently update it (synchronous apply means
17
+ // no meaningful lag, see projections-runner.ts). Once async apply lands
18
+ // in B.3+, this becomes the lag source.
19
+ // - status: "idle" (normal) | "rebuilding" (in-progress) | "failed"
20
+ // - lastRebuildAt: wall-clock time the last full rebuild finished
21
+ // - lastError: last error message when status = "failed" — rebuild sets
22
+ // this from the thrown message so ops can see it in `project status`
23
+ // last_processed_event_id uses a raw DEFAULT 0 instead of .default(0n) because
24
+ // drizzle-kit's JSON snapshot generator cannot serialise bigint literals —
25
+ // `TypeError: Do not know how to serialize a BigInt` bubbles through
26
+ // pushTables → generateMigration. `sql\`0\`` yields the same server-side
27
+ // default without ever putting a bigint in a generated-JSON path.
28
+ export const projectionStateTable = pgTable(
29
+ "kumiko_projections",
30
+ {
31
+ name: text("name").primaryKey(),
32
+ lastProcessedEventId: bigint("last_processed_event_id", { mode: "bigint" })
33
+ .notNull()
34
+ .default(sql`0`),
35
+ status: text("status").notNull().default("idle"),
36
+ lastRebuildAt: instant("last_rebuild_at", { precision: 3 }),
37
+ lastError: text("last_error"),
38
+ updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql`now()`),
39
+ },
40
+ (t) => ({
41
+ statusIdx: index("kumiko_projections_status_idx").on(t.status),
42
+ }),
43
+ );
44
+
45
+ export const ProjectionStatuses = {
46
+ idle: "idle",
47
+ rebuilding: "rebuilding",
48
+ failed: "failed",
49
+ } as const;
50
+ export type ProjectionStatus = (typeof ProjectionStatuses)[keyof typeof ProjectionStatuses];
51
+
52
+ /**
53
+ * @deprecated Use `ProjectionStatuses` (object form) or the `ProjectionStatus`
54
+ * union type. Tuple alias kept for back-compat with callers that relied on
55
+ * the array form (`z.enum(...)`, runtime iteration) — scheduled for removal
56
+ * after downstream migration.
57
+ */
58
+ export const PROJECTION_STATUSES = [
59
+ "idle",
60
+ "rebuilding",
61
+ "failed",
62
+ ] as const satisfies readonly ProjectionStatus[];
63
+
64
+ // Idempotent table bootstrap. Called by setupTestStack (and createApp once
65
+ // that wires it up) — same pattern as createEventsTable. If the table is
66
+ // already there (second stack in same test DB, production boot after
67
+ // migration), skip cleanly.
68
+ export async function createProjectionStateTable(db: DbConnection): Promise<void> {
69
+ // skip: table already exists — bootstrap is called from multiple paths
70
+ if (await tableExists(db, "public.kumiko_projections")) return;
71
+ await pushTables(db, { kumikoProjections: projectionStateTable });
72
+ }
@@ -0,0 +1,56 @@
1
+ import type { DbRunner } from "../db";
2
+ import type { HandlerContext, LifecycleResult, Registry } from "../engine/types";
3
+ import type { StoredEvent } from "../event-store";
4
+
5
+ // Run custom projections for a save or delete result. Lives INSIDE the
6
+ // transaction that appended the event — a throw from apply() rolls the event
7
+ // back along with any auto-projection write.
8
+ //
9
+ // Why in the pipeline, not in the executor:
10
+ // Executors used to take an optional `registry` per call. Every caller
11
+ // (crud-builder, manual handlers, seed scripts, future replay tools) had to
12
+ // remember to pass it — forgetting meant projections silently didn't fire.
13
+ // Putting the trigger here, keyed off the StoredEvent the executor surfaces
14
+ // on SaveContext/DeleteContext, closes that hole: every write that went
15
+ // through the dispatcher gets its projections, no opt-in needed.
16
+ //
17
+ // Contracts:
18
+ // - Projections receive the exact StoredEvent from the executor. If you
19
+ // hand-craft a SaveContext (tests, non-executor writes), just don't set
20
+ // `event` and the runner no-ops.
21
+ // - `tx`-scoped DbRunner is passed via the registered apply() — we reuse
22
+ // `ctx.db.raw`, which the dispatcher already scoped to the active tx.
23
+ // - Apply-function throws bubble up unchanged. The dispatcher wraps the
24
+ // whole lifecycle in a try/catch that rolls the tx back; the event is
25
+ // gone from the events table just like a rolled-back state change.
26
+ export async function runProjections(result: LifecycleResult, ctx: HandlerContext): Promise<void> {
27
+ // skip: hand-crafted result with no event — nothing to project
28
+ if (!result.event) return;
29
+ await runProjectionsForEvent(result.event, ctx.registry, ctx.db.raw);
30
+ }
31
+
32
+ // Fire every projection whose source matches the event's aggregate type AND
33
+ // that declares an apply-handler for the event's type. Used by both the
34
+ // CRUD path (via runProjections) and the ctx.appendEvent path (domain events
35
+ // emitted inside a write handler). Keeping one function means an auto-event
36
+ // and a r.defineEvent-event land in the same inline-projection pipeline.
37
+ export async function runProjectionsForEvent(
38
+ event: StoredEvent,
39
+ registry: Registry,
40
+ tx: DbRunner,
41
+ ): Promise<void> {
42
+ const projections = registry.getProjectionsForSource(event.aggregateType);
43
+ // skip: no projection feeds off this entity — fast path for the common case
44
+ if (projections.length === 0) return;
45
+ for (const proj of projections) {
46
+ // ImplicitProjections existieren nur für rebuildProjection — der
47
+ // EventStoreExecutor schreibt im Live-Pfad bereits direkt in die
48
+ // Tabelle. Live-Apply der Implicit würde doppelt schreiben → unique
49
+ // key violation. Filter ist Pflicht.
50
+ if (proj.isImplicit) continue;
51
+ const applyFn = proj.apply[event.type];
52
+ // skip: this projection doesn't care about this event type
53
+ if (!applyFn) continue;
54
+ await applyFn(event, tx);
55
+ }
56
+ }
@@ -0,0 +1,11 @@
1
+ // Central registry of all Redis key prefixes used by the framework.
2
+ // Prevents prefix collisions and makes key usage discoverable.
3
+
4
+ export const RedisKeys = {
5
+ idempotency: "kumiko:idempotency:",
6
+ eventDedup: "kumiko:event-dedup:",
7
+ entityCache: "kumiko:cache:",
8
+ lock: "kumiko:lock:",
9
+ events: "kumiko:events",
10
+ rateLimit: "kumiko:rl:",
11
+ } as const;