@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,409 @@
1
+ import type { DbRow } from "../db/connection";
2
+ import type {
3
+ AppContext,
4
+ DeleteContext,
5
+ HookPhase,
6
+ PostDeleteBatchHookFn,
7
+ PostDeleteHookFn,
8
+ PostSaveBatchHookFn,
9
+ PostSaveHookFn,
10
+ PreDeleteHookFn,
11
+ PreSaveHookFn,
12
+ Registry,
13
+ SaveContext,
14
+ } from "../engine/types";
15
+ import { HookPhases } from "../engine/types";
16
+ import { getFallbackTracer, type Tracer } from "../observability";
17
+ import type { EventDedup } from "./event-dedup";
18
+
19
+ function resolveTracer(context: AppContext): Tracer {
20
+ return context.tracer ?? getFallbackTracer();
21
+ }
22
+
23
+ export type SystemHookDef<TFn> = {
24
+ readonly name: string;
25
+ readonly priority: number;
26
+ readonly fn: TFn;
27
+ // Default: afterCommit (same as user-registered hooks).
28
+ // Set to "inTransaction" for DB-based side-effects (e.g. audit rows)
29
+ // that must roll back with the transaction.
30
+ readonly phase?: HookPhase;
31
+ };
32
+
33
+ export type SystemHooks = {
34
+ readonly preSave?: readonly SystemHookDef<PreSaveHookFn>[];
35
+ readonly postSave?: readonly SystemHookDef<PostSaveHookFn>[];
36
+ // Runs once per dispatcher batch, after every per-save postSave hook and
37
+ // after flushAfterCommit — for adapters that amortise work over the whole
38
+ // batch (search indexBatch, bulk webhook fanout).
39
+ readonly postSaveBatch?: readonly SystemHookDef<PostSaveBatchHookFn>[];
40
+ readonly preDelete?: readonly SystemHookDef<PreDeleteHookFn>[];
41
+ readonly postDelete?: readonly SystemHookDef<PostDeleteHookFn>[];
42
+ readonly postDeleteBatch?: readonly SystemHookDef<PostDeleteBatchHookFn>[];
43
+ };
44
+
45
+ export type LifecycleHooks = {
46
+ runPreSave(
47
+ handlerName: string,
48
+ changes: Record<string, unknown>,
49
+ previous: Readonly<Record<string, unknown>>,
50
+ isNew: boolean,
51
+ context: AppContext,
52
+ ): Promise<Record<string, unknown>>;
53
+
54
+ // Phase-aware: pass "inTransaction" to run only in-tx hooks during a batch
55
+ // transaction, then "afterCommit" after the transaction commits.
56
+ // Omitting phase runs all hooks (used by legacy call sites — to be removed).
57
+ runPostSave(
58
+ handlerName: string,
59
+ result: SaveContext,
60
+ context: AppContext,
61
+ phase?: HookPhase,
62
+ ): Promise<void>;
63
+
64
+ runPreDelete(handlerName: string, payload: DeleteContext, context: AppContext): Promise<void>;
65
+
66
+ runPostDelete(
67
+ handlerName: string,
68
+ payload: DeleteContext,
69
+ context: AppContext,
70
+ phase?: HookPhase,
71
+ ): Promise<void>;
72
+
73
+ // Fire the batch-level system hooks once per dispatcher batch, after all
74
+ // per-save hooks and the afterCommit flush. Errors are collected + logged,
75
+ // never rethrown — the writes are already committed.
76
+ runPostSaveBatch(results: readonly SaveContext[], context: AppContext): Promise<void>;
77
+ runPostDeleteBatch(payloads: readonly DeleteContext[], context: AppContext): Promise<void>;
78
+ };
79
+
80
+ export type LifecycleOptions = {
81
+ eventDedup?: EventDedup;
82
+ };
83
+
84
+ export function createLifecycleHooks(
85
+ registry: Registry,
86
+ systemHooks: SystemHooks = {},
87
+ options: LifecycleOptions = {},
88
+ ): LifecycleHooks {
89
+ const { eventDedup } = options;
90
+
91
+ function sortByPriority<T extends { priority: number }>(hooks: readonly T[]): T[] {
92
+ return [...hooks].sort((a, b) => a.priority - b.priority);
93
+ }
94
+
95
+ // Shared hook execution: runs handler hooks → entity hooks → system hooks.
96
+ //
97
+ // Error handling depends on hookPhase:
98
+ // - inTransaction: errors THROW (roll back transaction)
99
+ // - afterCommit: errors are collected + logged (best-effort)
100
+ //
101
+ // Event dedup is only applied in afterCommit phase, because:
102
+ // - the key must not be consumed if the transaction later rolls back
103
+ // - in-tx hooks run once per commit, dedup there is redundant
104
+ async function runHookSet<TPayload>(opts: {
105
+ handlerName: string;
106
+ payload: TPayload;
107
+ context: AppContext;
108
+ entityName: string | undefined;
109
+ getHandlerHooks: (name: string) => readonly ((p: TPayload, c: AppContext) => Promise<void>)[];
110
+ getEntityHooks: (name: string) => readonly ((p: TPayload, c: AppContext) => Promise<void>)[];
111
+ systemHookDefs:
112
+ | readonly SystemHookDef<(p: TPayload, c: AppContext) => Promise<void>>[]
113
+ | undefined;
114
+ phaseLabel: string;
115
+ hookPhase: HookPhase;
116
+ }): Promise<void> {
117
+ const throwOnError = opts.hookPhase === HookPhases.inTransaction;
118
+
119
+ // Event dedup: only in afterCommit (see comment above).
120
+ //
121
+ // SEMANTICS: pre-claim dedup = "at-most-once". tryAcquire is called before
122
+ // the hook runs, so if the hook crashes mid-execution, the retry sees
123
+ // `acquired=false` and skips the rest. This is the right trade-off for
124
+ // best-effort side-effects like Search Index, SSE broadcast, Audit — a
125
+ // missed hook is preferable to a duplicate notification. Features that
126
+ // need at-least-once semantics must not rely on this path; use
127
+ // ctx.appendEvent + r.multiStreamProjection instead, which the
128
+ // event-dispatcher retries until the consumer advances.
129
+ if (eventDedup && opts.hookPhase === HookPhases.afterCommit) {
130
+ const eventId = buildEventId(opts.handlerName, opts.payload, opts.phaseLabel);
131
+ if (eventId) {
132
+ const acquired = await eventDedup.tryAcquire(eventId);
133
+ if (!acquired) {
134
+ opts.context.log?.debug(
135
+ `${opts.phaseLabel}: skipping ${opts.handlerName} — event ${eventId} already processed (dedup)`,
136
+ );
137
+ return;
138
+ }
139
+ } else {
140
+ // Missing id on a save/delete payload is unexpected — CrudExecutor
141
+ // always returns one. Log so we can spot framework/feature bugs where
142
+ // a handler emits a non-standard LifecycleResult and accidentally
143
+ // bypasses dedup.
144
+ opts.context.log?.warn(
145
+ `${opts.phaseLabel}: ${opts.handlerName} has no dedup id (payload missing \`id\`) — hook may run multiple times on retry`,
146
+ );
147
+ }
148
+ }
149
+
150
+ const errors: Array<{ name: string; error: unknown }> = [];
151
+ const tracer = resolveTracer(opts.context);
152
+
153
+ // Common span attributes — populated per-hook below with source/name.
154
+ const baseAttrs = {
155
+ "kumiko.hook_type": opts.phaseLabel,
156
+ "kumiko.hook_phase": opts.hookPhase,
157
+ "kumiko.handler": opts.handlerName,
158
+ };
159
+
160
+ for (const hook of opts.getHandlerHooks(opts.handlerName)) {
161
+ try {
162
+ await tracer.withSpan(
163
+ "kumiko.pipeline.hook",
164
+ { attributes: { ...baseAttrs, "kumiko.hook_source": "handler" } },
165
+ () => hook(opts.payload, opts.context),
166
+ );
167
+ } catch (e) {
168
+ if (throwOnError) throw e;
169
+ errors.push({ name: `handler:${opts.handlerName}`, error: e });
170
+ }
171
+ }
172
+
173
+ // Shared runner for entity + system hook sets. In afterCommit phase they
174
+ // run in parallel (independent side-effects, errors are collected); in
175
+ // inTransaction phase they run sequentially (hooks share ctx.db and each
176
+ // writes must be observable to subsequent ones). `itemAttrs` lets the
177
+ // caller attach per-hook span attributes (e.g. the hook name).
178
+ async function runHooks<TItem>(
179
+ items: readonly TItem[],
180
+ itemAttrs: (item: TItem) => Record<string, string>,
181
+ errorName: (item: TItem) => string,
182
+ invoke: (item: TItem) => Promise<void>,
183
+ ): Promise<void> {
184
+ // skip: no hooks to run for this phase/handler combo
185
+ if (items.length === 0) return;
186
+ const withSpan = (item: TItem) =>
187
+ tracer.withSpan(
188
+ "kumiko.pipeline.hook",
189
+ { attributes: { ...baseAttrs, ...itemAttrs(item) } },
190
+ () => invoke(item),
191
+ );
192
+
193
+ if (opts.hookPhase === HookPhases.afterCommit) {
194
+ const outcomes = await Promise.allSettled(items.map(withSpan));
195
+ for (let i = 0; i < outcomes.length; i++) {
196
+ const outcome = outcomes[i];
197
+ if (outcome?.status === "rejected") {
198
+ if (throwOnError) throw outcome.reason;
199
+ const item = items[i];
200
+ if (item !== undefined) errors.push({ name: errorName(item), error: outcome.reason });
201
+ }
202
+ }
203
+ } else {
204
+ for (const item of items) {
205
+ try {
206
+ await withSpan(item);
207
+ } catch (e) {
208
+ if (throwOnError) throw e;
209
+ errors.push({ name: errorName(item), error: e });
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ if (opts.entityName) {
216
+ const entityName = opts.entityName;
217
+ await runHooks(
218
+ opts.getEntityHooks(entityName),
219
+ () => ({ "kumiko.hook_source": "entity", "kumiko.entity": entityName }),
220
+ () => `entity:${entityName}`,
221
+ (hook) => hook(opts.payload, opts.context),
222
+ );
223
+ }
224
+
225
+ if (opts.systemHookDefs) {
226
+ const applicable = sortByPriority(opts.systemHookDefs).filter(
227
+ (h) => (h.phase ?? HookPhases.afterCommit) === opts.hookPhase,
228
+ );
229
+ await runHooks(
230
+ applicable,
231
+ (h) => ({ "kumiko.hook_source": "system", "kumiko.hook_name": h.name }),
232
+ (h) => h.name,
233
+ (h) => h.fn(opts.payload, opts.context),
234
+ );
235
+ }
236
+
237
+ if (errors.length > 0) {
238
+ const log = opts.context.log;
239
+ const msg = `${opts.phaseLabel} errors for ${opts.handlerName}`;
240
+ const details = errors.map((e) => `${e.name}: ${e.error}`);
241
+ if (log) {
242
+ log.error(msg, { errors: details });
243
+ } else {
244
+ console.error(`[lifecycle] ${msg}:`, details);
245
+ }
246
+ }
247
+ }
248
+
249
+ return {
250
+ async runPreSave(handlerName, changes, previous, isNew, context) {
251
+ let currentChanges = changes;
252
+ const hookContext = { ...context, previous, isNew };
253
+ const eff = context.effectiveFeatures?.();
254
+
255
+ for (const hook of registry.getPreSaveHooks(handlerName, eff)) {
256
+ currentChanges = await hook(currentChanges, hookContext);
257
+ }
258
+
259
+ if (systemHooks.preSave) {
260
+ for (const sysHook of sortByPriority(systemHooks.preSave)) {
261
+ currentChanges = await sysHook.fn(currentChanges, hookContext);
262
+ }
263
+ }
264
+
265
+ return currentChanges;
266
+ },
267
+
268
+ async runPostSave(handlerName, result, context, phase = HookPhases.afterCommit) {
269
+ const eff = context.effectiveFeatures?.();
270
+ await runHookSet({
271
+ handlerName,
272
+ payload: result,
273
+ context,
274
+ entityName: result.entityName,
275
+ getHandlerHooks: (n) => registry.getPostSaveHooks(n, phase, eff),
276
+ getEntityHooks: (n) => registry.getEntityPostSaveHooks(n, phase, eff),
277
+ systemHookDefs: systemHooks.postSave,
278
+ phaseLabel: `postSave:${phase}`,
279
+ hookPhase: phase,
280
+ });
281
+ },
282
+
283
+ async runPreDelete(handlerName, payload, context) {
284
+ // preDelete hooks run in-transaction and throw on failure (not best-effort).
285
+ // They're used to check invariants before delete, so phase filter is "inTransaction".
286
+ const eff = context.effectiveFeatures?.();
287
+ for (const hook of registry.getPreDeleteHooks(handlerName, HookPhases.inTransaction, eff)) {
288
+ await hook(payload, context);
289
+ }
290
+
291
+ if (payload.entityName) {
292
+ for (const hook of registry.getEntityPreDeleteHooks(
293
+ payload.entityName,
294
+ HookPhases.inTransaction,
295
+ eff,
296
+ )) {
297
+ await hook(payload, context);
298
+ }
299
+ }
300
+
301
+ if (systemHooks.preDelete) {
302
+ for (const sysHook of sortByPriority(systemHooks.preDelete)) {
303
+ const sysHookPhase = sysHook.phase ?? HookPhases.inTransaction;
304
+ if (sysHookPhase !== HookPhases.inTransaction) continue;
305
+ await sysHook.fn(payload, context);
306
+ }
307
+ }
308
+ },
309
+
310
+ async runPostDelete(handlerName, payload, context, phase = HookPhases.afterCommit) {
311
+ const eff = context.effectiveFeatures?.();
312
+ await runHookSet({
313
+ handlerName,
314
+ payload,
315
+ context,
316
+ entityName: payload.entityName,
317
+ getHandlerHooks: (n) => registry.getPostDeleteHooks(n, phase, eff),
318
+ getEntityHooks: (n) => registry.getEntityPostDeleteHooks(n, phase, eff),
319
+ systemHookDefs: systemHooks.postDelete,
320
+ phaseLabel: `postDelete:${phase}`,
321
+ hookPhase: phase,
322
+ });
323
+ },
324
+
325
+ async runPostSaveBatch(results, context) {
326
+ await runBatchHooks({
327
+ hooks: systemHooks.postSaveBatch,
328
+ payload: results,
329
+ context,
330
+ phaseLabel: "postSaveBatch",
331
+ });
332
+ },
333
+
334
+ async runPostDeleteBatch(payloads, context) {
335
+ await runBatchHooks({
336
+ hooks: systemHooks.postDeleteBatch,
337
+ payload: payloads,
338
+ context,
339
+ phaseLabel: "postDeleteBatch",
340
+ });
341
+ },
342
+ };
343
+
344
+ // Runs batch hooks in parallel. Errors are logged but never rethrown —
345
+ // batch hooks fire after commit, so there's nothing to roll back.
346
+ async function runBatchHooks<TPayload>(opts: {
347
+ hooks: readonly SystemHookDef<(p: TPayload, c: AppContext) => Promise<void>>[] | undefined;
348
+ payload: TPayload;
349
+ context: AppContext;
350
+ phaseLabel: string;
351
+ }): Promise<void> {
352
+ // skip: no batch hooks registered for this phase
353
+ if (!opts.hooks || opts.hooks.length === 0) return;
354
+ const tracer = resolveTracer(opts.context);
355
+ const baseAttrs = { "kumiko.hook_type": opts.phaseLabel };
356
+
357
+ const outcomes = await Promise.allSettled(
358
+ sortByPriority(opts.hooks).map((sysHook) =>
359
+ tracer.withSpan(
360
+ "kumiko.pipeline.hook",
361
+ {
362
+ attributes: {
363
+ ...baseAttrs,
364
+ "kumiko.hook_source": "system",
365
+ "kumiko.hook_name": sysHook.name,
366
+ },
367
+ },
368
+ () => sysHook.fn(opts.payload, opts.context),
369
+ ),
370
+ ),
371
+ );
372
+
373
+ const failures = outcomes
374
+ .map((o, i) => ({ outcome: o, name: opts.hooks?.[i]?.name ?? "unknown" }))
375
+ .filter(
376
+ (x): x is { outcome: PromiseRejectedResult; name: string } =>
377
+ x.outcome.status === "rejected",
378
+ );
379
+ // skip: all batch hooks succeeded, nothing to log
380
+ if (failures.length === 0) return;
381
+
382
+ const log = opts.context.log;
383
+ const msg = `${opts.phaseLabel} errors`;
384
+ const details = failures.map((f) => `${f.name}: ${f.outcome.reason}`);
385
+ if (log) log.error(msg, { errors: details });
386
+ else console.error(`[lifecycle] ${msg}:`, details);
387
+ }
388
+ }
389
+
390
+ // Build a unique eventId from handler + entity identity + version + phase.
391
+ // version makes it unique per write (incremented on every update).
392
+ // Exported for unit tests — the warn-log path (null return) is otherwise
393
+ // unreachable through the normal LifecycleResult flow.
394
+ export function buildEventId(handlerName: string, payload: unknown, phase: string): string | null {
395
+ if (!payload || typeof payload !== "object") return null;
396
+ const p = payload as DbRow;
397
+ // Aggregate IDs are UUIDs (string) in this framework; legacy int-ids round-
398
+ // trip cleanly through String(). Anything else (undefined, null, object)
399
+ // means the payload doesn't carry a dedup-able identity. Also treat id=0
400
+ // and id="" as absent: serial PKs start at 1 and an empty string is never
401
+ // a valid aggregate id — safer to skip dedup than to collide on a sentinel.
402
+ const rawId = p["id"];
403
+ if (rawId === undefined || rawId === null) return null;
404
+ if (typeof rawId !== "string" && typeof rawId !== "number") return null;
405
+ if (rawId === 0 || rawId === "") return null;
406
+ const data = p["data"] as Record<string, unknown> | undefined; // @cast-boundary engine-payload
407
+ const version = data?.["version"] as number | undefined;
408
+ return `${handlerName}:${rawId}:${version ?? 0}:${phase}`;
409
+ }
@@ -0,0 +1,242 @@
1
+ import { and, asc, eq, getTableName, inArray, sql } from "drizzle-orm";
2
+ import type { DbConnection, DbRunner } from "../db/connection";
3
+ import type { Registry, TenantId } from "../engine/types";
4
+ import { InternalError } from "../errors";
5
+ import { eventsTable, type StoredEvent, upcastStoredEvent } from "../event-store";
6
+ import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store";
7
+ import { upcastStoredEvents } from "../event-store/upcaster";
8
+ import { emitProjectionRebuild } from "../observability/standard-metrics";
9
+ import type { Meter } from "../observability/types/metric";
10
+ import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
11
+ import type { MultiStreamApplyContext } from "./multi-stream-apply-context";
12
+ import type { RebuildResult } from "./projection-rebuild";
13
+
14
+ // Rebuild a multi-stream projection (MSP) from the event log. Symmetric to
15
+ // `rebuildProjection` for single-stream projections — same single-TX
16
+ // TRUNCATE+replay semantics — but wired against the dispatcher's consumer
17
+ // state row (cursor, not projection-state). MSPs are async-live and
18
+ // cursor-driven; rebuild resets the cursor to 0 and rematerializes the
19
+ // projection table in chronological event order.
20
+ //
21
+ // Why separate from rebuildProjection:
22
+ // - MSP apply signature includes a 3rd ctx arg (MultiStreamApplyContext
23
+ // for saga follow-ups). Rebuild passes a RESTRICTED ctx that allows
24
+ // loadAggregate but rejects appendEvent — the events a saga would emit
25
+ // already live in the log, replaying them would be a double-write.
26
+ // - Event selection is type-only (no aggregateType filter). MSPs subscribe
27
+ // by event-type; source aggregate is irrelevant.
28
+ // - State lives in kumiko_event_consumers (cursor row for the dispatcher),
29
+ // not in kumiko_projections.
30
+ //
31
+ // Side-effect-only MSPs (no `table`) cannot be rebuilt. Replaying would
32
+ // re-invoke the side-effect (webhook, notification, external sync) and
33
+ // produce duplicates by design. The function rejects up-front with a
34
+ // pointer at the consumer skip/restart ops surface.
35
+ //
36
+ // During the rebuild TX:
37
+ // - FOR UPDATE lock on the consumer row blocks concurrent live passes
38
+ // (SKIP LOCKED from the dispatcher backs off silently).
39
+ // - TRUNCATE the projection table.
40
+ // - Stream events matching apply-keys, invoke apply(event, tx, ctx).
41
+ // - Advance cursor to last processed event id, status=idle.
42
+ //
43
+ // Failure: outer catch writes status="dead" + lastError so ops sees the
44
+ // failure after the TX rolled back. Use restartConsumer to clear dead.
45
+
46
+ export type MspRebuildDeps = {
47
+ readonly db: DbConnection;
48
+ readonly registry: Registry;
49
+ // Optional framework meter; emits kumiko_projection_rebuild_* with a
50
+ // projection=<mspName> label (same metric namespace as single-stream —
51
+ // one rebuild series per projection, regardless of flavor).
52
+ readonly meter?: Meter;
53
+ // Test-hook — independent of `meter`, fires on success only.
54
+ readonly onMetrics?: (result: RebuildResult) => void;
55
+ };
56
+
57
+ function createRebuildCtx(
58
+ registry: Registry,
59
+ db: DbRunner,
60
+ tenantId: TenantId,
61
+ ): MultiStreamApplyContext {
62
+ // Both surfaces throw — rebuild MUST NOT emit. We share one impl.
63
+ const refuseAppend = async (args: { readonly type: string }) => {
64
+ throw new InternalError({
65
+ message: `rebuildMultiStreamProjection: ctx.appendEvent("${args.type}") is not supported during rebuild. The events your saga would emit already live in the event log — rebuild only derives read-model state. If you need to retroactively emit events, do so via a dedicated write-handler, not via the apply path.`,
66
+ });
67
+ };
68
+ return {
69
+ appendEvent: refuseAppend as MultiStreamApplyContext["appendEvent"], // @cast-boundary engine-bridge
70
+ appendEventUnsafe: refuseAppend,
71
+ loadAggregate: async (aggregateId, options) => {
72
+ const events = options?.asOf
73
+ ? await loadAggregateAsOf(db, aggregateId, tenantId, options.asOf)
74
+ : await loadAggregate(db, aggregateId, tenantId);
75
+ return upcastStoredEvents(events, registry.getEventUpcasters(), { db, tenantId });
76
+ },
77
+ };
78
+ }
79
+
80
+ export async function rebuildMultiStreamProjection(
81
+ mspName: string,
82
+ deps: MspRebuildDeps,
83
+ ): Promise<RebuildResult> {
84
+ const { db, registry } = deps;
85
+ const msp = registry.getAllMultiStreamProjections().get(mspName);
86
+ if (!msp) {
87
+ throw new Error(
88
+ `MultiStreamProjection "${mspName}" is not registered. Known: ${
89
+ [...registry.getAllMultiStreamProjections().keys()].join(", ") || "(none)"
90
+ }`,
91
+ );
92
+ }
93
+ if (!msp.table) {
94
+ throw new Error(
95
+ `MultiStreamProjection "${mspName}" has no backing table — it is a pure side-effect consumer (webhooks, notifications, external sync). Rebuild would re-invoke those side-effects by replaying the log. For poison events use yarn kumiko consumer skip / restart; there is no analogous "rebuild" concept for side-effect sinks.`,
96
+ );
97
+ }
98
+
99
+ const startedAt = Date.now();
100
+ let eventsProcessed = 0;
101
+ let lastProcessedEventId = 0n;
102
+
103
+ try {
104
+ await db.transaction(async (tx) => {
105
+ // Upsert + lock the consumer row. Rebuild always targets the
106
+ // SHARED-delivery shard: per-instance MSPs are side-effect-only (no
107
+ // table, so the guard above refuses them anyway), and rebuild's
108
+ // purpose is to rematerialize one persistent read-model, not fan
109
+ // out a local cache reset across instances. The FOR UPDATE on the
110
+ // next SELECT is what blocks concurrent rebuilds of the same MSP;
111
+ // live dispatcher passes use SKIP LOCKED on this row and will bail
112
+ // silently while we hold it.
113
+ await tx
114
+ .insert(eventConsumerStateTable)
115
+ .values({
116
+ name: mspName,
117
+ instanceId: SHARED_INSTANCE_SENTINEL,
118
+ lastProcessedEventId: 0n,
119
+ status: "idle",
120
+ })
121
+ .onConflictDoUpdate({
122
+ target: [eventConsumerStateTable.name, eventConsumerStateTable.instanceId],
123
+ set: {
124
+ lastProcessedEventId: 0n,
125
+ status: "idle",
126
+ attempts: 0,
127
+ lastError: null,
128
+ updatedAt: sql`now()`,
129
+ },
130
+ });
131
+ await tx
132
+ .select()
133
+ .from(eventConsumerStateTable)
134
+ .where(
135
+ and(
136
+ eq(eventConsumerStateTable.name, mspName),
137
+ eq(eventConsumerStateTable.instanceId, SHARED_INSTANCE_SENTINEL),
138
+ ),
139
+ )
140
+ .for("update");
141
+
142
+ // msp.table is narrowed by the upfront guard; the assertion here is
143
+ // for TS inside the async closure (narrowing doesn't cross the
144
+ // transaction boundary).
145
+ const tableName = getTableName(msp.table as NonNullable<typeof msp.table>);
146
+ await tx.execute(sql.raw(`TRUNCATE TABLE ${quoteIdent(tableName)}`));
147
+
148
+ const subscribedTypes = Object.keys(msp.apply);
149
+ if (subscribedTypes.length > 0) {
150
+ const events = (await tx
151
+ .select()
152
+ .from(eventsTable)
153
+ .where(inArray(eventsTable.type, subscribedTypes))
154
+ .orderBy(asc(eventsTable.id))) as ReadonlyArray<typeof eventsTable.$inferSelect>;
155
+
156
+ const upcasters = registry.getEventUpcasters();
157
+ for (const row of events) {
158
+ const raw: StoredEvent = {
159
+ id: String(row.id),
160
+ aggregateId: row.aggregateId,
161
+ aggregateType: row.aggregateType,
162
+ tenantId: row.tenantId,
163
+ version: row.version,
164
+ type: row.type,
165
+ eventVersion: row.eventVersion,
166
+ payload: row.payload,
167
+ metadata: row.metadata,
168
+ createdAt: row.createdAt,
169
+ createdBy: row.createdBy,
170
+ };
171
+ const storedEvent = await upcastStoredEvent(raw, upcasters, {
172
+ db: tx,
173
+ tenantId: row.tenantId as TenantId,
174
+ });
175
+ const applyFn = msp.apply[row.type];
176
+ if (!applyFn) continue;
177
+ const rebuildCtx = createRebuildCtx(registry, tx, row.tenantId as TenantId);
178
+ await applyFn(storedEvent, tx, rebuildCtx);
179
+ eventsProcessed++;
180
+ lastProcessedEventId = row.id;
181
+ }
182
+ }
183
+
184
+ await tx
185
+ .update(eventConsumerStateTable)
186
+ .set({
187
+ lastProcessedEventId,
188
+ status: "idle",
189
+ attempts: 0,
190
+ lastError: null,
191
+ updatedAt: sql`now()`,
192
+ })
193
+ .where(
194
+ and(
195
+ eq(eventConsumerStateTable.name, mspName),
196
+ eq(eventConsumerStateTable.instanceId, SHARED_INSTANCE_SENTINEL),
197
+ ),
198
+ );
199
+ });
200
+ } catch (e) {
201
+ const message = e instanceof Error ? e.message : String(e);
202
+ await db
203
+ .update(eventConsumerStateTable)
204
+ .set({ status: "dead", lastError: message, updatedAt: sql`now()` })
205
+ .where(
206
+ and(
207
+ eq(eventConsumerStateTable.name, mspName),
208
+ eq(eventConsumerStateTable.instanceId, SHARED_INSTANCE_SENTINEL),
209
+ ),
210
+ );
211
+ if (deps.meter) {
212
+ emitProjectionRebuild(
213
+ deps.meter,
214
+ { projection: mspName, success: false },
215
+ (Date.now() - startedAt) / 1000,
216
+ 0,
217
+ );
218
+ }
219
+ throw e;
220
+ }
221
+
222
+ const result: RebuildResult = {
223
+ projection: mspName,
224
+ eventsProcessed,
225
+ lastProcessedEventId,
226
+ durationMs: Date.now() - startedAt,
227
+ };
228
+ if (deps.meter) {
229
+ emitProjectionRebuild(
230
+ deps.meter,
231
+ { projection: mspName, success: true },
232
+ result.durationMs / 1000,
233
+ eventsProcessed,
234
+ );
235
+ }
236
+ deps.onMetrics?.(result);
237
+ return result;
238
+ }
239
+
240
+ function quoteIdent(name: string): string {
241
+ return `"${name.replace(/"/g, '""')}"`;
242
+ }