@cosmicdrift/kumiko-framework 1.0.0 → 2.0.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 (424) hide show
  1. package/README.md +9 -38
  2. package/package.json +21 -2
  3. package/src/__tests__/anonymous-access.integration.test.ts +185 -0
  4. package/src/__tests__/consumer-cli.integration.test.ts +172 -0
  5. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  6. package/src/__tests__/full-stack.integration.test.ts +1 -1
  7. package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
  8. package/src/__tests__/schema-cli.integration.test.ts +50 -0
  9. package/src/__tests__/store-table.integration.test.ts +93 -0
  10. package/src/api/__tests__/api.test.ts +373 -0
  11. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  13. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
  14. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
  15. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
  16. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
  17. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  18. package/src/api/__tests__/batch.integration.test.ts +101 -11
  19. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  20. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  21. package/src/api/__tests__/jwt.test.ts +200 -1
  22. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
  23. package/src/api/__tests__/pat-scope.test.ts +36 -0
  24. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  25. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
  26. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  27. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  28. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  29. package/src/api/__tests__/sse-broker.test.ts +57 -0
  30. package/src/api/__tests__/sse-route.test.ts +4 -0
  31. package/src/api/api-constants.ts +11 -0
  32. package/src/api/auth-middleware.ts +248 -40
  33. package/src/api/auth-routes.ts +576 -95
  34. package/src/api/index.ts +13 -4
  35. package/src/api/jwt.ts +170 -11
  36. package/src/api/pat-scope.ts +14 -0
  37. package/src/api/pii-leak-guard.ts +47 -0
  38. package/src/api/request-context.ts +3 -0
  39. package/src/api/request-id-middleware.ts +2 -0
  40. package/src/api/routes.ts +169 -2
  41. package/src/api/server.ts +112 -16
  42. package/src/api/sse-broker.ts +39 -0
  43. package/src/bun-db/__tests__/PATTERN.md +0 -1
  44. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
  45. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  46. package/src/bun-db/__tests__/write-brand.test.ts +21 -0
  47. package/src/bun-db/connection.ts +3 -3
  48. package/src/bun-db/index.ts +2 -0
  49. package/src/bun-db/query.ts +105 -32
  50. package/src/consumer-cli.ts +134 -0
  51. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  52. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  53. package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
  54. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  55. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  56. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  57. package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
  58. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  59. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  60. package/src/crypto/blind-index.ts +118 -0
  61. package/src/crypto/event-pii.ts +70 -0
  62. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  63. package/src/crypto/index.ts +67 -0
  64. package/src/crypto/kms-adapter.ts +2 -0
  65. package/src/crypto/pg-kms-adapter.ts +295 -0
  66. package/src/crypto/pii-field-encryption.ts +248 -0
  67. package/src/crypto/request-kms-cache.ts +38 -0
  68. package/src/crypto/subject-resolver.ts +91 -0
  69. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
  70. package/src/db/__tests__/blind-index.integration.test.ts +248 -0
  71. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  72. package/src/db/__tests__/collect-table-metas.test.ts +10 -10
  73. package/src/db/__tests__/config-seed.integration.test.ts +13 -5
  74. package/src/db/__tests__/dialect-instant.test.ts +1 -4
  75. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  76. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  77. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  78. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
  79. package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
  80. package/src/db/__tests__/feature-table-sources.test.ts +34 -0
  81. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
  82. package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
  83. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  84. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  85. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  86. package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
  87. package/src/db/__tests__/rebuild-marker.test.ts +13 -3
  88. package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
  89. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  90. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  91. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  92. package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
  93. package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
  94. package/src/db/api.ts +2 -2
  95. package/src/db/apply-entity-event.ts +18 -14
  96. package/src/db/blind-index-cleanup.ts +55 -0
  97. package/src/db/bun-provider.ts +2 -2
  98. package/src/db/collect-table-metas.ts +6 -7
  99. package/src/db/config-seed.ts +9 -9
  100. package/src/db/connection.ts +7 -12
  101. package/src/db/cursor.ts +1 -18
  102. package/src/db/dialect.ts +20 -26
  103. package/src/db/entity-field-encryption.ts +81 -24
  104. package/src/db/entity-table-meta-types.ts +2 -0
  105. package/src/db/entity-table-meta.ts +63 -90
  106. package/src/db/event-store-executor-context.ts +404 -0
  107. package/src/db/event-store-executor-read.ts +275 -0
  108. package/src/db/event-store-executor-write.ts +644 -0
  109. package/src/db/event-store-executor.ts +28 -1155
  110. package/src/db/feature-table-sources.ts +6 -5
  111. package/src/db/index.ts +20 -3
  112. package/src/db/located-timestamp.ts +4 -0
  113. package/src/db/migrate-generator.ts +39 -6
  114. package/src/db/migrate-runner.ts +107 -11
  115. package/src/db/pg-error.ts +9 -1
  116. package/src/db/postgres-provider.ts +2 -2
  117. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  118. package/src/db/queries/backfill-pii.ts +277 -0
  119. package/src/db/queries/ddl.ts +45 -0
  120. package/src/db/queries/event-consumer.ts +35 -2
  121. package/src/db/queries/event-store.ts +126 -19
  122. package/src/db/queries/projection-rebuild.ts +22 -8
  123. package/src/db/queries/shadow-swap.ts +183 -0
  124. package/src/db/queries/test-stack.ts +4 -30
  125. package/src/db/query-api.ts +1 -0
  126. package/src/db/query.ts +1 -0
  127. package/src/db/rebuild-marker.ts +18 -2
  128. package/src/db/reference-data.ts +2 -3
  129. package/src/db/replay-migration-sql.ts +257 -0
  130. package/src/db/schema-inspection.ts +1 -1
  131. package/src/db/table-builder.ts +102 -71
  132. package/src/db/tenant-db.ts +15 -53
  133. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  134. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  135. package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
  136. package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
  137. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
  138. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
  139. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  140. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
  141. package/src/engine/__tests__/boot-validator.test.ts +219 -15
  142. package/src/engine/__tests__/build-app-schema.test.ts +82 -0
  143. package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
  144. package/src/engine/__tests__/build-target.test.ts +3 -11
  145. package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
  146. package/src/engine/__tests__/config-helpers.test.ts +16 -0
  147. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  148. package/src/engine/__tests__/define-roles.test.ts +21 -0
  149. package/src/engine/__tests__/engine.test.ts +163 -1
  150. package/src/engine/__tests__/entity-handlers.test.ts +80 -0
  151. package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
  152. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  153. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  154. package/src/engine/__tests__/factories-time.test.ts +2 -66
  155. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  156. package/src/engine/__tests__/feature-manifest.test.ts +31 -1
  157. package/src/engine/__tests__/field-access.test.ts +23 -1
  158. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  159. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  160. package/src/engine/__tests__/nav.test.ts +86 -1
  161. package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
  162. package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
  163. package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
  164. package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
  165. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
  166. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  167. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  168. package/src/engine/__tests__/registry.test.ts +144 -0
  169. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  170. package/src/engine/__tests__/screen.test.ts +147 -1
  171. package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
  172. package/src/engine/__tests__/store-table.test.ts +229 -0
  173. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  174. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
  175. package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
  176. package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
  177. package/src/engine/boot-validator/action-wiring.ts +149 -0
  178. package/src/engine/boot-validator/boot-check.ts +21 -0
  179. package/src/engine/boot-validator/config-deps.ts +40 -4
  180. package/src/engine/boot-validator/entity-handler.ts +20 -21
  181. package/src/engine/boot-validator/entity-list-screens.ts +88 -0
  182. package/src/engine/boot-validator/gdpr-storage.ts +0 -20
  183. package/src/engine/boot-validator/i18n-keys.ts +58 -4
  184. package/src/engine/boot-validator/index.ts +33 -12
  185. package/src/engine/boot-validator/nav.ts +125 -0
  186. package/src/engine/boot-validator/pii-retention.ts +95 -10
  187. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
  188. package/src/engine/boot-validator/workspaces.ts +68 -0
  189. package/src/engine/build-app-schema.ts +16 -0
  190. package/src/engine/build-config-feature-schema.ts +44 -7
  191. package/src/engine/codemod/pipeline-codemod.ts +5 -5
  192. package/src/engine/config-helpers.ts +15 -0
  193. package/src/engine/constants.ts +32 -6
  194. package/src/engine/create-app.ts +11 -0
  195. package/src/engine/define-feature.ts +95 -947
  196. package/src/engine/define-handler.ts +28 -94
  197. package/src/engine/define-workflow.ts +1 -1
  198. package/src/engine/effective-features.ts +12 -2
  199. package/src/engine/entity-handlers.ts +76 -11
  200. package/src/engine/extensions/tenant-data.ts +19 -0
  201. package/src/engine/extensions/user-data.ts +29 -2
  202. package/src/engine/factories.ts +8 -49
  203. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
  204. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  205. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  206. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  207. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  208. package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
  209. package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
  210. package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
  211. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
  212. package/src/engine/feature-ast/extractors/events.ts +322 -0
  213. package/src/engine/feature-ast/extractors/handlers.ts +234 -0
  214. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  215. package/src/engine/feature-ast/extractors/index.ts +34 -31
  216. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  217. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  218. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  219. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  220. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  221. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  222. package/src/engine/feature-ast/index.ts +2 -4
  223. package/src/engine/feature-ast/parse.ts +130 -23
  224. package/src/engine/feature-ast/patch.ts +39 -50
  225. package/src/engine/feature-ast/patcher.ts +37 -38
  226. package/src/engine/feature-ast/patterns.ts +50 -62
  227. package/src/engine/feature-ast/render.ts +67 -44
  228. package/src/engine/feature-builder-state.ts +168 -0
  229. package/src/engine/feature-config-events-jobs.ts +403 -0
  230. package/src/engine/feature-entity-handlers.ts +208 -0
  231. package/src/engine/feature-manifest.ts +2 -1
  232. package/src/engine/feature-ui-extensions.ts +500 -0
  233. package/src/engine/field-access.ts +13 -2
  234. package/src/engine/field-helpers.ts +31 -0
  235. package/src/engine/handler-helpers.ts +26 -0
  236. package/src/engine/hook-helpers.ts +16 -0
  237. package/src/engine/index.ts +23 -7
  238. package/src/engine/membership-roles.ts +13 -0
  239. package/src/engine/object-form.ts +27 -0
  240. package/src/engine/ownership.ts +26 -79
  241. package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
  242. package/src/engine/pattern-library/library.ts +44 -1152
  243. package/src/engine/pattern-library/mixed-schemas.ts +450 -0
  244. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  245. package/src/engine/pattern-library/shared-fields.ts +75 -0
  246. package/src/engine/pattern-library/static-schemas.ts +456 -0
  247. package/src/engine/pipeline.ts +6 -11
  248. package/src/engine/registry-facade.ts +362 -0
  249. package/src/engine/registry-ingest.ts +478 -0
  250. package/src/engine/registry-state.ts +388 -0
  251. package/src/engine/registry-validate.ts +642 -0
  252. package/src/engine/registry.ts +78 -1658
  253. package/src/engine/run-pipeline.ts +1 -1
  254. package/src/engine/schema-builder.ts +1 -0
  255. package/src/engine/screen-helpers.ts +54 -0
  256. package/src/engine/soft-delete-cleanup.ts +6 -2
  257. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  258. package/src/engine/steps/_duration-utils.ts +2 -0
  259. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  260. package/src/engine/tier-resolver-extension.ts +3 -2
  261. package/src/engine/types/config.ts +2 -482
  262. package/src/engine/types/define-handler.ts +2 -0
  263. package/src/engine/types/entity-handlers.ts +2 -0
  264. package/src/engine/types/event-type-map.ts +1 -37
  265. package/src/engine/types/feature.ts +2 -972
  266. package/src/engine/types/fields.ts +2 -675
  267. package/src/engine/types/handlers.ts +2 -774
  268. package/src/engine/types/hooks.ts +2 -184
  269. package/src/engine/types/http-route.ts +1 -54
  270. package/src/engine/types/identifiers.ts +1 -47
  271. package/src/engine/types/index.ts +86 -40
  272. package/src/engine/types/nav.ts +2 -63
  273. package/src/engine/types/ownership.ts +2 -0
  274. package/src/engine/types/projection.ts +2 -138
  275. package/src/engine/types/relations.ts +1 -51
  276. package/src/engine/types/screen.ts +2 -574
  277. package/src/engine/types/step.ts +2 -334
  278. package/src/engine/types/target-ref.ts +1 -21
  279. package/src/engine/types/tree-node.ts +1 -132
  280. package/src/engine/types/workspace.ts +2 -49
  281. package/src/engine/validate-projection-allowlist.ts +6 -6
  282. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
  283. package/src/entrypoint/index.ts +49 -6
  284. package/src/errors/classes.ts +22 -1
  285. package/src/errors/field-issue.ts +0 -3
  286. package/src/errors/index.ts +1 -1
  287. package/src/errors/write-error-info.ts +10 -22
  288. package/src/es-ops/README.md +1 -1
  289. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  290. package/src/es-ops/context.ts +4 -2
  291. package/src/es-ops/types.ts +8 -1
  292. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  293. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  294. package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
  295. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  296. package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
  297. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
  298. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  299. package/src/event-store/admin-api.ts +11 -4
  300. package/src/event-store/errors.ts +2 -35
  301. package/src/event-store/event-store.ts +64 -78
  302. package/src/event-store/events-schema.ts +11 -13
  303. package/src/event-store/index.ts +18 -3
  304. package/src/event-store/rebuild-dead-letter.ts +111 -0
  305. package/src/event-store/snapshot.ts +63 -40
  306. package/src/event-store/types.ts +2 -0
  307. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  308. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  309. package/src/files/__tests__/files.integration.test.ts +24 -0
  310. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  311. package/src/files/__tests__/local-provider.test.ts +31 -0
  312. package/src/files/__tests__/provider-resolver.test.ts +70 -0
  313. package/src/files/__tests__/write-stream.test.ts +13 -0
  314. package/src/files/file-handle.ts +2 -19
  315. package/src/files/file-ref-entity.ts +2 -2
  316. package/src/files/file-routes.ts +13 -0
  317. package/src/files/index.ts +1 -1
  318. package/src/files/local-provider.ts +27 -8
  319. package/src/files/provider-resolver.ts +31 -20
  320. package/src/files/types.ts +13 -55
  321. package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
  322. package/src/i18n/required-surface-keys.ts +120 -1
  323. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  324. package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
  325. package/src/jobs/job-runner.ts +82 -12
  326. package/src/logging/types.ts +1 -7
  327. package/src/migrations/pending-rebuilds.ts +1 -1
  328. package/src/observability/__tests__/observability.integration.test.ts +4 -1
  329. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  330. package/src/observability/index.ts +2 -0
  331. package/src/observability/noop-provider.ts +0 -9
  332. package/src/observability/recording-tracer.ts +0 -12
  333. package/src/observability/standard-metrics.ts +64 -2
  334. package/src/observability/types/index.ts +1 -29
  335. package/src/observability/types/metric.ts +1 -56
  336. package/src/observability/types/provider.ts +1 -32
  337. package/src/observability/types/span.ts +1 -64
  338. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  339. package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
  340. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  341. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  342. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
  343. package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
  344. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  345. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
  346. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  347. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  348. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
  349. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  350. package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
  351. package/src/pipeline/dispatch-batch.ts +187 -0
  352. package/src/pipeline/dispatch-query.ts +165 -0
  353. package/src/pipeline/dispatch-shared.ts +826 -0
  354. package/src/pipeline/dispatch-stream.ts +90 -0
  355. package/src/pipeline/dispatch-write.ts +451 -0
  356. package/src/pipeline/dispatcher-utils.ts +1 -1
  357. package/src/pipeline/dispatcher.ts +44 -1372
  358. package/src/pipeline/entity-cache.ts +2 -33
  359. package/src/pipeline/event-consumer-state.ts +30 -2
  360. package/src/pipeline/event-dispatcher-admin.ts +293 -0
  361. package/src/pipeline/event-dispatcher-delivery.ts +305 -0
  362. package/src/pipeline/event-dispatcher.ts +85 -542
  363. package/src/pipeline/index.ts +2 -0
  364. package/src/pipeline/msp-rebuild.ts +42 -3
  365. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  366. package/src/pipeline/projection-rebuild.ts +105 -3
  367. package/src/pipeline/system-hooks.ts +144 -1
  368. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  369. package/src/rate-limit/resolver.ts +16 -32
  370. package/src/schema-cli.ts +76 -16
  371. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
  372. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  373. package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
  374. package/src/search/index.ts +6 -0
  375. package/src/search/meilisearch-adapter.ts +13 -12
  376. package/src/search/reindex-entity.ts +177 -0
  377. package/src/search/types.ts +1 -39
  378. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  379. package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
  380. package/src/secrets/__tests__/envelope.test.ts +1 -1
  381. package/src/secrets/dek-cache.ts +26 -5
  382. package/src/secrets/envelope-cipher.ts +53 -0
  383. package/src/secrets/envelope.ts +5 -3
  384. package/src/secrets/index.ts +13 -1
  385. package/src/secrets/stored-envelope.ts +46 -0
  386. package/src/secrets/types.ts +2 -162
  387. package/src/stack/__tests__/event-collector.test.ts +42 -0
  388. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
  389. package/src/stack/db.ts +2 -1
  390. package/src/stack/push-entity-projection-tables.ts +4 -3
  391. package/src/stack/redis.ts +8 -0
  392. package/src/stack/request-helper.ts +38 -2
  393. package/src/stack/table-helpers.ts +6 -4
  394. package/src/stack/test-stack.ts +249 -131
  395. package/src/testing/__tests__/late-bound.test.ts +32 -0
  396. package/src/testing/__tests__/wait-for.test.ts +59 -0
  397. package/src/testing/boot-validator-fixture.ts +121 -0
  398. package/src/testing/e2e-generator.ts +8 -0
  399. package/src/testing/file-provider-contract.ts +104 -0
  400. package/src/testing/handler-context.ts +3 -1
  401. package/src/testing/index.ts +5 -0
  402. package/src/testing/late-bound.ts +5 -3
  403. package/src/testing/mutable-master-key-provider.ts +29 -3
  404. package/src/testing/wait-for.ts +3 -0
  405. package/src/testing/without-ambient-temporal.ts +14 -0
  406. package/src/time/__tests__/tz-dateline.test.ts +8 -10
  407. package/src/time/geo-tz.ts +1 -32
  408. package/src/time/index.ts +1 -0
  409. package/src/time/legacy-date.ts +18 -0
  410. package/src/time/polyfill.ts +21 -38
  411. package/src/time/tz-context.ts +44 -85
  412. package/src/ui-types/app-schema.ts +12 -0
  413. package/src/ui-types/index.ts +20 -7
  414. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  415. package/src/utils/safe-json.ts +13 -1
  416. package/src/__tests__/raw-table.integration.test.ts +0 -116
  417. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  418. package/src/db/__tests__/encryption.test.ts +0 -39
  419. package/src/db/encryption.ts +0 -39
  420. package/src/db/row-helpers.ts +0 -4
  421. package/src/engine/__tests__/raw-table.test.ts +0 -150
  422. package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
  423. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
  424. package/src/engine/feature-ast/extractors/round4.ts +0 -1366
@@ -0,0 +1,274 @@
1
+ // #760 — poison-event quarantine during projection rebuild.
2
+ //
3
+ // A single historical event whose apply throws used to abort the WHOLE
4
+ // rebuild (tx rollback, status failed) with no recovery path short of
5
+ // hand-editing the event. With quarantine mode the rebuild confines each
6
+ // apply to a savepoint: the poison event is skipped, recorded into
7
+ // kumiko_rebuild_dead_letters, and the replay completes.
8
+ //
9
+ // Covers both rebuild flavors:
10
+ // - single-stream: RebuildDeps.errorPolicy.skipApplyErrors (per run)
11
+ // - MSP: MspErrorMode.rebuild.skipApplyErrors (per definition — declared
12
+ // API that was previously never honored by rebuildMultiStreamProjection)
13
+ // and both poison flavors:
14
+ // - JS throw (no SQL executed)
15
+ // - SQL error (aborts the tx → proves the savepoint is load-bearing)
16
+
17
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
+ import { z } from "zod";
19
+ import { integer as pgInteger, table as pgTable, uuid as pgUuid } from "../../db/dialect";
20
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
21
+ import { asRawClient } from "../../db/query";
22
+ import { buildEntityTable } from "../../db/table-builder";
23
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
24
+ import { createEntity, createTextField, defineApply, defineFeature } from "../../engine";
25
+ import type { ProjectionDefinition } from "../../engine/types";
26
+ import { append, createEventsTable } from "../../event-store";
27
+ import { listRebuildDeadLetters } from "../../event-store/rebuild-dead-letter";
28
+ import {
29
+ createProjectionStateTable,
30
+ getConsumerState,
31
+ getProjectionState,
32
+ rebuildMultiStreamProjection,
33
+ rebuildProjection,
34
+ } from "../../pipeline";
35
+ import {
36
+ setupTestStack,
37
+ type TestStack,
38
+ TestUsers,
39
+ unsafeCreateEntityTable,
40
+ unsafePushTables,
41
+ } from "../../stack";
42
+
43
+ // --- Fixtures ---
44
+
45
+ const POISON_JS = "poison-js";
46
+ const POISON_SQL = "poison-sql";
47
+
48
+ const itemEntity = createEntity({
49
+ table: "read_poison_items",
50
+ fields: {
51
+ groupId: createTextField({ required: true }),
52
+ name: createTextField({ required: true }),
53
+ },
54
+ });
55
+ const itemTable = buildEntityTable("poison-item", itemEntity);
56
+
57
+ const counterTable = pgTable("read_poison_counter", {
58
+ groupId: pgUuid("group_id").primaryKey(),
59
+ tenantId: pgUuid("tenant_id").notNull(),
60
+ itemCount: pgInteger("item_count").notNull().default(0),
61
+ });
62
+
63
+ const mspCounterTable = pgTable("read_poison_msp_counter", {
64
+ groupId: pgUuid("group_id").primaryKey(),
65
+ tenantId: pgUuid("tenant_id").notNull(),
66
+ itemCount: pgInteger("item_count").notNull().default(0),
67
+ });
68
+
69
+ async function bump(tx: unknown, tableName: string, groupId: string, tenantId: string) {
70
+ await asRawClient(tx).unsafe(
71
+ `INSERT INTO "${tableName}" (group_id, tenant_id, item_count) VALUES ($1::uuid, $2::uuid, 1) ON CONFLICT (group_id) DO UPDATE SET item_count = "${tableName}".item_count + 1`,
72
+ [groupId, tenantId],
73
+ );
74
+ }
75
+
76
+ // Poison by payload marker. POISON_SQL runs a genuinely failing statement so
77
+ // the surrounding tx would be in 25P02 without the savepoint.
78
+ async function applyOrPoison(
79
+ tx: unknown,
80
+ tableName: string,
81
+ payload: { groupId: string; name: string },
82
+ tenantId: string,
83
+ ): Promise<void> {
84
+ if (payload.name === POISON_JS) throw new Error("intentional js poison");
85
+ if (payload.name === POISON_SQL) {
86
+ await asRawClient(tx).unsafe(
87
+ `INSERT INTO "${tableName}" (group_id, tenant_id, item_count) VALUES ('not-a-uuid', $1::uuid, 1)`,
88
+ [tenantId],
89
+ );
90
+ return;
91
+ }
92
+ await bump(tx, tableName, payload.groupId, tenantId);
93
+ }
94
+
95
+ type ItemPayload = { groupId: string; name: string };
96
+
97
+ const poisonProjection: ProjectionDefinition = {
98
+ name: "poison-counter",
99
+ source: "poison-item",
100
+ table: counterTable,
101
+ apply: {
102
+ "poison-item.created": defineApply<ItemPayload>(async (event, tx) => {
103
+ await applyOrPoison(tx, "read_poison_counter", event.payload, event.tenantId);
104
+ }),
105
+ },
106
+ };
107
+
108
+ const MSP_EVENT_SHORT = "poison-noted";
109
+
110
+ const feature = defineFeature("poisontest", (r) => {
111
+ r.entity("poison-item", itemEntity);
112
+ r.projection(poisonProjection);
113
+ const noted = r.defineEvent(MSP_EVENT_SHORT, z.object({ groupId: z.uuid(), name: z.string() }));
114
+ r.multiStreamProjection({
115
+ name: "poison-msp-counter",
116
+ table: mspCounterTable,
117
+ errorMode: { rebuild: { skipApplyErrors: true } },
118
+ apply: {
119
+ [noted.name]: async (event, tx) => {
120
+ const p = event.payload as ItemPayload; // @cast-boundary engine-payload
121
+ await applyOrPoison(tx, "read_poison_msp_counter", p, event.tenantId);
122
+ },
123
+ },
124
+ });
125
+ });
126
+
127
+ const admin = TestUsers.admin;
128
+ const PROJECTION = "poisontest:projection:poison-counter";
129
+ const MSP = "poisontest:projection:poison-msp-counter";
130
+ const MSP_EVENT = "poisontest:event:poison-noted";
131
+ const GROUP = "00000000-0000-4000-8000-00000000cafe";
132
+
133
+ let stack: TestStack;
134
+ let tdb: TenantDb;
135
+
136
+ const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "poison-item" });
137
+
138
+ beforeAll(async () => {
139
+ stack = await setupTestStack({ features: [feature] });
140
+ await unsafeCreateEntityTable(stack.db, itemEntity, "poison-item");
141
+ await createEventsTable(stack.db);
142
+ await createProjectionStateTable(stack.db);
143
+ await unsafePushTables(stack.db, {
144
+ poisonCounter: counterTable,
145
+ poisonMspCounter: mspCounterTable,
146
+ });
147
+ tdb = createTenantDb(stack.db, admin.tenantId);
148
+ });
149
+
150
+ afterAll(async () => {
151
+ await stack.cleanup();
152
+ });
153
+
154
+ beforeEach(async () => {
155
+ await asRawClient(stack.db).unsafe(
156
+ `TRUNCATE kumiko_events, read_poison_items, read_poison_counter, read_poison_msp_counter, kumiko_projections RESTART IDENTITY CASCADE`,
157
+ );
158
+ await asRawClient(stack.db).unsafe(`DROP TABLE IF EXISTS kumiko_rebuild_dead_letters`);
159
+ if (stack.eventDispatcher) await stack.eventDispatcher.ensureRegistered();
160
+ });
161
+
162
+ async function createItem(name: string): Promise<void> {
163
+ await executor.create({ groupId: GROUP, name }, admin, tdb);
164
+ }
165
+
166
+ async function getCount(table: string): Promise<number | undefined> {
167
+ const rows = (await asRawClient(stack.db).unsafe(
168
+ `SELECT item_count FROM "${table}" WHERE group_id = $1::uuid`,
169
+ [GROUP],
170
+ )) as ReadonlyArray<{ item_count: number }>;
171
+ return rows[0]?.item_count;
172
+ }
173
+
174
+ describe("rebuildProjection — poison event, strict default (#760)", () => {
175
+ test("apply throw aborts the rebuild: status failed, no dead letters", async () => {
176
+ await createItem("good-1");
177
+ await createItem(POISON_JS);
178
+ await createItem("good-2");
179
+
180
+ await expect(
181
+ rebuildProjection(PROJECTION, { db: stack.db, registry: stack.registry }),
182
+ ).rejects.toThrow("intentional js poison");
183
+
184
+ const state = await getProjectionState(stack.db, PROJECTION);
185
+ expect(state?.status).toBe("failed");
186
+ // Strict mode never provisions/records dead letters.
187
+ const rows = (await asRawClient(stack.db).unsafe(
188
+ `SELECT to_regclass('public.kumiko_rebuild_dead_letters') AS t`,
189
+ )) as ReadonlyArray<{ t: string | null }>;
190
+ expect(rows[0]?.t).toBeNull();
191
+ });
192
+ });
193
+
194
+ describe("rebuildProjection — quarantine mode (#760)", () => {
195
+ test("js- and sql-poison events are skipped, recorded, and the rebuild completes", async () => {
196
+ await createItem("good-1");
197
+ await createItem(POISON_JS);
198
+ // sql-poison proves the savepoint: without it the failed statement puts
199
+ // the whole rebuild tx into 25P02 and every later apply fails too.
200
+ await createItem(POISON_SQL);
201
+ await createItem("good-2");
202
+
203
+ const result = await rebuildProjection(PROJECTION, {
204
+ db: stack.db,
205
+ registry: stack.registry,
206
+ errorPolicy: { skipApplyErrors: true },
207
+ });
208
+
209
+ expect(result.eventsSkipped).toBe(2);
210
+ expect(result.eventsProcessed).toBe(4);
211
+ expect(await getCount("read_poison_counter")).toBe(2);
212
+
213
+ const state = await getProjectionState(stack.db, PROJECTION);
214
+ expect(state?.status).toBe("idle");
215
+
216
+ const deadLetters = await listRebuildDeadLetters(stack.db, { projectionName: PROJECTION });
217
+ expect(deadLetters).toHaveLength(2);
218
+ const messages = deadLetters.map((d) => d.errorMessage).sort();
219
+ expect(messages[0]).toContain("poison");
220
+ expect(deadLetters.every((d) => d.eventType === "poison-item.created")).toBe(true);
221
+ expect(deadLetters.every((d) => d.aggregateType === "poison-item")).toBe(true);
222
+ });
223
+
224
+ test("clean run in quarantine mode: zero skipped, no dead letters", async () => {
225
+ await createItem("good-1");
226
+ await createItem("good-2");
227
+
228
+ const result = await rebuildProjection(PROJECTION, {
229
+ db: stack.db,
230
+ registry: stack.registry,
231
+ errorPolicy: { skipApplyErrors: true },
232
+ });
233
+
234
+ expect(result.eventsSkipped).toBe(0);
235
+ expect(await getCount("read_poison_counter")).toBe(2);
236
+ expect(await listRebuildDeadLetters(stack.db, { projectionName: PROJECTION })).toHaveLength(0);
237
+ });
238
+ });
239
+
240
+ describe("rebuildMultiStreamProjection — errorMode.rebuild.skipApplyErrors (#760)", () => {
241
+ async function appendMspEvent(name: string, expectedVersion: number): Promise<void> {
242
+ await append(stack.db, {
243
+ aggregateId: GROUP,
244
+ aggregateType: "poison-note",
245
+ tenantId: admin.tenantId,
246
+ expectedVersion,
247
+ type: MSP_EVENT,
248
+ payload: { groupId: GROUP, name },
249
+ metadata: { userId: admin.id },
250
+ });
251
+ }
252
+
253
+ test("declared rebuild policy is honored: poison skipped + recorded, cursor advanced", async () => {
254
+ await appendMspEvent("good-1", 0);
255
+ await appendMspEvent(POISON_SQL, 1);
256
+ await appendMspEvent("good-2", 2);
257
+
258
+ const result = await rebuildMultiStreamProjection(MSP, {
259
+ db: stack.db,
260
+ registry: stack.registry,
261
+ });
262
+
263
+ expect(result.eventsSkipped).toBe(1);
264
+ expect(result.eventsProcessed).toBe(3);
265
+ expect(await getCount("read_poison_msp_counter")).toBe(2);
266
+
267
+ const deadLetters = await listRebuildDeadLetters(stack.db, { projectionName: MSP });
268
+ expect(deadLetters).toHaveLength(1);
269
+ expect(deadLetters[0]?.eventType).toBe(MSP_EVENT);
270
+
271
+ const consumer = await getConsumerState(stack.db, MSP);
272
+ expect(consumer?.lastProcessedEventId).toBe(result.lastProcessedEventId);
273
+ });
274
+ });
@@ -0,0 +1,166 @@
1
+ // Issue #1038 — ctx.tryAppendEvent (savepoint-scoped append).
2
+ //
3
+ // Claims pinned here:
4
+ // 1. Happy path: first append on a fresh aggregate succeeds, returns
5
+ // { ok: true, event } with the stored event.
6
+ // 2. Non-poisoning: when tryAppendEvent loses a version-conflict race, the
7
+ // rest of the handler's transaction still commits (a write AFTER the
8
+ // call in the same handler is not rolled back) — this is the whole
9
+ // reason the primitive exists over a bare try/catch around
10
+ // unsafeAppendEvent (Bun.SQL/postgres.js abort the entire begin() on
11
+ // an uncaught statement error, SQLSTATE 25P02, even if the JS error is
12
+ // caught).
13
+ //
14
+ // The conflict branch can't be forced deterministically through the public
15
+ // interface — appendDomainEventCore reads the stream version fresh inside
16
+ // itself, so a lone sequential append never conflicts; only two concurrent
17
+ // transactions racing the same version produce the 23505. So claim 2 is
18
+ // tested as an invariant over concurrent writers (looped per house
19
+ // convention for probabilistic tests) rather than by asserting the conflict
20
+ // branch was hit on a specific call — depending on interleaving, Postgres
21
+ // may pick either writer as the winner.
22
+
23
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
24
+ import { z } from "zod";
25
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
26
+ import { asRawClient, selectMany } from "../../db/query";
27
+ import { buildEntityTable } from "../../db/table-builder";
28
+ import { createEntity, createTextField, defineFeature } from "../../engine";
29
+ import { loadAggregate } from "../../event-store";
30
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
31
+
32
+ const TAE_AGGREGATE_TYPE = "taeDoc";
33
+
34
+ // Marker entity — a plain CRUD write issued right after ctx.tryAppendEvent
35
+ // in the same handler/transaction. Its row landing (regardless of whether
36
+ // the append won or lost the race) is the non-poisoning proof.
37
+ const markerEntity = createEntity({
38
+ table: "tae_markers",
39
+ fields: {
40
+ note: createTextField({ required: true }),
41
+ },
42
+ });
43
+ const markerTable = buildEntityTable("taeMarker", markerEntity);
44
+ const markerExecutor = createEventStoreExecutor(markerTable, markerEntity, {
45
+ entityName: "taeMarker",
46
+ });
47
+
48
+ let stack: TestStack;
49
+ const admin = TestUsers.admin;
50
+
51
+ const tryAppendFeature = defineFeature("tae", (r) => {
52
+ r.entity("taeMarker", markerEntity);
53
+ const appended = r.defineEvent("appended", z.object({ note: z.string() }));
54
+
55
+ r.writeHandler(
56
+ "doc:try-append",
57
+ z.object({ aggregateId: z.uuid(), note: z.string() }),
58
+ async (event, ctx) => {
59
+ const result = await ctx.tryAppendEvent({
60
+ aggregateId: event.payload.aggregateId,
61
+ aggregateType: TAE_AGGREGATE_TYPE,
62
+ type: appended.name,
63
+ payload: { note: event.payload.note },
64
+ });
65
+ // Runs unconditionally — if tryAppendEvent's savepoint failed to
66
+ // confine the VersionConflictError, this write would fail too
67
+ // because the whole tx aborted (25P02).
68
+ const markerCreated = await markerExecutor.create(
69
+ { note: event.payload.note },
70
+ event.user,
71
+ ctx.db,
72
+ );
73
+ if (!markerCreated.isSuccess) return markerCreated;
74
+ return {
75
+ isSuccess: true as const,
76
+ data: {
77
+ ok: result.ok,
78
+ version: result.ok ? result.event.version : null,
79
+ },
80
+ };
81
+ },
82
+ { access: { roles: ["Admin"] } },
83
+ );
84
+ });
85
+
86
+ beforeAll(async () => {
87
+ stack = await setupTestStack({ features: [tryAppendFeature] });
88
+ await unsafeCreateEntityTable(stack.db, markerEntity, "taeMarker");
89
+ });
90
+
91
+ afterAll(async () => {
92
+ await stack.cleanup();
93
+ });
94
+
95
+ beforeEach(async () => {
96
+ await asRawClient(stack.db).unsafe(
97
+ `TRUNCATE kumiko_events, "${markerTable.tableName}" RESTART IDENTITY CASCADE`,
98
+ );
99
+ });
100
+
101
+ describe("Issue #1038 — ctx.tryAppendEvent", () => {
102
+ test("happy path: first append on a fresh aggregate returns { ok: true, event }", async () => {
103
+ const aggregateId = crypto.randomUUID();
104
+
105
+ const result = await stack.http.writeOk<{
106
+ ok: boolean;
107
+ version: number | null;
108
+ }>("tae:write:doc:try-append", { aggregateId, note: "hello" }, admin);
109
+
110
+ expect(result.ok).toBe(true);
111
+ expect(result.version).toBe(1);
112
+
113
+ const events = await loadAggregate(stack.db, aggregateId, admin.tenantId);
114
+ expect(events).toHaveLength(1);
115
+ expect(events[0]?.payload).toEqual({ note: "hello" });
116
+
117
+ const markers = await selectMany(stack.db, markerTable);
118
+ expect(markers).toHaveLength(1);
119
+ });
120
+
121
+ // Probabilistic — looped per house convention (mehrere Durchläufe, z.B.
122
+ // 20x) since a genuine 23505 race depends on transaction-timing
123
+ // interleaving that a single run can't force deterministically.
124
+ const ITERATIONS = 20;
125
+ const CONCURRENCY = 3;
126
+
127
+ test(`non-poisoning invariant holds across ${ITERATIONS} concurrent-writer rounds`, async () => {
128
+ for (let i = 0; i < ITERATIONS; i++) {
129
+ const aggregateId = crypto.randomUUID();
130
+
131
+ const responses = await Promise.all(
132
+ Array.from({ length: CONCURRENCY }, (_, n) =>
133
+ stack.http.write("tae:write:doc:try-append", { aggregateId, note: `writer-${n}` }, admin),
134
+ ),
135
+ );
136
+
137
+ // No writer's transaction was poisoned by a losing append — every
138
+ // request completes as a normal 200, never a 500 from an aborted tx.
139
+ let okCount = 0;
140
+ for (const res of responses) {
141
+ expect(res.status).toBe(200);
142
+ const body = (await res.json()) as { isSuccess: boolean; data: { ok: boolean } };
143
+ expect(body.isSuccess).toBe(true);
144
+ if (body.data.ok) okCount++;
145
+ }
146
+
147
+ // One event per successful append — interleaving-independent
148
+ // invariant. Serialized (non-racing) writers can legitimately all
149
+ // succeed at different versions; only actual conflicts return
150
+ // { ok: false }, so events.length must track okCount, not a fixed 1.
151
+ const events = await loadAggregate(stack.db, aggregateId, admin.tenantId);
152
+ expect(events).toHaveLength(okCount);
153
+ expect(okCount).toBeGreaterThanOrEqual(1);
154
+
155
+ // Every writer's marker insert landed regardless of win/lose — the
156
+ // savepoint confined the losers' VersionConflictError instead of
157
+ // aborting their whole transaction.
158
+ const markers = await selectMany(stack.db, markerTable);
159
+ expect(markers).toHaveLength(CONCURRENCY);
160
+
161
+ await asRawClient(stack.db).unsafe(
162
+ `TRUNCATE kumiko_events, "${markerTable.tableName}" RESTART IDENTITY CASCADE`,
163
+ );
164
+ }
165
+ });
166
+ });
@@ -0,0 +1,187 @@
1
+ import type { DbConnection } from "../db/connection";
2
+ import { transaction } from "../db/query";
3
+ import type { DeleteContext, SaveContext, SessionUser, WriteResult } from "../engine/types";
4
+ import { InternalError, toWriteErrorInfo, writeFailure } from "../errors";
5
+ import { createFallbackLogger } from "../logging/utils";
6
+ import { parseJsonSafe } from "../utils/safe-json";
7
+ import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared";
8
+ import { resolveDbSource } from "./dispatch-shared";
9
+ import { executeNestedWrite } from "./dispatch-write";
10
+ import {
11
+ type AfterCommitHook,
12
+ BatchRollback,
13
+ isLifecycleResult,
14
+ wrapToKumiko,
15
+ } from "./dispatcher-utils";
16
+
17
+ // Core batch logic extracted so write() and command() can reuse it
18
+ // (a single write = batch of one, running in its own transaction).
19
+ export async function runBatch(
20
+ ctx: DispatchContext,
21
+ commands: readonly BatchCommand[],
22
+ user: SessionUser,
23
+ requestId?: string,
24
+ ): Promise<BatchResult> {
25
+ const { idempotency, lifecycle, appContext: context } = ctx;
26
+ if (commands.length === 0) {
27
+ return { isSuccess: true, results: [] };
28
+ }
29
+
30
+ // Idempotency: if the same requestId has already been processed, return the
31
+ // cached result without re-executing. The cache holds the full BatchResult.
32
+ if (requestId && idempotency) {
33
+ const cached = await idempotency.check(requestId);
34
+ if (cached) {
35
+ const parsed = parseJsonSafe<BatchResult | null>(cached, null);
36
+ if (parsed) return parsed;
37
+ // corrupted cache entry — treat as miss, let the request re-run
38
+ }
39
+ }
40
+
41
+ // Wrap return paths: cache the final result under requestId so retries get
42
+ // the same answer (both success and failure results are cached).
43
+ const finalize = async (result: BatchResult): Promise<BatchResult> => {
44
+ if (requestId && idempotency) {
45
+ await idempotency.store(requestId, result);
46
+ }
47
+ return result;
48
+ };
49
+
50
+ const afterCommitHooks: AfterCommitHook[] = [];
51
+ const results: WriteResult[] = [];
52
+
53
+ // Flush afterCommit hooks in parallel. Errors are logged, not rethrown:
54
+ // the writes are already committed, we can't undo them.
55
+ //
56
+ // Parallelisation is safe because afterCommit hooks are deferred side-
57
+ // effects (e.g. feature-level postSave hooks in afterCommit phase)
58
+ // that don't depend on each other — the in-transaction work already ran
59
+ // sequentially inside the lifecycle pipeline where ordering matters. If a
60
+ // future hook ever needs ordering, it should do its sequencing internally
61
+ // (one hook pushing multiple sub-calls) rather than relying on the
62
+ // flush-loop order.
63
+ const flushAfterCommit = async () => {
64
+ const logError = createFallbackLogger("dispatcher", context.log);
65
+ const outcomes = await Promise.allSettled(afterCommitHooks.map((hook) => hook()));
66
+ for (const outcome of outcomes) {
67
+ if (outcome.status === "rejected") {
68
+ const detail =
69
+ outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
70
+ logError.error("afterCommit hook failed", { error: detail });
71
+ }
72
+ }
73
+ };
74
+
75
+ // Fires the batch-level system hooks with every successful save/delete
76
+ // context from this run. Called after flushAfterCommit so per-save hooks
77
+ // have all completed first; errors are isolated inside lifecycleHooks.
78
+ const flushBatchHooks = async () => {
79
+ try {
80
+ const saves: SaveContext[] = [];
81
+ const deletes: DeleteContext[] = [];
82
+ for (const r of results) {
83
+ if (!r.isSuccess) continue;
84
+ if (!isLifecycleResult(r.data)) continue;
85
+ if (r.data.kind === "save") saves.push(r.data);
86
+ else if (r.data.kind === "delete") deletes.push(r.data);
87
+ }
88
+ if (saves.length > 0 && lifecycle) await lifecycle.runPostSaveBatch(saves, context);
89
+ if (deletes.length > 0 && lifecycle) await lifecycle.runPostDeleteBatch(deletes, context);
90
+ } catch (e) {
91
+ // Batch hooks must never fail the batch — the commit already happened.
92
+ // Pass the raw error so the logger preserves stack + cause chain;
93
+ // collapsing to .message hides exactly what ops needs to debug.
94
+ const logError = createFallbackLogger("dispatcher", context.log);
95
+ logError.error("batch hook flush failed", { error: e });
96
+ }
97
+ };
98
+
99
+ // batch() opens its own outer transaction — needs the top-level
100
+ // connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
101
+ const db = resolveDbSource(ctx, undefined) as DbConnection | undefined;
102
+ if (!db) {
103
+ // Without a DB connection there is no transaction to open. Fall back to
104
+ // sequential execution — useful for unit tests that don't touch the DB.
105
+ // Each command runs independently; a failure stops the batch.
106
+ for (let i = 0; i < commands.length; i++) {
107
+ const cmd = commands[i];
108
+ if (!cmd) continue;
109
+ const res = await executeNestedWrite(
110
+ ctx,
111
+ cmd.type,
112
+ cmd.payload,
113
+ user,
114
+ undefined,
115
+ afterCommitHooks,
116
+ );
117
+ results.push(res);
118
+ if (!res.isSuccess) {
119
+ // No tx means no rollback — but we still drop afterCommit hooks,
120
+ // matching the semantic "failure = side-effects don't fire".
121
+ return finalize({ isSuccess: false, error: res.error, failedIndex: i, results });
122
+ }
123
+ }
124
+ await flushAfterCommit();
125
+ await flushBatchHooks();
126
+ return finalize({ isSuccess: true, results });
127
+ }
128
+
129
+ try {
130
+ await transaction(db, async (tx) => {
131
+ for (let i = 0; i < commands.length; i++) {
132
+ const cmd = commands[i];
133
+ if (!cmd) continue;
134
+ const res = await executeNestedWrite(
135
+ ctx,
136
+ cmd.type,
137
+ cmd.payload,
138
+ user,
139
+ tx,
140
+ afterCommitHooks,
141
+ );
142
+ results.push(res);
143
+ if (!res.isSuccess) {
144
+ throw new BatchRollback(i, res.error);
145
+ }
146
+ }
147
+ });
148
+ } catch (e) {
149
+ if (e instanceof BatchRollback) {
150
+ return finalize({
151
+ isSuccess: false,
152
+ error: e.failureError,
153
+ failedIndex: e.failedIndex,
154
+ results,
155
+ });
156
+ }
157
+ return finalize({
158
+ isSuccess: false,
159
+ error: toWriteErrorInfo(wrapToKumiko(e)),
160
+ failedIndex: results.length,
161
+ results,
162
+ });
163
+ }
164
+
165
+ // Commit succeeded — fire deferred side-effects.
166
+ await flushAfterCommit();
167
+ await flushBatchHooks();
168
+ return finalize({ isSuccess: true, results });
169
+ }
170
+
171
+ // Unwrap a BatchResult into a single WriteResult for write()/command().
172
+ // Picks the first result on success (the only one for a single write), the
173
+ // failing one on failure. Falls back to a synthetic error if the batch
174
+ // didn't produce any results (unexpected).
175
+ export function unwrapSingle(batchResult: BatchResult): WriteResult {
176
+ if (batchResult.isSuccess) {
177
+ return (
178
+ batchResult.results[0] ?? writeFailure(new InternalError({ message: "empty_batch_result" }))
179
+ );
180
+ }
181
+ return (
182
+ batchResult.results[batchResult.failedIndex] ?? {
183
+ isSuccess: false,
184
+ error: batchResult.error,
185
+ }
186
+ );
187
+ }