@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
@@ -1,7 +1,8 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { InMemoryKmsAdapter, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL } from "../../crypto";
2
3
  import { asRawClient } from "../../db/query";
3
4
  import { createBooleanField, createEntity, createTextField } from "../../engine";
4
- import { append, createEventsTable } from "../../event-store";
5
+ import { append, createEventsTable, loadEventsAfterVersion } from "../../event-store";
5
6
  import type { EntityCache } from "../../pipeline/entity-cache";
6
7
  import {
7
8
  createTestDb,
@@ -10,7 +11,8 @@ import {
10
11
  testTenantId,
11
12
  unsafeCreateEntityTable,
12
13
  } from "../../stack";
13
- import { createEncryptionProvider } from "../encryption";
14
+ import { createTestEnvelopeCipher } from "../../testing";
15
+ import { applyEntityEvent } from "../apply-entity-event";
14
16
  import { resetEntityFieldEncryptionCacheForTests } from "../entity-field-encryption";
15
17
  import { createEventStoreExecutor } from "../event-store-executor";
16
18
  import { buildEntityTable } from "../table-builder";
@@ -122,17 +124,18 @@ describe("event-store-executor", () => {
122
124
  });
123
125
  });
124
126
 
125
- // Sensitive-field stripping: passwords/tokens/IBANs stay in the entity row
126
- // but MUST NOT land in the immutable event log (GDPR right-to-be-forgotten,
127
- // secrets-rotation, audit discoverability). Fields marked `sensitive: true`
128
- // are excluded from every event payload: create data, update changes,
129
- // update previous, delete previous, restore previous.
127
+ // Sensitive fields (#967): passwords/tokens/IBANs must be ciphertext-at-rest
128
+ // (boot validates sensitive pii | encrypted). The event log carries the
129
+ // table ciphertext replay reproduces the row byte-identically. Plaintext
130
+ // never lands in permanent history; the caller-facing event echo strips the
131
+ // field entirely (#820).
132
+ const SENSITIVE_TEST_KEY = Buffer.from("s3nS!t1vE.kP9xQ2@wN!vL$hR5yT8eU0").toString("base64");
130
133
  const sensitiveEntity = createEntity({
131
134
  table: "read_es_exec_sensitive",
132
135
  fields: {
133
136
  email: createTextField({ required: true }),
134
- passwordHash: createTextField({ sensitive: true }),
135
- apiToken: createTextField({ sensitive: true }),
137
+ passwordHash: createTextField({ sensitive: true, encrypted: true }),
138
+ apiToken: createTextField({ sensitive: true, encrypted: true }),
136
139
  },
137
140
  softDelete: true,
138
141
  });
@@ -141,6 +144,7 @@ const sensitiveTable = buildEntityTable("esExecSensitive", sensitiveEntity);
141
144
  describe("event-store-executor — sensitive fields", () => {
142
145
  const crud = createEventStoreExecutor(sensitiveTable, sensitiveEntity, {
143
146
  entityName: "esExecSensitive",
147
+ encryption: createTestEnvelopeCipher(SENSITIVE_TEST_KEY),
144
148
  });
145
149
 
146
150
  beforeAll(async () => {
@@ -170,26 +174,35 @@ describe("event-store-executor — sensitive fields", () => {
170
174
  };
171
175
  }
172
176
 
173
- test("create event payload excludes sensitive fields but entity row keeps them", async () => {
177
+ test("create event payload carries sensitive fields as table ciphertext; echo strips them", async () => {
174
178
  const result = await crud.create(
175
179
  { email: "s@test.de", passwordHash: "pw-hash-123", apiToken: "tok-abc" },
176
180
  adminUser,
177
181
  tdb,
178
182
  );
179
183
  if (!result.isSuccess) throw new Error("create failed");
180
- // Entity row: full data preserved.
184
+ // Caller-facing row: plaintext preserved.
181
185
  expect(result.data.data["passwordHash"]).toBe("pw-hash-123");
182
186
  expect(result.data.data["apiToken"]).toBe("tok-abc");
183
187
 
184
- // Event payload: sensitive stripped, public retained.
188
+ // Event payload: ciphertext, byte-identical to the table column (#967).
185
189
  const event = await lastEvent();
186
190
  expect(event.type).toBe("esExecSensitive.created");
187
191
  expect(event.payload["email"]).toBe("s@test.de");
188
- expect(event.payload["passwordHash"]).toBeUndefined();
189
- expect(event.payload["apiToken"]).toBeUndefined();
192
+ expect(event.payload["passwordHash"]).toBeDefined();
193
+ expect(event.payload["passwordHash"]).not.toBe("pw-hash-123");
194
+ const rawRows = (await asRawClient(testDb.db).unsafe(
195
+ `SELECT password_hash FROM read_es_exec_sensitive WHERE email = 's@test.de' LIMIT 1`,
196
+ )) as Array<{ password_hash: string }>;
197
+ expect(event.payload["passwordHash"]).toBe(rawRows[0]!.password_hash);
198
+
199
+ // Caller-facing event echo stays stripped (#820).
200
+ if (!result.data.event) throw new Error("no event echo");
201
+ expect(result.data.event.payload["passwordHash"]).toBeUndefined();
202
+ expect(result.data.event.payload["apiToken"]).toBeUndefined();
190
203
  });
191
204
 
192
- test("update event strips sensitive from BOTH changes and previous", async () => {
205
+ test("update event carries ciphertext in BOTH changes and previous", async () => {
193
206
  const created = await crud.create(
194
207
  { email: "u@test.de", passwordHash: "old-hash", apiToken: "old-tok" },
195
208
  adminUser,
@@ -213,16 +226,29 @@ describe("event-store-executor — sensitive fields", () => {
213
226
  previous: { email?: string; passwordHash?: string; apiToken?: string };
214
227
  }>();
215
228
  expect(event.type).toBe("esExecSensitive.updated");
216
- // Changes: email retained (public), passwordHash stripped.
229
+ // Changes: email plaintext (public), passwordHash ciphertext.
217
230
  expect(event.payload.changes.email).toBe("u2@test.de");
218
- expect(event.payload.changes.passwordHash).toBeUndefined();
219
- // Previous: email retained, passwordHash + apiToken stripped.
231
+ expect(event.payload.changes.passwordHash).toBeDefined();
232
+ expect(event.payload.changes.passwordHash).not.toBe("new-hash");
233
+ // Previous: email plaintext, passwordHash + apiToken ciphertext.
220
234
  expect(event.payload.previous.email).toBe("u@test.de");
221
- expect(event.payload.previous.passwordHash).toBeUndefined();
222
- expect(event.payload.previous.apiToken).toBeUndefined();
235
+ expect(event.payload.previous.passwordHash).toBeDefined();
236
+ expect(event.payload.previous.passwordHash).not.toBe("old-hash");
237
+ expect(event.payload.previous.apiToken).toBeDefined();
238
+ expect(event.payload.previous.apiToken).not.toBe("old-tok");
239
+
240
+ // Caller-facing event echo stays stripped (#820).
241
+ if (!result.data.event) throw new Error("no event echo");
242
+ const echoPayload = result.data.event.payload as {
243
+ changes: Record<string, unknown>;
244
+ previous: Record<string, unknown>;
245
+ };
246
+ expect(echoPayload.changes["passwordHash"]).toBeUndefined();
247
+ expect(echoPayload.previous["passwordHash"]).toBeUndefined();
248
+ expect(echoPayload.previous["apiToken"]).toBeUndefined();
223
249
  });
224
250
 
225
- test("delete event strips sensitive from previous", async () => {
251
+ test("delete event carries ciphertext in previous", async () => {
226
252
  const created = await crud.create(
227
253
  { email: "d@test.de", passwordHash: "pw", apiToken: "tk" },
228
254
  adminUser,
@@ -238,11 +264,13 @@ describe("event-store-executor — sensitive fields", () => {
238
264
  const event = await lastEvent<SensitivePrevious>();
239
265
  expect(event.type).toBe("esExecSensitive.deleted");
240
266
  expect(event.payload.previous.email).toBe("d@test.de");
241
- expect(event.payload.previous.passwordHash).toBeUndefined();
242
- expect(event.payload.previous.apiToken).toBeUndefined();
267
+ expect(event.payload.previous.passwordHash).toBeDefined();
268
+ expect(event.payload.previous.passwordHash).not.toBe("pw");
269
+ expect(event.payload.previous.apiToken).toBeDefined();
270
+ expect(event.payload.previous.apiToken).not.toBe("tk");
243
271
  });
244
272
 
245
- test("restore event strips sensitive from previous", async () => {
273
+ test("restore event carries ciphertext in previous", async () => {
246
274
  const created = await crud.create(
247
275
  { email: "r@test.de", passwordHash: "pw", apiToken: "tk" },
248
276
  adminUser,
@@ -259,8 +287,10 @@ describe("event-store-executor — sensitive fields", () => {
259
287
  const event = await lastEvent<SensitivePrevious>();
260
288
  expect(event.type).toBe("esExecSensitive.restored");
261
289
  expect(event.payload.previous.email).toBe("r@test.de");
262
- expect(event.payload.previous.passwordHash).toBeUndefined();
263
- expect(event.payload.previous.apiToken).toBeUndefined();
290
+ expect(event.payload.previous.passwordHash).toBeDefined();
291
+ expect(event.payload.previous.passwordHash).not.toBe("pw");
292
+ expect(event.payload.previous.apiToken).toBeDefined();
293
+ expect(event.payload.previous.apiToken).not.toBe("tk");
264
294
  });
265
295
  });
266
296
 
@@ -324,7 +354,7 @@ const encryptedSoftDeleteEntity = createEntity({
324
354
  const encryptedSoftDeleteTable = buildEntityTable("esExecEncSoft", encryptedSoftDeleteEntity);
325
355
 
326
356
  describe("event-store-executor — encrypted fields", () => {
327
- const encryption = createEncryptionProvider(ENCRYPTION_TEST_KEY);
357
+ const encryption = createTestEnvelopeCipher(ENCRYPTION_TEST_KEY);
328
358
  const crud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
329
359
  entityName: "esExecEncrypted",
330
360
  encryption,
@@ -337,7 +367,6 @@ describe("event-store-executor — encrypted fields", () => {
337
367
  );
338
368
 
339
369
  beforeAll(async () => {
340
- process.env["ENCRYPTION_KEY"] = ENCRYPTION_TEST_KEY;
341
370
  resetEntityFieldEncryptionCacheForTests();
342
371
  await unsafeCreateEntityTable(testDb.db, encryptedEntity, "esExecEncrypted");
343
372
  await unsafeCreateEntityTable(testDb.db, encryptedSoftDeleteEntity, "esExecEncSoft");
@@ -390,6 +419,81 @@ describe("event-store-executor — encrypted fields", () => {
390
419
  expect(updated.data.data["secretNote"]).toBe("new-note");
391
420
  });
392
421
 
422
+ test("update WITHOUT skipUnchanged still re-encrypts a resubmitted-but-unchanged encrypted field (KEK-rotation/backfill contract)", async () => {
423
+ // Regression guard: direct executor.update() callers that don't opt into
424
+ // skipUnchanged rely on today's always-re-encrypt behavior to force a
425
+ // fresh ciphertext for an unchanged plaintext — e.g. auth-mfa's
426
+ // KEK-rotation job (reencrypt.job.ts) and the user-data-rights #494
427
+ // backfill both resubmit the CURRENT value on purpose to land a real
428
+ // event. A blanket value-diff here would silently break both.
429
+ const created = await crud.create(
430
+ { email: "force-enc@test.de", secretNote: "same-note" },
431
+ adminUser,
432
+ tdb,
433
+ );
434
+ if (!created.isSuccess) throw new Error("create failed");
435
+
436
+ const before = (await asRawClient(testDb.db).unsafe(
437
+ `SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'force-enc@test.de' LIMIT 1`,
438
+ )) as Array<{ secret_note: string }>;
439
+
440
+ const updated = await crud.update(
441
+ { id: created.data.id, version: 1, changes: { secretNote: "same-note" } },
442
+ adminUser,
443
+ tdb,
444
+ );
445
+ if (!updated.isSuccess) throw new Error("update failed");
446
+
447
+ const after = (await asRawClient(testDb.db).unsafe(
448
+ `SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'force-enc@test.de' LIMIT 1`,
449
+ )) as Array<{ secret_note: string }>;
450
+
451
+ // Fresh AEAD nonce per encrypt call → different ciphertext even though
452
+ // the plaintext is identical. Byte-identical would mean the write was
453
+ // skipped, which is exactly what would break KEK rotation.
454
+ expect(after[0]?.secret_note).toBeDefined();
455
+ expect(after[0]?.secret_note).not.toBe(before[0]?.secret_note);
456
+ });
457
+
458
+ test("update with skipUnchanged: true omits a resubmitted-but-unchanged encrypted field — no phantom re-encrypt (#464)", async () => {
459
+ const created = await crud.create(
460
+ { email: "noop-enc@test.de", secretNote: "same-note" },
461
+ adminUser,
462
+ tdb,
463
+ );
464
+ if (!created.isSuccess) throw new Error("create failed");
465
+
466
+ const before = (await asRawClient(testDb.db).unsafe(
467
+ `SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'noop-enc@test.de' LIMIT 1`,
468
+ )) as Array<{ secret_note: string }>;
469
+
470
+ const updated = await crud.update(
471
+ {
472
+ id: created.data.id,
473
+ version: 1,
474
+ changes: { secretNote: "same-note", email: "noop-enc-2@test.de" },
475
+ },
476
+ adminUser,
477
+ tdb,
478
+ { skipUnchanged: true },
479
+ );
480
+ if (!updated.isSuccess) throw new Error("update failed");
481
+
482
+ const after = (await asRawClient(testDb.db).unsafe(
483
+ `SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'noop-enc-2@test.de' LIMIT 1`,
484
+ )) as Array<{ secret_note: string }>;
485
+
486
+ // Re-encrypting the same plaintext produces a fresh AEAD nonce → a
487
+ // different ciphertext even for an unchanged value. Byte-identical
488
+ // ciphertext proves the column was never touched by this update.
489
+ expect(after[0]?.secret_note).toBe(before[0]?.secret_note);
490
+
491
+ const rows = (await asRawClient(testDb.db).unsafe(
492
+ `SELECT payload FROM kumiko_events WHERE type = 'esExecEncrypted.updated' ORDER BY id DESC LIMIT 1`,
493
+ )) as Array<{ payload: { changes?: Record<string, unknown> } }>;
494
+ expect(rows[0]?.payload.changes).not.toHaveProperty("secretNote");
495
+ });
496
+
393
497
  test("update's persisted event carries ciphertext (not plaintext) for an encrypted field in `previous`", async () => {
394
498
  // Regression: `previous` in the STORED event came from loadById(), which
395
499
  // decrypts — appending it unchanged would put the plaintext of an
@@ -524,7 +628,7 @@ describe("event-store-executor — entity cache + encrypted fields", () => {
524
628
  store.delete(`${tenantId}:${name}:${id}`);
525
629
  },
526
630
  };
527
- const encryption = createEncryptionProvider(ENCRYPTION_TEST_KEY);
631
+ const encryption = createTestEnvelopeCipher(ENCRYPTION_TEST_KEY);
528
632
  const cachedEncryptedCrud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
529
633
  entityName: "esExecEncrypted",
530
634
  entityCache,
@@ -554,4 +658,170 @@ describe("event-store-executor — entity cache + encrypted fields", () => {
554
658
  const second = await cachedEncryptedCrud.detail({ id }, adminUser, tdb);
555
659
  expect(second?.["secretNote"]).toBe("cache-plaintext-note");
556
660
  });
661
+
662
+ // Regression twin pack for the list() path:
663
+ // 1. list decrypted BEFORE the snake→camel coercion, so any multi-word
664
+ // encrypted/pii field (secret_note vs secretNote) came back as raw
665
+ // ciphertext to the caller.
666
+ // 2. list's mset cached the decrypted rows — plaintext in Redis, the
667
+ // exact leak the detail() path re-encrypts to avoid.
668
+ test("list() returns plaintext for camelCase encrypted fields and caches ciphertext", async () => {
669
+ const created = await cachedEncryptedCrud.create(
670
+ { email: "cache-list@test.de", secretNote: "list-plaintext-note" },
671
+ adminUser,
672
+ tdb,
673
+ );
674
+ if (!created.isSuccess) throw new Error("create failed");
675
+ const storeKey = `${adminUser.tenantId}:esExecEncrypted:${created.data.id}`;
676
+ store.clear();
677
+
678
+ const list = await cachedEncryptedCrud.list({}, adminUser, tdb);
679
+ const listed = list.rows.find((r) => r["id"] === created.data.id);
680
+ expect(listed?.["secretNote"]).toBe("list-plaintext-note");
681
+
682
+ const cachedRaw = store.get(storeKey);
683
+ expect(cachedRaw?.["secretNote"]).toBeDefined();
684
+ expect(cachedRaw?.["secretNote"]).not.toBe("list-plaintext-note");
685
+ });
686
+ });
687
+
688
+ // PII subject encryption (crypto-shredding, #724 phase C): pii/userOwned/
689
+ // tenantOwned fields are encrypted with the erase subject's DEK. Event
690
+ // payload AND projection row carry ciphertext (live == rebuild); erasing the
691
+ // subject key renders every value as the [[erased]] sentinel without
692
+ // touching the immutable log.
693
+ const piiEntity = createEntity({
694
+ table: "read_es_exec_pii",
695
+ fields: {
696
+ email: createTextField({ required: true, pii: true }),
697
+ note: createTextField({ userOwned: { ownerField: "authorId" } }),
698
+ authorId: createTextField(),
699
+ plain: createTextField(),
700
+ },
701
+ });
702
+ const piiTable = buildEntityTable("esExecPii", piiEntity);
703
+
704
+ describe("event-store-executor — pii subject encryption", () => {
705
+ const kms = new InMemoryKmsAdapter();
706
+ const crud = createEventStoreExecutor(piiTable, piiEntity, {
707
+ entityName: "esExecPii",
708
+ kms,
709
+ });
710
+
711
+ beforeAll(async () => {
712
+ await unsafeCreateEntityTable(testDb.db, piiEntity, "esExecPii");
713
+ });
714
+
715
+ beforeEach(async () => {
716
+ await asRawClient(testDb.db).unsafe(
717
+ `TRUNCATE kumiko_events, read_es_exec_pii RESTART IDENTITY CASCADE`,
718
+ );
719
+ });
720
+
721
+ test("create: projection row + event payload are ciphertext, API reads plaintext", async () => {
722
+ const result = await crud.create({ email: "pii@test.de", plain: "visible" }, adminUser, tdb);
723
+ if (!result.isSuccess) throw new Error("create failed");
724
+ expect(result.data.data["email"]).toBe("pii@test.de");
725
+
726
+ const rawRows = (await asRawClient(testDb.db).unsafe(
727
+ `SELECT email, plain FROM read_es_exec_pii LIMIT 1`,
728
+ )) as Array<{ email: string; plain: string }>;
729
+ expect(rawRows[0]!.email).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${result.data.id}:`);
730
+ expect(rawRows[0]!.plain).toBe("visible");
731
+
732
+ const events = (await asRawClient(testDb.db).unsafe(
733
+ `SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
734
+ )) as Array<{ payload: { email?: string } }>;
735
+ expect(events[0]?.payload.email).toStartWith(PII_CIPHERTEXT_PREFIX);
736
+
737
+ const detail = await crud.detail({ id: result.data.id }, adminUser, tdb);
738
+ expect(detail?.["email"]).toBe("pii@test.de");
739
+ const list = await crud.list({}, adminUser, tdb);
740
+ expect(list.rows[0]?.["email"]).toBe("pii@test.de");
741
+ });
742
+
743
+ test("userOwned update without ownerField in changes resolves via the merged row", async () => {
744
+ const created = await crud.create(
745
+ { email: "owner@test.de", note: "v1", authorId: TestUsers.admin.id },
746
+ adminUser,
747
+ tdb,
748
+ );
749
+ if (!created.isSuccess) throw new Error("create failed");
750
+
751
+ const updated = await crud.update(
752
+ { id: created.data.id, version: 1, changes: { note: "v2" } },
753
+ adminUser,
754
+ tdb,
755
+ );
756
+ if (!updated.isSuccess) throw new Error("update failed");
757
+ expect(updated.data.data["note"]).toBe("v2");
758
+
759
+ const rawRows = (await asRawClient(testDb.db).unsafe(
760
+ `SELECT note FROM read_es_exec_pii LIMIT 1`,
761
+ )) as Array<{ note: string }>;
762
+ expect(rawRows[0]!.note).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${TestUsers.admin.id}:`);
763
+ });
764
+
765
+ test("eraseKey: detail renders the sentinel, stored events stay byte-identical", async () => {
766
+ const created = await crud.create({ email: "forget@test.de" }, adminUser, tdb);
767
+ if (!created.isSuccess) throw new Error("create failed");
768
+
769
+ const eventsBefore = (await asRawClient(testDb.db).unsafe(
770
+ `SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
771
+ )) as Array<{ payload: { email?: string } }>;
772
+
773
+ await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
774
+
775
+ const detail = await crud.detail({ id: created.data.id }, adminUser, tdb);
776
+ expect(detail?.["email"]).toBe(PII_ERASED_SENTINEL);
777
+
778
+ const eventsAfter = (await asRawClient(testDb.db).unsafe(
779
+ `SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
780
+ )) as Array<{ payload: { email?: string } }>;
781
+ expect(eventsAfter[0]?.payload.email).toBe(eventsBefore[0]!.payload.email!);
782
+ });
783
+
784
+ test("rebuild after erase resurrects no plaintext (replay writes ciphertext, reads render sentinel)", async () => {
785
+ const created = await crud.create({ email: "rebuild@test.de" }, adminUser, tdb);
786
+ if (!created.isSuccess) throw new Error("create failed");
787
+ await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
788
+
789
+ // Same code path rebuildProjection replays: applyEntityEvent on the
790
+ // PERSISTED event (ciphertext payload). The response echo carries the
791
+ // plaintext payload since #820 and must not be used as a replay source.
792
+ const responseEvent = created.data.event as { payload: Record<string, unknown> } | undefined;
793
+ expect(responseEvent?.payload["email"]).toBe("rebuild@test.de");
794
+ await asRawClient(testDb.db).unsafe(`TRUNCATE read_es_exec_pii RESTART IDENTITY CASCADE`);
795
+ const [storedEvent] = await loadEventsAfterVersion(
796
+ testDb.db,
797
+ String(created.data.id),
798
+ adminUser.tenantId,
799
+ 0,
800
+ );
801
+ if (!storedEvent) throw new Error("no persisted event for the aggregate");
802
+ const applied = await applyEntityEvent(storedEvent, piiTable, piiEntity, tdb.raw);
803
+ expect(applied.kind).toBe("applied");
804
+
805
+ const rawRows = (await asRawClient(testDb.db).unsafe(
806
+ `SELECT email FROM read_es_exec_pii LIMIT 1`,
807
+ )) as Array<{ email: string }>;
808
+ expect(rawRows[0]!.email).toStartWith(PII_CIPHERTEXT_PREFIX);
809
+ expect(rawRows[0]!.email).not.toContain("rebuild@test.de");
810
+
811
+ const detail = await crud.detail({ id: created.data.id }, adminUser, tdb);
812
+ expect(detail?.["email"]).toBe(PII_ERASED_SENTINEL);
813
+ });
814
+
815
+ test("without a kms adapter the engine is off: plaintext row (pre-#724 behavior)", async () => {
816
+ const plainCrud = createEventStoreExecutor(piiTable, piiEntity, {
817
+ entityName: "esExecPii",
818
+ });
819
+ const created = await plainCrud.create({ email: "off@test.de" }, adminUser, tdb);
820
+ if (!created.isSuccess) throw new Error("create failed");
821
+
822
+ const rawRows = (await asRawClient(testDb.db).unsafe(
823
+ `SELECT email FROM read_es_exec_pii LIMIT 1`,
824
+ )) as Array<{ email: string }>;
825
+ expect(rawRows[0]!.email).toBe("off@test.de");
826
+ });
557
827
  });
@@ -0,0 +1,34 @@
1
+ // Regression test for #255-class drift: storeTables must appear in
2
+ // enumerateFeatureTableSources so setupTestStack's auto-push and
3
+ // collectTableMetas see the exact same table set. Before this fix,
4
+ // unmanaged tables were only handled by collectTableMetas directly, so any
5
+ // app relying on setupTestStack's ephemeral DB (Playwright/e2e) never got
6
+ // its store tables created — e.g. the bundled "sessions" feature's
7
+ // store_user_sessions, breaking every login in an ephemeral test stack.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { defineFeature } from "../../engine/define-feature";
11
+ import { defineUnmanagedTable } from "../entity-table-meta";
12
+ import { enumerateFeatureTableSources } from "../feature-table-sources";
13
+
14
+ const probeMeta = defineUnmanagedTable({
15
+ tableName: "ftst_probe",
16
+ columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
17
+ });
18
+
19
+ describe("enumerateFeatureTableSources — storeTables", () => {
20
+ test("includes a feature's storeTable as a table source", () => {
21
+ const feature = defineFeature("probe", (r) => {
22
+ r.storeTable(probeMeta, { reason: "direct-write store" });
23
+ });
24
+ const sources = enumerateFeatureTableSources(feature);
25
+ const entry = sources.find((s) => s.origin.includes("ftst_probe"));
26
+ expect(entry).toBeDefined();
27
+ expect(entry?.table).toBe(probeMeta);
28
+ });
29
+
30
+ test("a feature with no storeTable contributes nothing extra", () => {
31
+ const feature = defineFeature("plain", () => {});
32
+ expect(enumerateFeatureTableSources(feature)).toEqual([]);
33
+ });
34
+ });