@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
@@ -17,9 +17,10 @@ import { z } from "zod";
17
17
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
18
18
  import { createRegistry, defineFeature } from "../../engine";
19
19
  import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
20
+ import { createNoopProvider, createPrometheusMeter } from "../../observability";
20
21
  import { createEventConsumerStateTable } from "../../pipeline";
21
22
  import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
22
- import { waitFor } from "../../testing";
23
+ import { sleep, waitFor } from "../../testing";
23
24
  import { createAllInOneEntrypoint } from "../index";
24
25
 
25
26
  const jobRuns: Array<{ name: string; payload: Record<string, unknown> }> = [];
@@ -172,3 +173,121 @@ describe("createAllInOneEntrypoint auto-wires jobRunner into command-dispatcher"
172
173
  }
173
174
  });
174
175
  });
176
+
177
+ // Regression test for #1046 — createJobRunner is built from the caller's
178
+ // RAW context, BEFORE buildServer ever merges observability.tracer/meter
179
+ // into a context object of its own. Without threading the resolved
180
+ // provider into the job-runner's context too, `context.meter` stayed
181
+ // undefined, the queue-depth poller's `if (context.meter)` guard skipped
182
+ // silently, and `/metrics` showed the kumiko_job_queue_depth HELP/TYPE
183
+ // header but never a single data line — forever, with no error anywhere.
184
+ describe("createAllInOneEntrypoint — kumiko_job_queue_depth sees the SAME meter as buildServer (#1046)", () => {
185
+ test("passing an explicit observability provider makes the queue-depth poller emit real data for BOTH lanes", async () => {
186
+ const registry = createRegistry([wiringFeature]);
187
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
188
+ const meter = createPrometheusMeter();
189
+ const observability = { ...createNoopProvider(), meter };
190
+ const entry = createAllInOneEntrypoint({
191
+ registry,
192
+ context: { db: testDb.db, redis: testRedis.redis },
193
+ jwtSecret: JWT,
194
+ redisUrl,
195
+ queueNamePrefix: `wiring-qd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
196
+ observability,
197
+ });
198
+ await entry.start();
199
+ try {
200
+ // If the job-runner never saw this meter (the #1046 bug), the poller's
201
+ // `if (context.meter)` guard skips entirely and no slots ever appear —
202
+ // this assertion is the one that would have caught it.
203
+ const snapshot = meter.snapshot().get("kumiko_job_queue_depth");
204
+ expect(snapshot).toBeDefined();
205
+ const workerWaiting = snapshot?.slots.find(
206
+ (s) => s.labels?.["lane"] === "worker" && s.labels?.["state"] === "waiting",
207
+ );
208
+ const apiWaiting = snapshot?.slots.find(
209
+ (s) => s.labels?.["lane"] === "api" && s.labels?.["state"] === "waiting",
210
+ );
211
+ expect(workerWaiting).toBeDefined();
212
+ expect(apiWaiting).toBeDefined();
213
+ // Same instance, not a coincidentally-equal one — proves buildServer's
214
+ // internal registerStandardMetrics() and the job-runner's poller wrote
215
+ // into the identical meter, not two disconnected Noop instances.
216
+ expect(entry.observability.meter).toBe(meter);
217
+ // BullMQ's fresh Worker connections are still settling right after
218
+ // start() returns — stopping immediately races their own connection
219
+ // teardown and throws an unrelated "Connection is closed" from
220
+ // ioredis (same artifact documented in job-queue-depth.integration.
221
+ // test.ts). Production runners live far longer than this.
222
+ await sleep(50);
223
+ } finally {
224
+ await entry.stop();
225
+ }
226
+ });
227
+ });
228
+
229
+ // Regression test for #1253 — effectiveFeatures was wired into
230
+ // dispatcherOptions (command-dispatcher's feature-gate) but never merged
231
+ // into the job-runner's context. A job handler reading
232
+ // `context.effectiveFeatures?.(tenantId)` always saw undefined in prod.
233
+ const featuresSeenByJob: Array<ReadonlySet<string> | undefined> = [];
234
+
235
+ const featureGateJobFeature = defineFeature("featuregate", (r) => {
236
+ r.writeHandler(
237
+ "ping",
238
+ z.object({ msg: z.string() }),
239
+ async (event) => ({
240
+ isSuccess: true as const,
241
+ data: { id: 1, msg: event.payload.msg },
242
+ }),
243
+ { access: { openToAll: true } },
244
+ );
245
+ r.job(
246
+ "record-features",
247
+ { trigger: { on: "featuregate:write:ping" }, runIn: "worker" },
248
+ async (_payload, context) => {
249
+ featuresSeenByJob.push(context.effectiveFeatures?.(adminUser.tenantId));
250
+ },
251
+ );
252
+ });
253
+
254
+ describe("createAllInOneEntrypoint — job context sees dispatcherOptions.effectiveFeatures (#1253)", () => {
255
+ test("job handler reads the same effectiveFeatures resolver as the command-dispatcher's feature-gate", async () => {
256
+ featuresSeenByJob.length = 0;
257
+ const registry = createRegistry([featureGateJobFeature]);
258
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
259
+ const entry = createAllInOneEntrypoint({
260
+ registry,
261
+ context: { db: testDb.db, redis: testRedis.redis },
262
+ jwtSecret: JWT,
263
+ redisUrl,
264
+ queueNamePrefix: `wiring-features-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
265
+ dispatcherOptions: {
266
+ effectiveFeatures: () => new Set(["featuregate", "some-feature"]),
267
+ },
268
+ });
269
+ await entry.start();
270
+ try {
271
+ const token = await entry.jwt.sign(adminUser);
272
+ const res = await entry.app.request("/api/write", {
273
+ method: "POST",
274
+ headers: {
275
+ "Content-Type": "application/json",
276
+ Authorization: `Bearer ${token}`,
277
+ },
278
+ body: JSON.stringify({ type: "featuregate:write:ping", payload: { msg: "hi" } }),
279
+ });
280
+ const result = (await res.json()) as { isSuccess: boolean };
281
+ expect(result.isSuccess).toBe(true);
282
+
283
+ // Without the fix, context.effectiveFeatures is undefined and this
284
+ // stays empty forever — waitFor times out on the missing entry.
285
+ await waitFor(() => {
286
+ expect(featuresSeenByJob.length).toBeGreaterThan(0);
287
+ expect(featuresSeenByJob[0]?.has("some-feature")).toBe(true);
288
+ });
289
+ } finally {
290
+ await entry.stop();
291
+ }
292
+ });
293
+ });
@@ -32,17 +32,19 @@
32
32
 
33
33
  import type { Hono } from "hono";
34
34
  import type { AuthRoutesConfig } from "../api/auth-routes";
35
- import type { JwtHelper } from "../api/jwt";
35
+ import type { JwtHelper, JwtKeyring } from "../api/jwt";
36
36
  import type { KumikoServer, ServerOptions } from "../api/server";
37
37
  import { buildServer } from "../api/server";
38
38
  import type { SseBroker } from "../api/sse-broker";
39
39
  import type { PgClient } from "../db/connection";
40
+ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
40
41
  import type { AppContext, JobRunIn, Registry, RunIn } from "../engine/types";
41
42
  import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner";
42
43
  import { createJobRunner } from "../jobs/job-runner";
43
44
  import type { Lifecycle } from "../lifecycle";
44
45
  import { createLifecycle } from "../lifecycle";
45
46
  import type { ObservabilityOptions, ObservabilityProvider } from "../observability";
47
+ import { createNoopProvider } from "../observability";
46
48
  import type { EventDedup, EventDispatcher } from "../pipeline";
47
49
  import type { Dispatcher, DispatcherOptions } from "../pipeline/dispatcher";
48
50
  import type { SystemHooks } from "../pipeline/lifecycle-pipeline";
@@ -52,7 +54,7 @@ import type { SystemHooks } from "../pipeline/lifecycle-pipeline";
52
54
  export type BaseEntrypointOptions = {
53
55
  readonly registry: Registry;
54
56
  readonly context: AppContext;
55
- readonly jwtSecret: string;
57
+ readonly jwtSecret: string | JwtKeyring;
56
58
  readonly jwtIssuer?: string;
57
59
  readonly observability?: ObservabilityProvider;
58
60
  readonly observabilityOptions?: ObservabilityOptions;
@@ -175,6 +177,31 @@ function mergeDispatcherOptions(
175
177
  return { ...(caller ?? {}), jobRunner };
176
178
  }
177
179
 
180
+ // Resolved once for the job-runner's context only — buildServer still gets
181
+ // the caller's original `options.observability` (unresolved) since it
182
+ // derives `shouldWrapRedis` from whether that was explicitly set.
183
+ function resolveObservability(
184
+ observability: ObservabilityProvider | undefined,
185
+ ): ObservabilityProvider {
186
+ return observability ?? createNoopProvider();
187
+ }
188
+
189
+ function contextWithObservability(
190
+ context: AppContext,
191
+ observability: ObservabilityProvider,
192
+ effectiveFeatures: EffectiveFeaturesResolver | undefined,
193
+ ): AppContext {
194
+ return {
195
+ ...context,
196
+ // Same source of truth as buildServer's contextWithObservability
197
+ // (api/server.ts) — job handlers reading ctx.effectiveFeatures should
198
+ // see the same resolver as command handlers, not just dispatcherOptions.
199
+ ...(effectiveFeatures && { effectiveFeatures }),
200
+ tracer: observability.tracer,
201
+ meter: observability.meter,
202
+ };
203
+ }
204
+
178
205
  // buildApiServer shapes ServerOptions from API-mode caller-options.
179
206
  // AllInOneEntrypointOptions extends ApiEntrypointOptions, so structural
180
207
  // subtyping makes the all-in-one path a valid caller without an explicit
@@ -291,6 +318,7 @@ function requireDispatcher(server: KumikoServer, mode: string): EventDispatcher
291
318
 
292
319
  export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoint {
293
320
  const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
321
+ const observability = resolveObservability(options.observability);
294
322
 
295
323
  // Boot-validation (Welle 2.6.c) — fail loud before traffic arrives:
296
324
  // (a) Any jobs declared + no jobs-block → command-dispatcher would
@@ -324,7 +352,11 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
324
352
  const apiJobRunner = options.jobs
325
353
  ? buildJobRunnerWithHook(
326
354
  options.registry,
327
- options.context,
355
+ contextWithObservability(
356
+ options.context,
357
+ observability,
358
+ options.dispatcherOptions?.effectiveFeatures,
359
+ ),
328
360
  options.jobs,
329
361
  options.jobs.runLocalJobs ? "api" : undefined,
330
362
  lifecycle,
@@ -375,9 +407,14 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
375
407
 
376
408
  export function createWorkerEntrypoint(options: WorkerEntrypointOptions): WorkerEntrypoint {
377
409
  const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
410
+ const observability = resolveObservability(options.observability);
378
411
  const jobRunner = buildJobRunnerWithHook(
379
412
  options.registry,
380
- options.context,
413
+ contextWithObservability(
414
+ options.context,
415
+ observability,
416
+ options.dispatcherOptions?.effectiveFeatures,
417
+ ),
381
418
  options,
382
419
  "worker",
383
420
  lifecycle,
@@ -406,6 +443,12 @@ export function createWorkerEntrypoint(options: WorkerEntrypointOptions): Worker
406
443
 
407
444
  export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): AllInOneEntrypoint {
408
445
  const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
446
+ const observability = resolveObservability(options.observability);
447
+ const jobRunnerContext = contextWithObservability(
448
+ options.context,
449
+ observability,
450
+ options.dispatcherOptions?.effectiveFeatures,
451
+ );
409
452
 
410
453
  // All-in-one consumes BOTH lanes: two runners, each with a BullMQ worker
411
454
  // for its own lane's queue. Both runners hold queue-clients for both
@@ -416,7 +459,7 @@ export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): Al
416
459
  // its own lane in its own .start().
417
460
  const workerJobRunner = buildJobRunnerWithHook(
418
461
  options.registry,
419
- options.context,
462
+ jobRunnerContext,
420
463
  options,
421
464
  "worker",
422
465
  lifecycle,
@@ -424,7 +467,7 @@ export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): Al
424
467
  );
425
468
  const apiJobRunner = buildJobRunnerWithHook(
426
469
  options.registry,
427
- options.context,
470
+ jobRunnerContext,
428
471
  options,
429
472
  "api",
430
473
  lifecycle,
@@ -2,7 +2,7 @@ import { toSnakeCase } from "../utils/case";
2
2
  import type { FieldIssue } from "./field-issue";
3
3
  import { type ErrorOpts, KumikoError } from "./kumiko-error";
4
4
 
5
- export type { FieldIssue, ValidationFieldIssue } from "./field-issue";
5
+ export type { FieldIssue } from "./field-issue";
6
6
 
7
7
  export type ValidationDetails = {
8
8
  readonly fields: readonly FieldIssue[];
@@ -138,6 +138,27 @@ export class VersionConflictError extends ConflictError {
138
138
  }
139
139
  }
140
140
 
141
+ // The caller retried a write with an idempotencyKey it had already used —
142
+ // the event-store's partial unique index on metadata.idempotencyKey caught
143
+ // the duplicate. Distinct from unique_violation/version_conflict: this is
144
+ // not an error the caller should retry, it means the earlier attempt
145
+ // already succeeded and this one is a no-op replay.
146
+ export class IdempotentReplayError extends ConflictError {
147
+ override readonly code: string = "idempotent_replay";
148
+
149
+ constructor(
150
+ details: { readonly idempotencyKey: string },
151
+ opts?: Pick<ErrorOpts, "i18nKey" | "cause">,
152
+ ) {
153
+ super({
154
+ message: `event with idempotencyKey "${details.idempotencyKey}" was already appended`,
155
+ i18nKey: opts?.i18nKey ?? "errors.idempotentReplay",
156
+ details,
157
+ ...(opts?.cause && { cause: opts.cause }),
158
+ });
159
+ }
160
+ }
161
+
141
162
  // Entity-level unique-index violation. Distinct from VersionConflictError:
142
163
  // version_conflict means "two writers raced on the events_aggregate_version
143
164
  // _uq index" (optimistic-concurrency); unique_violation means "you tried to
@@ -6,6 +6,3 @@ export type FieldIssue = {
6
6
  readonly i18nKey: string;
7
7
  readonly params?: Readonly<Record<string, unknown>>;
8
8
  };
9
-
10
- /** @deprecated Use `FieldIssue` — kept for existing imports. */
11
- export type ValidationFieldIssue = FieldIssue;
@@ -7,13 +7,13 @@ export type {
7
7
  UniqueViolationDetails,
8
8
  UnprocessableOpts,
9
9
  ValidationDetails,
10
- ValidationFieldIssue,
11
10
  VersionConflictDetails,
12
11
  } from "./classes";
13
12
  export {
14
13
  AccessDeniedError,
15
14
  ConflictError,
16
15
  FeatureDisabledError,
16
+ IdempotentReplayError,
17
17
  InternalError,
18
18
  NotFoundError,
19
19
  RateLimitError,
@@ -1,30 +1,18 @@
1
+ import type {
2
+ WriteErrorInfo,
3
+ WriteFailure,
4
+ } from "@cosmicdrift/kumiko-types/write-error-info-types";
1
5
  import { NotFoundError, UnprocessableError } from "./classes";
2
6
  import { KumikoError } from "./kumiko-error";
3
7
  import { FrameworkReasons } from "./reasons";
4
8
  import { buildInvalidTransitionDetails } from "./transition-details";
5
9
 
6
- // Plain, JSON-serializable snapshot of a KumikoError for use on the write-path
7
- // (WriteResult.error, BatchResult.error). The dispatcher stores results under
8
- // an idempotency key — a KumikoError instance wouldn't round-trip through
9
- // JSON, so we keep structural data only and rebuild the instance on demand
10
- // via reraiseAsKumikoError when we need to throw upstream again.
11
- export type WriteErrorInfo = {
12
- readonly code: string;
13
- readonly httpStatus: number;
14
- readonly i18nKey: string;
15
- readonly i18nParams?: Readonly<Record<string, unknown>>;
16
- readonly message: string;
17
- readonly details?: unknown;
18
- };
19
-
20
- // The failure half of WriteResult — `{ isSuccess: false } + error`. Named
21
- // so the three write-failure factories below and WriteResult share one
22
- // shape instead of restating it. Not generic: the error carries zero data,
23
- // so there's nothing for the caller to narrow.
24
- export type WriteFailure = {
25
- readonly isSuccess: false;
26
- readonly error: WriteErrorInfo;
27
- };
10
+ // Re-exported so a caller importing this module directly (instead of via
11
+ // the `./errors` barrel, which already re-exports these by name) still
12
+ // gets the typesexplicit, not a wildcard: this module isn't a public
13
+ // subpath export (see package.json `exports`), so the only readers are
14
+ // framework-internal.
15
+ export type { WriteErrorInfo, WriteFailure };
28
16
 
29
17
  // Convenience for call sites that return a failed WriteResult. Keeps the
30
18
  // pattern `return writeFailure(new NotFoundError(...))` compact so handlers
@@ -7,7 +7,7 @@ ES-Operations für Kumiko-Apps. Phase 1+1.5 liefert `seed-migrations` als file-b
7
7
  ## Quick API
8
8
 
9
9
  ```ts
10
- import { runProdApp } from "@cosmicdrift/kumiko-dev-server";
10
+ import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
11
11
 
12
12
  await runProdApp({
13
13
  features: [...],
@@ -72,6 +72,14 @@ const seedTestFeature = defineFeature("esopstest", (r) => {
72
72
  },
73
73
  { access: { openToAll: true } },
74
74
  );
75
+ // Role-gated handler (no openToAll): the bare system actor has no bypass
76
+ // for explicit role gates — only systemWriteAs' extraRoles reaches it.
77
+ r.writeHandler(
78
+ "probe:admin-only",
79
+ z.object({ label: z.string().min(1) }),
80
+ async (event, ctx) => probeExecutor.create(event.payload, event.user, ctx.db),
81
+ { access: { roles: ["SystemAdmin"] } },
82
+ );
75
83
  });
76
84
 
77
85
  let testDb: TestDb;
@@ -233,6 +241,72 @@ describe("runPendingSeedMigrations (integration)", () => {
233
241
  }
234
242
  });
235
243
 
244
+ test("systemWriteAs ohne extraRoles → access_denied gegen rollen-gated Handler, mit extraRoles → durch", async () => {
245
+ const denyDir = makeTempSeedsDir([
246
+ {
247
+ name: "2026-05-20-denied.ts",
248
+ content: `
249
+ export default {
250
+ description: "no extraRoles against a role-gated handler",
251
+ run: async (ctx) => {
252
+ await ctx.systemWriteAs("esopstest:write:probe:admin-only", { label: "denied" });
253
+ },
254
+ };
255
+ `,
256
+ },
257
+ ]);
258
+ try {
259
+ await expect(
260
+ runPendingSeedMigrations({
261
+ db: testDb.db,
262
+ seedsDir: denyDir,
263
+ appliedBy: "boot",
264
+ registry,
265
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
266
+ logger: () => {},
267
+ }),
268
+ ).rejects.toThrow(/access_denied/);
269
+ expect(await selectEvents()).toHaveLength(0);
270
+ } finally {
271
+ rmSync(denyDir, { recursive: true, force: true });
272
+ }
273
+
274
+ const allowDir = makeTempSeedsDir([
275
+ {
276
+ name: "2026-05-20-allowed.ts",
277
+ content: `
278
+ export default {
279
+ description: "extraRoles reaches a role-gated handler",
280
+ run: async (ctx) => {
281
+ await ctx.systemWriteAs(
282
+ "esopstest:write:probe:admin-only",
283
+ { label: "allowed" },
284
+ undefined,
285
+ ["SystemAdmin"],
286
+ );
287
+ },
288
+ };
289
+ `,
290
+ },
291
+ ]);
292
+ try {
293
+ await runPendingSeedMigrations({
294
+ db: testDb.db,
295
+ seedsDir: allowDir,
296
+ appliedBy: "boot",
297
+ registry,
298
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
299
+ logger: () => {},
300
+ });
301
+ const events = await selectEvents();
302
+ expect(events).toHaveLength(1);
303
+ expect(events[0]?.type).toBe("esops-probe.created");
304
+ expect(events[0]?.tenant_id).toBe(SYSTEM_TENANT_ID);
305
+ } finally {
306
+ rmSync(allowDir, { recursive: true, force: true });
307
+ }
308
+ });
309
+
236
310
  test("WriteResult{isSuccess:false} → throw + Marker NICHT geschrieben", async () => {
237
311
  // Critical: ohne diese Garantie würde ein silent-failed Write den Seed
238
312
  // als "applied" markieren → beim nächsten Boot kein retry → DB-Drift
@@ -42,13 +42,15 @@ export function createSeedMigrationContext(
42
42
  const defaultSystemUser = createSystemUser(SYSTEM_TENANT_ID);
43
43
 
44
44
  return {
45
- systemWriteAs: async (handlerQualifiedName, payload, tenantIdOverride) => {
45
+ systemWriteAs: async (handlerQualifiedName, payload, tenantIdOverride, extraRoles) => {
46
46
  // tenantIdOverride: baut einen System-User mit der Stream-tenantId
47
47
  // damit der Event-Store-Executor das Aggregate im richtigen Stream
48
48
  // findet. Verhindert die version_conflict-Falle (siehe Memory
49
49
  // feedback_event_store_tenant_consistency.md).
50
50
  const executor =
51
- tenantIdOverride !== undefined ? createSystemUser(tenantIdOverride) : defaultSystemUser;
51
+ tenantIdOverride !== undefined || (extraRoles && extraRoles.length > 0)
52
+ ? createSystemUser(tenantIdOverride ?? SYSTEM_TENANT_ID, extraRoles)
53
+ : defaultSystemUser;
52
54
  const result = await args.dispatcher.write(handlerQualifiedName, payload, executor);
53
55
  // Critical: WriteResult{isSuccess: false} würde sonst silent durchlaufen
54
56
  // → Marker landet trotz failed-Write → Migration falsch als "applied"
@@ -95,11 +95,18 @@ export type SeedMigrationContext = {
95
95
  * tenantIdOverride weglassen (Default SYSTEM_TENANT_ID).
96
96
  * - Tenant-scope-Aggregate (memberships, tenant-config, app-data) →
97
97
  * `tenantIdOverride: m.tenantId` (oder den Stream-Tenant aus
98
- * einem find*-Helper). */
98
+ * einem find*-Helper).
99
+ *
100
+ * **extraRoles:** hasAccess has no system-bypass — a handler with an
101
+ * explicit role gate (e.g. `access: { roles: ["SystemAdmin"] }` or
102
+ * `["anonymous"]`) otherwise rejects the bare `system` actor with
103
+ * `access_denied`. Pass the required role(s) here — see
104
+ * `createSystemUser`'s `extraRoles` doc. */
99
105
  readonly systemWriteAs: (
100
106
  handlerQualifiedName: string,
101
107
  payload: unknown,
102
108
  tenantIdOverride?: TenantId,
109
+ extraRoles?: readonly string[],
103
110
  ) => Promise<WriteResult>;
104
111
 
105
112
  // Read-helpers für die häufigsten Lookups. Wachsen on-demand —
@@ -15,7 +15,7 @@ import { asRawClient, selectMany } from "../../db/query";
15
15
  import { createTestDb, type TestDb } from "../../stack";
16
16
  import { generateId as uuid } from "../../utils";
17
17
  import { appendRaw, appendRawBatch, type RawEventToAppend } from "../admin-api";
18
- import { VersionConflictError } from "../errors";
18
+ import { IdempotentAppendConflictError, VersionConflictError } from "../errors";
19
19
  import { append, loadAggregate } from "../event-store";
20
20
  import { createEventsTable, eventsTable } from "../events-schema";
21
21
 
@@ -187,6 +187,32 @@ describe("appendRaw — single event", () => {
187
187
  ).rejects.toBeInstanceOf(VersionConflictError);
188
188
  });
189
189
 
190
+ test("appendRaw distinguishes an idempotency-key replay from a version conflict (#1499)", async () => {
191
+ const idempotencyKey = uuid();
192
+ await appendRaw(
193
+ testDb.db,
194
+ makeEvent({
195
+ aggregateId: uuid(),
196
+ expectedVersion: 0,
197
+ metadata: { userId: userMigration, idempotencyKey },
198
+ }),
199
+ );
200
+
201
+ // A second, different aggregate reusing the same idempotencyKey hits
202
+ // the tenant-scoped partial unique index — distinct from a version
203
+ // race on the same aggregate, and must not be reported as one.
204
+ await expect(
205
+ appendRaw(
206
+ testDb.db,
207
+ makeEvent({
208
+ aggregateId: uuid(),
209
+ expectedVersion: 0,
210
+ metadata: { userId: userMigration, idempotencyKey },
211
+ }),
212
+ ),
213
+ ).rejects.toBeInstanceOf(IdempotentAppendConflictError);
214
+ });
215
+
190
216
  test("version_conflict on missing predecessor (appendRaw v=5 without v=1..4)", async () => {
191
217
  const aggregateId = uuid();
192
218
  // Try to write version=5 (expectedVersion=4) against an empty stream.