@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,279 @@
1
+ // backfillEventPiiEncryption (#799): pre-KMS plaintext events get re-
2
+ // encrypted in place — entity lifecycle payloads (created / updated
3
+ // changes+previous / forgotten previous) AND catalogued custom events.
4
+ // Pre-KMS-forgotten subjects (detectable only via their *.forgotten event)
5
+ // get [[erased]] instead of a freshly minted key. After the backfill,
6
+ // applyEntityEvent (the rebuild primitive) materializes ciphertext AND the
7
+ // blind-index column, so equality lookups keep working.
8
+
9
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { z } from "zod";
11
+ import {
12
+ configureBlindIndexKey,
13
+ configurePiiSubjectKms,
14
+ InMemoryKmsAdapter,
15
+ isPiiCiphertext,
16
+ PII_ERASED_SENTINEL,
17
+ resetBlindIndexKeyForTests,
18
+ resetPiiSubjectKmsForTests,
19
+ } from "../../crypto";
20
+ import { applyEntityEvent } from "../../db/apply-entity-event";
21
+ import { backfillEventPiiEncryption } from "../../db/queries/backfill-pii";
22
+ import { asRawClient, fetchOne } from "../../db/query";
23
+ import { buildEntityTable } from "../../db/table-builder";
24
+ import { defineFeature } from "../../engine/define-feature";
25
+ import { createEntity, createTextField } from "../../engine/factories";
26
+ import { createRegistry } from "../../engine/registry";
27
+ import type { Registry, TenantId } from "../../engine/types";
28
+ import { createTestDb, type TestDb, unsafeCreateEntityTable } from "../../stack";
29
+ import { generateId } from "../../utils";
30
+ import { append, loadAggregate } from "../event-store";
31
+ import { createEventsTable } from "../events-schema";
32
+
33
+ const TENANT = "00000000-0000-4000-8000-000000000001" as TenantId;
34
+ const BIDX_KEY = Buffer.alloc(32, 5).toString("base64");
35
+
36
+ const contactEntity = createEntity({
37
+ fields: {
38
+ email: createTextField({ required: true, pii: true, lookupable: true }),
39
+ displayName: createTextField(),
40
+ },
41
+ });
42
+ const contactTable = buildEntityTable("contact", contactEntity);
43
+
44
+ const crmFeature = defineFeature("crm", (r) => {
45
+ r.entity("contact", contactEntity);
46
+ });
47
+
48
+ const mailerFeature = defineFeature("mailer", (r) => {
49
+ r.defineEvent(
50
+ "ping",
51
+ z.object({ targetId: z.string().nullable(), address: z.string().nullable() }),
52
+ { piiFields: { address: { subjectField: "targetId" } } },
53
+ );
54
+ });
55
+
56
+ let testDb: TestDb;
57
+ let registry: Registry;
58
+ let kms: InMemoryKmsAdapter;
59
+
60
+ async function appendPlain(
61
+ aggregateId: string,
62
+ aggregateType: string,
63
+ type: string,
64
+ payload: Record<string, unknown>,
65
+ expectedVersion = 0,
66
+ ): Promise<void> {
67
+ await append(testDb.db, {
68
+ aggregateId,
69
+ aggregateType,
70
+ tenantId: TENANT,
71
+ expectedVersion,
72
+ type,
73
+ payload,
74
+ metadata: { userId: "system" },
75
+ });
76
+ }
77
+
78
+ beforeAll(async () => {
79
+ testDb = await createTestDb();
80
+ registry = createRegistry([crmFeature, mailerFeature]);
81
+ await createEventsTable(testDb.db);
82
+ await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
83
+ });
84
+
85
+ afterAll(async () => {
86
+ await testDb.cleanup();
87
+ });
88
+
89
+ beforeEach(async () => {
90
+ const raw = asRawClient(testDb.db);
91
+ await raw.unsafe(`TRUNCATE "kumiko_events" RESTART IDENTITY`);
92
+ await raw.unsafe(`TRUNCATE "${contactTable.tableName}"`);
93
+ // Plaintext era: NO KMS while the legacy events are appended.
94
+ resetPiiSubjectKmsForTests();
95
+ resetBlindIndexKeyForTests();
96
+ });
97
+
98
+ afterEach(() => {
99
+ resetPiiSubjectKmsForTests();
100
+ resetBlindIndexKeyForTests();
101
+ });
102
+
103
+ function armKms(): void {
104
+ kms = new InMemoryKmsAdapter();
105
+ configurePiiSubjectKms(kms);
106
+ }
107
+
108
+ describe("backfillEventPiiEncryption", () => {
109
+ test("encrypts entity lifecycle payloads: created flat, updated changes+previous", async () => {
110
+ const c1 = generateId();
111
+ await appendPlain(c1, "contact", "contact.created", {
112
+ id: c1,
113
+ email: "old@x.com",
114
+ displayName: "Alice",
115
+ });
116
+ await appendPlain(
117
+ c1,
118
+ "contact",
119
+ "contact.updated",
120
+ { changes: { email: "new@x.com" }, previous: { id: c1, email: "old@x.com" } },
121
+ 1,
122
+ );
123
+
124
+ armKms();
125
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
126
+
127
+ expect(result.failures).toEqual([]);
128
+ expect(result.updatedEvents).toBe(2);
129
+ expect(result.encryptedFields).toBe(3);
130
+
131
+ const events = await loadAggregate(testDb.db, c1, TENANT);
132
+ const created = events[0]?.payload as Record<string, unknown>;
133
+ expect(isPiiCiphertext(created["email"])).toBe(true);
134
+ expect(String(created["email"])).toContain(`user:${c1}`);
135
+ expect(created["displayName"]).toBe("Alice");
136
+
137
+ const updated = events[1]?.payload as {
138
+ changes: Record<string, unknown>;
139
+ previous: Record<string, unknown>;
140
+ };
141
+ expect(isPiiCiphertext(updated.changes["email"])).toBe(true);
142
+ expect(isPiiCiphertext(updated.previous["email"])).toBe(true);
143
+ });
144
+
145
+ test("pre-KMS-forgotten aggregate gets [[erased]], not a fresh key", async () => {
146
+ const c2 = generateId();
147
+ await appendPlain(c2, "contact", "contact.created", { id: c2, email: "gone@x.com" });
148
+ await appendPlain(
149
+ c2,
150
+ "contact",
151
+ "contact.forgotten",
152
+ { previous: { id: c2, email: "gone@x.com" } },
153
+ 1,
154
+ );
155
+
156
+ armKms();
157
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
158
+
159
+ expect(result.failures).toEqual([]);
160
+ expect(result.erasedFields).toBe(2);
161
+ const events = await loadAggregate(testDb.db, c2, TENANT);
162
+ const created = events[0]?.payload as Record<string, unknown>;
163
+ expect(created["email"]).toBe(PII_ERASED_SENTINEL);
164
+ const forgotten = events[1]?.payload as { previous: Record<string, unknown> };
165
+ expect(forgotten.previous["email"]).toBe(PII_ERASED_SENTINEL);
166
+ });
167
+
168
+ test("catalogued custom events: encrypt under target's DEK; forgotten target → [[erased]]", async () => {
169
+ const forgottenUser = generateId();
170
+ await appendPlain(forgottenUser, "contact", "contact.created", {
171
+ id: forgottenUser,
172
+ email: "f@x.com",
173
+ });
174
+ await appendPlain(
175
+ forgottenUser,
176
+ "contact",
177
+ "contact.forgotten",
178
+ { previous: { id: forgottenUser, email: "f@x.com" } },
179
+ 1,
180
+ );
181
+
182
+ const p1 = generateId();
183
+ const p2 = generateId();
184
+ const p3 = generateId();
185
+ await appendPlain(p1, "ping", "mailer:event:ping", {
186
+ targetId: "u-7",
187
+ address: "u7@x.com",
188
+ });
189
+ await appendPlain(p2, "ping", "mailer:event:ping", {
190
+ targetId: forgottenUser,
191
+ address: "f@x.com",
192
+ });
193
+ await appendPlain(p3, "ping", "mailer:event:ping", { targetId: null, address: "ops@x.com" });
194
+
195
+ armKms();
196
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
197
+
198
+ expect(result.failures).toEqual([]);
199
+ const alive = (await loadAggregate(testDb.db, p1, TENANT))[0]?.payload as Record<
200
+ string,
201
+ unknown
202
+ >;
203
+ expect(isPiiCiphertext(alive["address"])).toBe(true);
204
+ expect(String(alive["address"])).toContain("user:u-7");
205
+
206
+ const erased = (await loadAggregate(testDb.db, p2, TENANT))[0]?.payload as Record<
207
+ string,
208
+ unknown
209
+ >;
210
+ expect(erased["address"]).toBe(PII_ERASED_SENTINEL);
211
+
212
+ // No subject → no key to shred; stays plaintext (documented rollout gap).
213
+ const system = (await loadAggregate(testDb.db, p3, TENANT))[0]?.payload as Record<
214
+ string,
215
+ unknown
216
+ >;
217
+ expect(system["address"]).toBe("ops@x.com");
218
+ });
219
+
220
+ test("idempotent: second run updates nothing; dryRun writes nothing", async () => {
221
+ const c1 = generateId();
222
+ await appendPlain(c1, "contact", "contact.created", { id: c1, email: "a@x.com" });
223
+
224
+ armKms();
225
+ const dry = await backfillEventPiiEncryption(testDb.db, registry, { dryRun: true });
226
+ expect(dry.updatedEvents).toBe(1);
227
+ const untouched = (await loadAggregate(testDb.db, c1, TENANT))[0]?.payload as Record<
228
+ string,
229
+ unknown
230
+ >;
231
+ expect(untouched["email"]).toBe("a@x.com");
232
+
233
+ const first = await backfillEventPiiEncryption(testDb.db, registry);
234
+ expect(first.updatedEvents).toBe(1);
235
+ const second = await backfillEventPiiEncryption(testDb.db, registry);
236
+ expect(second.updatedEvents).toBe(0);
237
+ expect(second.failures).toEqual([]);
238
+ });
239
+
240
+ test("small batchSize pages through the estate completely", async () => {
241
+ const ids = [generateId(), generateId(), generateId(), generateId(), generateId()];
242
+ for (const id of ids) {
243
+ await appendPlain(id, "contact", "contact.created", { id, email: `${id}@x.com` });
244
+ }
245
+
246
+ armKms();
247
+ const result = await backfillEventPiiEncryption(testDb.db, registry, { batchSize: 2 });
248
+ expect(result.scannedEvents).toBe(5);
249
+ expect(result.updatedEvents).toBe(5);
250
+ });
251
+
252
+ test("after backfill, applyEntityEvent (rebuild) materializes ciphertext + blind index", async () => {
253
+ const c1 = generateId();
254
+ await appendPlain(c1, "contact", "contact.created", { id: c1, email: "login@x.com" });
255
+
256
+ armKms();
257
+ configureBlindIndexKey(BIDX_KEY);
258
+ await backfillEventPiiEncryption(testDb.db, registry);
259
+
260
+ const events = await loadAggregate(testDb.db, c1, TENANT);
261
+ const created = events[0];
262
+ if (!created) throw new Error("missing created event");
263
+ await applyEntityEvent(created, contactTable, contactEntity, testDb.db);
264
+
265
+ const row = await fetchOne(testDb.db, contactTable, { id: c1 });
266
+ expect(isPiiCiphertext(row?.["email"])).toBe(true);
267
+ const rawRows = (await asRawClient(testDb.db).unsafe(
268
+ `SELECT "email_bidx" FROM "${contactTable.tableName}" WHERE "id" = $1`,
269
+ [c1],
270
+ )) as ReadonlyArray<Record<string, unknown>>;
271
+ expect(String(rawRows[0]?.["email_bidx"])).toStartWith("kumiko-bidx:v1:");
272
+ });
273
+
274
+ test("throws without a configured KMS", async () => {
275
+ expect(backfillEventPiiEncryption(testDb.db, registry)).rejects.toThrow(
276
+ /requires a configured subject KMS/,
277
+ );
278
+ });
279
+ });
@@ -6,6 +6,7 @@ import { generateId as uuid } from "../../utils";
6
6
  import {
7
7
  append,
8
8
  createEventsTable,
9
+ IdempotentAppendConflictError,
9
10
  loadAggregate,
10
11
  loadAggregateAsOf,
11
12
  loadAllEventsByType,
@@ -59,6 +60,38 @@ describe("event-store: append + load", () => {
59
60
  expect(events[0]?.metadata.userId).toBe(userA);
60
61
  });
61
62
 
63
+ test("kumiko-framework#1490: subsequent append doesn't rely on globalThis.Temporal", async () => {
64
+ const aggregateId = uuid();
65
+ const first = await append(testDb.db, {
66
+ aggregateId,
67
+ aggregateType: "task",
68
+ tenantId: tenantA,
69
+ expectedVersion: 0,
70
+ type: "task.created",
71
+ payload: { title: "Buy milk" },
72
+ metadata: { userId: userA },
73
+ });
74
+
75
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
76
+ delete (globalThis as { Temporal?: unknown }).Temporal;
77
+ try {
78
+ const second = await append(testDb.db, {
79
+ aggregateId,
80
+ aggregateType: "task",
81
+ tenantId: tenantA,
82
+ expectedVersion: first.version,
83
+ type: "task.updated",
84
+ payload: { title: "Buy oat milk" },
85
+ metadata: { userId: userA },
86
+ });
87
+ expect(second.version).toBe(2);
88
+ expect(second.createdAt).toBeDefined();
89
+ } finally {
90
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
91
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
92
+ }
93
+ });
94
+
62
95
  test("subsequent appends increment version and are ordered", async () => {
63
96
  const aggregateId = uuid();
64
97
  const base = {
@@ -93,6 +126,143 @@ describe("event-store: append + load", () => {
93
126
  });
94
127
  });
95
128
 
129
+ describe("event-store: idempotency-key conflict", () => {
130
+ test("second append with a reused idempotencyKey (new aggregate+version) throws IdempotentAppendConflictError", async () => {
131
+ const first = uuid();
132
+ const second = uuid();
133
+ const key = uuid();
134
+
135
+ await append(testDb.db, {
136
+ aggregateId: first,
137
+ aggregateType: "task",
138
+ tenantId: tenantA,
139
+ expectedVersion: 0,
140
+ type: "task.created",
141
+ payload: { title: "Orig" },
142
+ metadata: { userId: userA, idempotencyKey: key },
143
+ });
144
+
145
+ // A retried command that re-runs after the Redis idempotency guard
146
+ // missed its window: different aggregate, fresh expectedVersion=0 — the
147
+ // aggregate-version unique index has nothing to say about this pair, so
148
+ // only the idempotency-key index catches the duplicate.
149
+ await expect(
150
+ append(testDb.db, {
151
+ aggregateId: second,
152
+ aggregateType: "task",
153
+ tenantId: tenantA,
154
+ expectedVersion: 0,
155
+ type: "task.created",
156
+ payload: { title: "Retry" },
157
+ metadata: { userId: userA, idempotencyKey: key },
158
+ }),
159
+ ).rejects.toThrow(IdempotentAppendConflictError);
160
+
161
+ const events = await loadAggregate(testDb.db, second, tenantA);
162
+ expect(events).toHaveLength(0);
163
+ });
164
+
165
+ test("subsequent-event append (expectedVersion > 0) also enforces the idempotency key", async () => {
166
+ const aggregateId = uuid();
167
+ const key = uuid();
168
+
169
+ await append(testDb.db, {
170
+ aggregateId,
171
+ aggregateType: "task",
172
+ tenantId: tenantA,
173
+ expectedVersion: 0,
174
+ type: "task.created",
175
+ payload: { title: "Orig" },
176
+ metadata: { userId: userA },
177
+ });
178
+
179
+ await append(testDb.db, {
180
+ aggregateId,
181
+ aggregateType: "task",
182
+ tenantId: tenantA,
183
+ expectedVersion: 1,
184
+ type: "task.updated",
185
+ payload: { title: "V2" },
186
+ metadata: { userId: userA, idempotencyKey: key },
187
+ });
188
+
189
+ // Retry of the v2 update: goes through insertSubsequentEventRow's raw
190
+ // INSERT ... SELECT ... WHERE EXISTS path, not insertFirstEvent — the
191
+ // idempotency index must catch it there too.
192
+ await expect(
193
+ append(testDb.db, {
194
+ aggregateId,
195
+ aggregateType: "task",
196
+ tenantId: tenantA,
197
+ expectedVersion: 2,
198
+ type: "task.updated",
199
+ payload: { title: "V3-retry" },
200
+ metadata: { userId: userA, idempotencyKey: key },
201
+ }),
202
+ ).rejects.toThrow(IdempotentAppendConflictError);
203
+
204
+ const events = await loadAggregate(testDb.db, aggregateId, tenantA);
205
+ expect(events).toHaveLength(2);
206
+ });
207
+
208
+ test("same idempotencyKey on a different tenant does not conflict", async () => {
209
+ const key = uuid();
210
+
211
+ const a = await append(testDb.db, {
212
+ aggregateId: uuid(),
213
+ aggregateType: "task",
214
+ tenantId: tenantA,
215
+ expectedVersion: 0,
216
+ type: "task.created",
217
+ payload: {},
218
+ metadata: { userId: userA, idempotencyKey: key },
219
+ });
220
+ const b = await append(testDb.db, {
221
+ aggregateId: uuid(),
222
+ aggregateType: "task",
223
+ tenantId: tenantB,
224
+ expectedVersion: 0,
225
+ type: "task.created",
226
+ payload: {},
227
+ metadata: { userId: userA, idempotencyKey: key },
228
+ });
229
+
230
+ expect(a.version).toBe(1);
231
+ expect(b.version).toBe(1);
232
+ });
233
+
234
+ test("omitting idempotencyKey allows unlimited appends, unchanged from before", async () => {
235
+ // Same tenant for both, unlike the different-aggregateId version this
236
+ // replaces — Postgres treats every NULL as distinct even in a plain
237
+ // unique index, so this does NOT exercise the partial index's `WHERE
238
+ // ... IS NOT NULL` clause specifically (that's provable only by a
239
+ // duplicate NON-null key, covered above). What this does pin: a fully-
240
+ // omitted key and an explicit `idempotencyKey: undefined` both serialize
241
+ // to a JSON-absent key (JSON.stringify drops undefined) and must behave
242
+ // identically, rather than one silently colliding on `"idempotencyKey":null`.
243
+ const omitted = await append(testDb.db, {
244
+ aggregateId: uuid(),
245
+ aggregateType: "task",
246
+ tenantId: tenantA,
247
+ expectedVersion: 0,
248
+ type: "task.created",
249
+ payload: {},
250
+ metadata: { userId: userA },
251
+ });
252
+ const explicitUndefined = await append(testDb.db, {
253
+ aggregateId: uuid(),
254
+ aggregateType: "task",
255
+ tenantId: tenantA,
256
+ expectedVersion: 0,
257
+ type: "task.created",
258
+ payload: {},
259
+ metadata: { userId: userA, idempotencyKey: undefined },
260
+ });
261
+ expect(omitted.version).toBe(1);
262
+ expect(explicitUndefined.version).toBe(1);
263
+ });
264
+ });
265
+
96
266
  describe("event-store: optimistic concurrency", () => {
97
267
  test("wrong expectedVersion throws VersionConflictError (no write)", async () => {
98
268
  const aggregateId = uuid();
@@ -456,6 +626,37 @@ describe("event-store: loadAllEventsByType", () => {
456
626
  expect(all.map((e) => e.version)).toEqual([1, 2, 3, 4]);
457
627
  expect(all.map((e) => (e.payload as { v: number }).v)).toEqual([0, 1, 2, 3]);
458
628
  });
629
+
630
+ test("throws once rows exceed the given rowLimit", async () => {
631
+ for (let v = 0; v < 3; v++) {
632
+ await append(testDb.db, {
633
+ aggregateId: uuid(),
634
+ aggregateType: "task",
635
+ tenantId: tenantA,
636
+ expectedVersion: 0,
637
+ type: "task.created",
638
+ payload: { v },
639
+ metadata: { userId: userA },
640
+ });
641
+ }
642
+ await expect(loadAllEventsByType(testDb.db, "task", 2)).rejects.toThrow(/exceeds 2 rows/);
643
+ });
644
+
645
+ test("does not throw when rows equal the given rowLimit", async () => {
646
+ for (let v = 0; v < 2; v++) {
647
+ await append(testDb.db, {
648
+ aggregateId: uuid(),
649
+ aggregateType: "task",
650
+ tenantId: tenantA,
651
+ expectedVersion: 0,
652
+ type: "task.created",
653
+ payload: { v },
654
+ metadata: { userId: userA },
655
+ });
656
+ }
657
+ const all = await loadAllEventsByType(testDb.db, "task", 2);
658
+ expect(all).toHaveLength(2);
659
+ });
459
660
  });
460
661
 
461
662
  describe("event-store: streamAllEventsByType (memory-bounded iteration)", () => {
@@ -48,8 +48,8 @@ describe("toStoredEvent", () => {
48
48
  });
49
49
 
50
50
  test("Feld-Vollständigkeit: das Mapping deckt genau die StoredEvent-Keys ab", () => {
51
- // Pin gegen versehentliches Weglassen eines Feldes im Mapping. Muss mit
52
- // der StoredEvent-Definition (event-store.ts) synchron bleiben.
51
+ // Guards required fields staying in sync with StoredEvent (event-store.ts);
52
+ // optional fields consistently omitted on both sides must be added manually.
53
53
  const expectedKeys: ReadonlyArray<keyof StoredEvent> = [
54
54
  "id",
55
55
  "aggregateId",
@@ -242,3 +242,89 @@ describe("snapshot-store — loadAggregateWithSnapshot", () => {
242
242
  expect(archived.state.count).toBe(10);
243
243
  });
244
244
  });
245
+
246
+ describe("snapshot-store — auto-snapshot policy (snapshotEvery)", () => {
247
+ test("first load persists a snapshot at the folded version; second load hits it", async () => {
248
+ const aggId = await seedAggregate(250);
249
+
250
+ const first = await loadAggregateWithSnapshot<CounterState>(
251
+ bun.db,
252
+ aggId,
253
+ tenant,
254
+ reducer,
255
+ initial,
256
+ { snapshotEvery: 100 },
257
+ );
258
+ expect(first.snapshotHit).toBe(false);
259
+ expect(first.version).toBe(250);
260
+ expect(first.state).toEqual(await loadFullState(aggId));
261
+
262
+ const stored = await loadLatestSnapshot<CounterState>(bun.db, aggId, tenant);
263
+ expect(stored?.version).toBe(250);
264
+ expect(stored?.snapshotVersion).toBe(1);
265
+ expect(stored?.aggregateType).toBe("counter");
266
+
267
+ const second = await loadAggregateWithSnapshot<CounterState>(
268
+ bun.db,
269
+ aggId,
270
+ tenant,
271
+ reducer,
272
+ initial,
273
+ { snapshotEvery: 100 },
274
+ );
275
+ expect(second.snapshotHit).toBe(true);
276
+ expect(second.state).toEqual(first.state);
277
+ expect(second.version).toBe(250);
278
+ });
279
+
280
+ test("fewer folded events than snapshotEvery → no snapshot written", async () => {
281
+ const aggId = await seedAggregate(10);
282
+ await loadAggregateWithSnapshot<CounterState>(bun.db, aggId, tenant, reducer, initial, {
283
+ snapshotEvery: 100,
284
+ });
285
+ expect(await loadLatestSnapshot(bun.db, aggId, tenant)).toBeNull();
286
+ });
287
+
288
+ test("snapshotVersion bump ignores the stored snapshot and restamps on auto-save", async () => {
289
+ const aggId = await seedAggregate(120);
290
+ await loadAggregateWithSnapshot<CounterState>(bun.db, aggId, tenant, reducer, initial, {
291
+ snapshotEvery: 100,
292
+ });
293
+
294
+ // Shape bump: the generation-1 snapshot must be ignored (full replay),
295
+ // and the auto-save restamps generation 2 at the same event version.
296
+ const bumped = await loadAggregateWithSnapshot<CounterState>(
297
+ bun.db,
298
+ aggId,
299
+ tenant,
300
+ reducer,
301
+ initial,
302
+ { snapshotEvery: 100, snapshotVersion: 2 },
303
+ );
304
+ expect(bumped.snapshotHit).toBe(false);
305
+ expect(bumped.state).toEqual(await loadFullState(aggId));
306
+
307
+ const restamped = await loadLatestSnapshot<CounterState>(bun.db, aggId, tenant);
308
+ expect(restamped?.snapshotVersion).toBe(2);
309
+ expect(restamped?.version).toBe(120);
310
+
311
+ const third = await loadAggregateWithSnapshot<CounterState>(
312
+ bun.db,
313
+ aggId,
314
+ tenant,
315
+ reducer,
316
+ initial,
317
+ { snapshotEvery: 100, snapshotVersion: 2 },
318
+ );
319
+ expect(third.snapshotHit).toBe(true);
320
+ });
321
+
322
+ test("invalid snapshotEvery fails loud", async () => {
323
+ const aggId = await seedAggregate(1);
324
+ await expect(
325
+ loadAggregateWithSnapshot<CounterState>(bun.db, aggId, tenant, reducer, initial, {
326
+ snapshotEvery: 0,
327
+ }),
328
+ ).rejects.toThrow(/snapshotEvery/);
329
+ });
330
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Glob } from "bun";
3
+
4
+ // getUnscopedAggregateStreamMaxVersion has no tenant filter — a caller can use
5
+ // it to probe whether a foreign tenant's aggregate exists (see event-store.ts
6
+ // SECURITY doc). Restricted to known seed/system-internal callers; extend
7
+ // only for genuine new ones.
8
+ const RESTRICTED_SYMBOLS = ["getUnscopedAggregateStreamMaxVersion"];
9
+
10
+ const ALLOWED_FILES = new Set([
11
+ "packages/framework/src/event-store/event-store.ts",
12
+ "packages/framework/src/event-store/index.ts",
13
+ "packages/bundled-features/src/tenant/seeding.ts",
14
+ "packages/bundled-features/src/tier-engine/feature.ts",
15
+ "packages/framework/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts",
16
+ ]);
17
+
18
+ const REPO_ROOT = `${import.meta.dir}/../../../../..`;
19
+
20
+ describe("unscoped stream primitives — caller allowlist", () => {
21
+ test("only seed/system-internal paths reference the existence-oracle primitives", async () => {
22
+ // Widened from {framework,bundled-features} — the primitives are exported
23
+ // from the public @cosmicdrift/kumiko-framework/event-store barrel, so any
24
+ // package (server-runtime, dispatcher-live, headless, renderer-web, cli, ...)
25
+ // can import and call them; the allowlist must cover all of them, not just
26
+ // the two packages that happened to have callers when this guard was written.
27
+ const glob = new Glob("packages/*/src/**/*.ts");
28
+ const matches = new Set<string>();
29
+ for await (const relPath of glob.scan({ cwd: REPO_ROOT })) {
30
+ const content = await Bun.file(`${REPO_ROOT}/${relPath}`).text();
31
+ // Strip full-line `//` comments before scanning — a bare
32
+ // `content.includes(symbol)` also matches a symbol only mentioned in
33
+ // prose (e.g. explaining why a helper isn't needed), which would force
34
+ // an unrelated file into the allowlist for a reference that isn't an
35
+ // actual import or call.
36
+ const code = content
37
+ .split("\n")
38
+ .filter((line) => !line.trim().startsWith("//"))
39
+ .join("\n");
40
+ const referencesSymbol = (symbol: string): boolean =>
41
+ new RegExp(`\\b${symbol}\\s*\\(`).test(code) ||
42
+ // Covers both `import { symbol } from "..."` and a re-export barrel
43
+ // (`export { symbol } from "./event-store"`) — a new barrel that
44
+ // surfaces the oracle primitive must widen the allowlist too.
45
+ new RegExp(`\\b(import|export)\\b[^;]*\\b${symbol}\\b`).test(code);
46
+ if (RESTRICTED_SYMBOLS.some(referencesSymbol)) {
47
+ matches.add(relPath);
48
+ }
49
+ }
50
+
51
+ // Positive control — proves the scan actually ran and found the known
52
+ // caller, not just that it (silently) found nothing.
53
+ expect(matches.has("packages/bundled-features/src/tenant/seeding.ts")).toBe(true);
54
+
55
+ const offenders = [...matches].filter((relPath) => !ALLOWED_FILES.has(relPath));
56
+ expect(offenders).toEqual([]);
57
+ });
58
+ });