@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
@@ -19,6 +19,16 @@
19
19
  // drizzle's getTableName + getTableColumns (drizzle weiterhin als type-
20
20
  // reference, NICHT als runtime-API-call)
21
21
 
22
+ import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
23
+ import type {
24
+ SelectOptions,
25
+ WhereObject,
26
+ WhereOperator,
27
+ } from "@cosmicdrift/kumiko-types/where-clause-types";
28
+ // Static import, not the ambient global: Bun doesn't expose Temporal on
29
+ // globalThis, so instantFromDriver crashed on timestamptz reads (#1480).
30
+ import { Temporal } from "temporal-polyfill";
31
+ import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
22
32
  import type { EntityTableMeta } from "../db/entity-table-meta";
23
33
  import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
24
34
  import { camelCase as envCamelCase } from "../env";
@@ -42,7 +52,6 @@ import type { BunDbRunner } from "./connection";
42
52
  // enumerable props, so an entity field named `source`/`columns`/`tableName`/…
43
53
  // would overwrite the matching meta key — reading the meta from the symbol is
44
54
  // the only collision-safe path.
45
- const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
46
55
 
47
56
  function isEntityTableMeta(v: unknown): v is EntityTableMeta {
48
57
  return (
@@ -109,6 +118,23 @@ export function asRawClient(db: unknown): RawClient {
109
118
  );
110
119
  }
111
120
 
121
+ // postgres.js / Bun.SQL reject the WHOLE begin() when any statement inside
122
+ // it errored — even if the JS error was caught (the driver tracks per-block
123
+ // query failures). Error confinement therefore must go through the driver's
124
+ // own savepoint(), not manual SAVEPOINT statements. The callback receives
125
+ // the savepoint-scoped tx; run every confined statement on THAT handle.
126
+ export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promise<T>): Promise<T> {
127
+ const raw = asRawClient(tx) as unknown as {
128
+ savepoint?: <TR>(cb: (sp: unknown) => Promise<TR>) => Promise<TR>;
129
+ };
130
+ if (typeof raw.savepoint !== "function") {
131
+ throw new Error(
132
+ "runInSavepoint: transaction handle has no savepoint() — pass the driver tx (postgres.js TransactionSql / Bun.SQL).",
133
+ );
134
+ }
135
+ return raw.savepoint(fn);
136
+ }
137
+
112
138
  /**
113
139
  * When handlers call `selectMany(ctx.db, …)` instead of `ctx.db.selectMany(…)`,
114
140
  * unwrap via asRawClient would bypass TenantDb scoping. Duck-type TenantDb and
@@ -165,19 +191,23 @@ function assertNotTenantScoped(db: unknown, fnName: string): void {
165
191
 
166
192
  export type AnyDb = BunDbRunner | unknown;
167
193
 
168
- // WhereValue: primitive für eq, array für IN, null für IS NULL, oder
169
- // operator-object für range/comparisons.
170
- export type WhereOperator = {
171
- readonly gt?: unknown;
172
- readonly gte?: unknown;
173
- readonly lt?: unknown;
174
- readonly lte?: unknown;
175
- readonly ne?: unknown;
176
- readonly in?: readonly unknown[];
177
- readonly like?: string;
178
- };
179
- export type WhereValue = unknown | WhereOperator;
180
- export type WhereObject = Record<string, WhereValue>;
194
+ export type {
195
+ OrderByClause,
196
+ SelectOptions,
197
+ WhereObject,
198
+ WhereOperator,
199
+ WhereValue,
200
+ } from "@cosmicdrift/kumiko-types/where-clause-types";
201
+
202
+ const WHERE_OPERATOR_KEYS = ["gt", "gte", "lt", "lte", "ne", "in", "like"] as const;
203
+ // Forces a tsc error here (not just a silent runtime miss) the moment
204
+ // WhereOperator in @cosmicdrift/kumiko-types gains a key this array doesn't
205
+ // know about the two live in different packages and can't share a value
206
+ // import without adding a runtime dependency edge.
207
+ type _WhereOperatorKeysExhaustive =
208
+ Exclude<keyof WhereOperator, (typeof WHERE_OPERATOR_KEYS)[number]> extends never ? true : never;
209
+ const _whereOperatorKeysExhaustive: _WhereOperatorKeysExhaustive = true;
210
+ void _whereOperatorKeysExhaustive;
181
211
 
182
212
  function isWhereOperator(v: unknown): v is WhereOperator {
183
213
  if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
@@ -185,21 +215,8 @@ function isWhereOperator(v: unknown): v is WhereOperator {
185
215
  // WhereOperator is plain object literal with at most these keys.
186
216
  const keys = Object.keys(v);
187
217
  if (keys.length === 0) return false;
188
- const opKeys = ["gt", "gte", "lt", "lte", "ne", "in", "like"];
189
- return keys.every((k) => opKeys.includes(k));
218
+ return keys.every((k) => (WHERE_OPERATOR_KEYS as readonly string[]).includes(k));
190
219
  }
191
- export type OrderByClause = {
192
- readonly col: string;
193
- readonly direction?: "asc" | "desc";
194
- };
195
-
196
- export type SelectOptions = {
197
- readonly limit?: number;
198
- // Single column or array for multi-column tie-breaks (e.g.
199
- // [{col: "createdAt"}, {col: "id"}] for chronological-with-stable-id).
200
- readonly orderBy?: OrderByClause | readonly OrderByClause[];
201
- };
202
-
203
220
  // Akzeptiert EITHER. Beide haben einen tableName und field→column-mapping.
204
221
  // biome-ignore lint/suspicious/noExplicitAny: legacy drizzle pgTable surface
205
222
  type TableLike = EntityTableMeta | any;
@@ -338,6 +355,12 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
338
355
  const out: Record<string, unknown> = {};
339
356
  let changed = false;
340
357
  for (const key of Object.keys(row)) {
358
+ // Blind-Index-Spalten (#818) sind reine Lookup-Artefakte — der
359
+ // deterministische HMAC darf nie an Caller/API-Responses leaken.
360
+ if ((key.endsWith("_bidx") || key.endsWith("Bidx")) && info.hasColumn(key)) {
361
+ changed = true;
362
+ continue;
363
+ }
341
364
  const pgType = info.pgTypeOf(key);
342
365
  const value = row[key];
343
366
  let coerced: unknown = value;
@@ -512,14 +535,64 @@ function buildWhereClause(
512
535
  if (p.kind === "literal") {
513
536
  conditions.push(`${quoteIdent(col)} = ${p.literal}`);
514
537
  } else {
515
- conditions.push(`${quoteIdent(col)} = $${idx++}${p.sql}`);
516
- values.push(p.bound);
538
+ // Blind-Index-OR-Rewrite (#818): existiert zur Spalte ein bidx-
539
+ // Pendant (Suffix-Konvention `<col>_bidx`, framework-reserviert)
540
+ // und ist ein Key konfiguriert, matcht Equality beide Arme —
541
+ // Klartext-Alt-Rows über den Plaintext-Arm, verschlüsselte Rows
542
+ // über den HMAC. Der `kumiko-pii:`-Prefix der Ciphertexte macht
543
+ // einen Zufalls-Match des Plaintext-Arms praktisch unmöglich.
544
+ const bidxKey = configuredBlindIndexKey();
545
+ const bidxCol = `${col}_bidx`;
546
+ if (
547
+ bidxKey !== undefined &&
548
+ typeof value === "string" &&
549
+ !col.endsWith("_bidx") &&
550
+ info.hasColumn(bidxCol)
551
+ ) {
552
+ conditions.push(`(${quoteIdent(col)} = $${idx++} OR ${quoteIdent(bidxCol)} = $${idx++})`);
553
+ values.push(p.bound, computeBlindIndex(bidxKey, value));
554
+ } else {
555
+ conditions.push(`${quoteIdent(col)} = $${idx++}${p.sql}`);
556
+ values.push(p.bound);
557
+ }
517
558
  }
518
559
  }
519
560
  }
520
561
  return { sqlText: conditions.join(" AND "), values };
521
562
  }
522
563
 
564
+ // #1163: under load the Bun.SQL pool can hand out a connection the server
565
+ // already closed ("The connection was closed.", AbortError code 20) — no
566
+ // validate-on-checkout exists. One retry re-checks out a fresh connection;
567
+ // safe for pure reads. Tx handles are never retried: their transaction is
568
+ // dead once the connection dropped, and a retry would run on the same dead
569
+ // handle. Detection is name+message (not name alone) so a genuine user
570
+ // abort (AbortSignal cancel) is NOT retried.
571
+ function isClosedConnectionError(err: unknown): boolean {
572
+ if (err === null || typeof err !== "object") return false;
573
+ const e = err as { name?: unknown; message?: unknown };
574
+ return (
575
+ e.name === "AbortError" &&
576
+ typeof e.message === "string" &&
577
+ /connection was closed/i.test(e.message)
578
+ );
579
+ }
580
+
581
+ async function unsafeRead<TRow>(
582
+ db: AnyDb,
583
+ sqlText: string,
584
+ params: readonly unknown[],
585
+ ): Promise<readonly TRow[]> {
586
+ const raw = asRawClient(db);
587
+ try {
588
+ return (await raw.unsafe(sqlText, params)) as readonly TRow[];
589
+ } catch (err) {
590
+ // TransactionSql has savepoint(), only a top-level pool client has begin().
591
+ if (typeof raw.begin !== "function" || !isClosedConnectionError(err)) throw err;
592
+ return (await raw.unsafe(sqlText, params)) as readonly TRow[];
593
+ }
594
+ }
595
+
523
596
  // biome-ignore lint/suspicious/noExplicitAny: opt-in default loosens row type for unannotated test fixtures
524
597
  export async function selectMany<TRow = any>(
525
598
  db: AnyDb,
@@ -556,7 +629,7 @@ export async function selectMany<TRow = any>(
556
629
  }
557
630
  sqlText += ` LIMIT ${options.limit}`;
558
631
  }
559
- const raw = (await asRawClient(db).unsafe(sqlText, values)) as readonly Record<string, unknown>[];
632
+ const raw = (await unsafeRead(db, sqlText, values)) as readonly Record<string, unknown>[];
560
633
  return coerceRows(raw, info) as readonly TRow[];
561
634
  }
562
635
 
@@ -790,7 +863,7 @@ export async function countWhere(
790
863
  sqlText += ` WHERE ${w.sqlText}`;
791
864
  values = w.values;
792
865
  }
793
- const rows = (await asRawClient(db).unsafe(sqlText, values)) as readonly { count: number }[];
866
+ const rows = (await unsafeRead(db, sqlText, values)) as readonly { count: number }[];
794
867
  return rows[0]?.count ?? 0;
795
868
  }
796
869
 
@@ -0,0 +1,134 @@
1
+ // Shared core for the standalone consumer-ops CLI (status | restart).
2
+ //
3
+ // A dead event consumer (halt-on-poison after maxAttempts) previously had no
4
+ // recovery surface in the standalone prod bundle — only raw SQL against
5
+ // kumiko_event_consumers. Mirrors schema-cli.ts's shape (single runXCli(argv,
6
+ // out) entry point, own DB connection) so `kumiko-consumer` ships the same
7
+ // way `kumiko-schema` does.
8
+
9
+ import { createConnection } from "./db/api";
10
+ import { dbConnectionOptionsFromEnv } from "./db/connection";
11
+ import { getConsumerState, restartConsumer } from "./pipeline";
12
+ import { ensureTemporalPolyfill } from "./time";
13
+
14
+ export type ConsumerCliOut = {
15
+ readonly log: (line: string) => void;
16
+ readonly err: (line: string) => void;
17
+ };
18
+
19
+ type ParsedConsumerCliArgs = {
20
+ readonly sub: string | undefined;
21
+ readonly name: string | undefined;
22
+ readonly instanceId: string | undefined;
23
+ readonly error: string | undefined;
24
+ };
25
+
26
+ // Single pass: pulls --instance-id (both "--instance-id <id>" and
27
+ // "--instance-id=<id>") out of argv regardless of position, leaving the
28
+ // remaining positionals for sub/name — instead of two independent
29
+ // positional/flag reads that silently misparse "--instance-id" with no
30
+ // value, the "=" form, or a flag placed before the positionals (#1412).
31
+ function parseConsumerCliArgs(argv: readonly string[]): ParsedConsumerCliArgs {
32
+ const positionals: string[] = [];
33
+ let instanceId: string | undefined;
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const arg = argv[i];
36
+ if (arg === undefined) continue;
37
+ if (arg === "--instance-id") {
38
+ const value = argv[i + 1];
39
+ if (value === undefined || value.startsWith("--")) {
40
+ return {
41
+ sub: undefined,
42
+ name: undefined,
43
+ instanceId: undefined,
44
+ error: "--instance-id braucht einen Wert.",
45
+ };
46
+ }
47
+ instanceId = value;
48
+ i++;
49
+ continue;
50
+ }
51
+ if (arg.startsWith("--instance-id=")) {
52
+ instanceId = arg.slice("--instance-id=".length);
53
+ continue;
54
+ }
55
+ positionals.push(arg);
56
+ }
57
+ return { sub: positionals[0], name: positionals[1], instanceId, error: undefined };
58
+ }
59
+
60
+ export async function runConsumerCli(
61
+ argv: readonly string[],
62
+ out: ConsumerCliOut,
63
+ ): Promise<number> {
64
+ // The standalone bundle never runs runProdApp/runDevApp's boot, which is
65
+ // where Temporal normally gets installed — ConsumerRecoveryState.updatedAt
66
+ // is a Temporal.Instant, so without this every subcommand throws "Temporal
67
+ // is not defined" (same failure mode as schema-cli, see its polyfill test).
68
+ await ensureTemporalPolyfill();
69
+ const parsed = parseConsumerCliArgs(argv);
70
+ if (parsed.error) {
71
+ out.err(` ${parsed.error}`);
72
+ return 1;
73
+ }
74
+ const { sub, name, instanceId } = parsed;
75
+
76
+ if (sub !== "status" && sub !== "restart") {
77
+ // Unknown subcommand must be visible on stderr, not just a bare exit 1 —
78
+ // ops invocations piping `2>&1 >/dev/null` or log pipelines that only
79
+ // watch stderr would otherwise see a failure with zero explanation
80
+ // (#1412). A bare help call (no subcommand at all) is not an error, so
81
+ // it keeps exit 0 + stdout.
82
+ const write = sub === undefined ? out.log : out.err;
83
+ if (sub !== undefined) out.err(` Unknown subcommand: ${sub}`);
84
+ write("");
85
+ write(" Subcommands:");
86
+ write(" status <name> [--instance-id <id>] Zeigt Status + Cursor eines Consumers");
87
+ write(" restart <name> [--instance-id <id>] Reaktiviert einen dead-Consumer (idle)");
88
+ write("");
89
+ return sub === undefined ? 0 : 1;
90
+ }
91
+
92
+ if (!name) {
93
+ out.err(` Usage: consumer ${sub} <name> [--instance-id <id>]`);
94
+ return 1;
95
+ }
96
+
97
+ const dbUrl = process.env["DATABASE_URL"];
98
+ if (!dbUrl) {
99
+ out.err(" DATABASE_URL not set.");
100
+ return 1;
101
+ }
102
+ const { db, close } = await createConnection(dbUrl, dbConnectionOptionsFromEnv());
103
+ try {
104
+ if (sub === "status") {
105
+ const state = await getConsumerState(db, name, instanceId);
106
+ if (!state) {
107
+ out.err(` Consumer "${name}" (instance_id="${instanceId ?? "__shared__"}") not found.`);
108
+ return 1;
109
+ }
110
+ out.log("");
111
+ out.log(` ${state.name} (instance_id="${state.instanceId}")`);
112
+ out.log(` status: ${state.status}`);
113
+ out.log(` cursor: ${state.lastProcessedEventId}`);
114
+ out.log(` attempts: ${state.attempts}`);
115
+ out.log(` rearmCount: ${state.rearmCount}`);
116
+ out.log(` lastError: ${state.lastError ?? "-"}`);
117
+ out.log(` updatedAt: ${state.updatedAt.toString()}`);
118
+ out.log("");
119
+ return 0;
120
+ }
121
+
122
+ // restart
123
+ const result = await restartConsumer(db, name, instanceId);
124
+ out.log("");
125
+ out.log(` ✓ ${result.name} (instance_id="${result.instanceId}") → ${result.status}`);
126
+ out.log("");
127
+ return 0;
128
+ } catch (e) {
129
+ out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
130
+ return 1;
131
+ } finally {
132
+ await close();
133
+ }
134
+ }
@@ -0,0 +1,130 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { createEntity, createTextField } from "../../engine/factories";
3
+ import {
4
+ blindIndexFieldName,
5
+ collectLookupableFields,
6
+ computeBlindIndex,
7
+ computeBlindIndexValues,
8
+ configureBlindIndexKey,
9
+ configuredBlindIndexKey,
10
+ decodeBlindIndexKey,
11
+ resetBlindIndexKeyForTests,
12
+ } from "../blind-index";
13
+ import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
14
+ import {
15
+ configurePiiSubjectKms,
16
+ encryptPiiFieldValues,
17
+ PII_ERASED_SENTINEL,
18
+ resetPiiSubjectKmsForTests,
19
+ } from "../pii-field-encryption";
20
+
21
+ const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
22
+ const TEST_KEY_B64 = Buffer.alloc(32, 7).toString("base64");
23
+ const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
24
+
25
+ const userLikeEntity = createEntity({
26
+ fields: {
27
+ email: createTextField({ required: true, pii: true, lookupable: true }),
28
+ role: createTextField(),
29
+ },
30
+ table: "bidx_users",
31
+ });
32
+
33
+ afterEach(() => {
34
+ resetBlindIndexKeyForTests();
35
+ resetPiiSubjectKmsForTests();
36
+ });
37
+
38
+ describe("computeBlindIndex", () => {
39
+ test("deterministic, prefixed, byte-exact (no normalization)", () => {
40
+ const a = computeBlindIndex(TEST_KEY, "marc@example.com");
41
+ expect(a).toStartWith("kumiko-bidx:v1:");
42
+ expect(computeBlindIndex(TEST_KEY, "marc@example.com")).toBe(a);
43
+ expect(computeBlindIndex(TEST_KEY, "Marc@example.com")).not.toBe(a);
44
+ });
45
+
46
+ test("different keys produce different indexes", () => {
47
+ const otherKey = decodeBlindIndexKey(Buffer.alloc(32, 9).toString("base64"));
48
+ expect(computeBlindIndex(otherKey, "x")).not.toBe(computeBlindIndex(TEST_KEY, "x"));
49
+ });
50
+ });
51
+
52
+ describe("configureBlindIndexKey", () => {
53
+ test("rejects keys that are not 32 bytes", () => {
54
+ expect(() => configureBlindIndexKey(Buffer.alloc(16, 1).toString("base64"))).toThrow(
55
+ /32 bytes/,
56
+ );
57
+ expect(configuredBlindIndexKey()).toBeUndefined();
58
+ });
59
+
60
+ test("undefined clears the key", () => {
61
+ configureBlindIndexKey(TEST_KEY_B64);
62
+ expect(configuredBlindIndexKey()).toBeDefined();
63
+ configureBlindIndexKey(undefined);
64
+ expect(configuredBlindIndexKey()).toBeUndefined();
65
+ });
66
+ });
67
+
68
+ describe("collectLookupableFields", () => {
69
+ test("only text fields with lookupable: true", () => {
70
+ expect(collectLookupableFields(userLikeEntity)).toEqual(["email"]);
71
+ });
72
+ });
73
+
74
+ describe("computeBlindIndexValues", () => {
75
+ test("no key configured → empty (blind-indexing off)", async () => {
76
+ const out = await computeBlindIndexValues({ email: "a@b.c" }, ["email"]);
77
+ expect(out).toEqual({});
78
+ });
79
+
80
+ test("plaintext value → HMAC over the value itself", async () => {
81
+ configureBlindIndexKey(TEST_KEY_B64);
82
+ const out = await computeBlindIndexValues({ email: "a@b.c" }, ["email"]);
83
+ expect(out[blindIndexFieldName("email")]).toBe(computeBlindIndex(TEST_KEY, "a@b.c"));
84
+ });
85
+
86
+ test("absent field stays untouched, null value → NULL bidx", async () => {
87
+ configureBlindIndexKey(TEST_KEY_B64);
88
+ expect(await computeBlindIndexValues({ role: "admin" }, ["email"])).toEqual({});
89
+ expect(await computeBlindIndexValues({ email: null }, ["email"])).toEqual({
90
+ emailBidx: null,
91
+ });
92
+ });
93
+
94
+ test("ciphertext value → decrypt via configured KMS, HMAC over plaintext", async () => {
95
+ const kms = new InMemoryKmsAdapter();
96
+ configurePiiSubjectKms(kms);
97
+ configureBlindIndexKey(TEST_KEY_B64);
98
+ const stored = await encryptPiiFieldValues(
99
+ { id: UUID_A, email: "marc@example.com" },
100
+ userLikeEntity,
101
+ ["email"],
102
+ kms,
103
+ { requestId: "test" },
104
+ );
105
+ const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
106
+ expect(out["emailBidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
107
+ });
108
+
109
+ test("erased subject → NULL bidx (lookup stops matching)", async () => {
110
+ const kms = new InMemoryKmsAdapter();
111
+ configurePiiSubjectKms(kms);
112
+ configureBlindIndexKey(TEST_KEY_B64);
113
+ const stored = await encryptPiiFieldValues(
114
+ { id: UUID_A, email: "marc@example.com" },
115
+ userLikeEntity,
116
+ ["email"],
117
+ kms,
118
+ { requestId: "test" },
119
+ );
120
+ await kms.eraseKey({ kind: "user", userId: UUID_A });
121
+ const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
122
+ expect(out["emailBidx"]).toBeNull();
123
+ });
124
+
125
+ test("erased sentinel → NULL bidx", async () => {
126
+ configureBlindIndexKey(TEST_KEY_B64);
127
+ const out = await computeBlindIndexValues({ email: PII_ERASED_SENTINEL }, ["email"]);
128
+ expect(out["emailBidx"]).toBeNull();
129
+ });
130
+ });
@@ -0,0 +1,161 @@
1
+ // Event-PII catalog (#799): defineEvent({ piiFields }) → createRegistry
2
+ // publishes the catalog → encryptEventPayloadPii encrypts under the owning
3
+ // user's DEK. append() applies this on every write path; the pure pieces
4
+ // are testable without a database.
5
+
6
+ import { afterEach, describe, expect, test } from "bun:test";
7
+ import { z } from "zod";
8
+ import { createRegistry, defineFeature } from "../../engine";
9
+ import {
10
+ configuredEventPiiCatalog,
11
+ configureEventPiiCatalog,
12
+ encryptEventPayloadPii,
13
+ resetEventPiiCatalogForTests,
14
+ } from "../event-pii";
15
+ import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
16
+ import {
17
+ configurePiiSubjectKms,
18
+ decryptPiiFieldValues,
19
+ isPiiCiphertext,
20
+ PII_ERASED_SENTINEL,
21
+ resetPiiSubjectKmsForTests,
22
+ } from "../pii-field-encryption";
23
+
24
+ const attemptSchema = z.object({
25
+ recipientId: z.string().nullable(),
26
+ recipientAddress: z.string().nullable(),
27
+ status: z.string(),
28
+ });
29
+
30
+ const EVENT_TYPE = "mailer:event:attempt";
31
+
32
+ function catalogWithAttempt(): void {
33
+ configureEventPiiCatalog(
34
+ new Map([[EVENT_TYPE, { recipientAddress: { subjectField: "recipientId" } }]]),
35
+ );
36
+ }
37
+
38
+ afterEach(() => {
39
+ resetEventPiiCatalogForTests();
40
+ resetPiiSubjectKmsForTests();
41
+ });
42
+
43
+ describe("defineEvent piiFields validation", () => {
44
+ test("valid piiFields land on the EventDef and in the registry catalog", () => {
45
+ const feature = defineFeature("mailer", (r) => {
46
+ r.defineEvent("attempt", attemptSchema, {
47
+ piiFields: { recipientAddress: { subjectField: "recipientId" } },
48
+ });
49
+ });
50
+ createRegistry([feature]);
51
+ expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
52
+ recipientAddress: { subjectField: "recipientId" },
53
+ });
54
+ });
55
+
56
+ test("pii field not on the payload schema throws at definition time", () => {
57
+ expect(() =>
58
+ defineFeature("mailer", (r) => {
59
+ r.defineEvent("attempt", attemptSchema, {
60
+ piiFields: { nope: { subjectField: "recipientId" } },
61
+ });
62
+ }),
63
+ ).toThrow(/piiFields references "nope"/);
64
+ });
65
+
66
+ test("unknown subjectField throws at definition time", () => {
67
+ expect(() =>
68
+ defineFeature("mailer", (r) => {
69
+ r.defineEvent("attempt", attemptSchema, {
70
+ piiFields: { recipientAddress: { subjectField: "ownerId" } },
71
+ });
72
+ }),
73
+ ).toThrow(/piiFields references "ownerId"/);
74
+ });
75
+
76
+ test("field cannot be its own subjectField", () => {
77
+ expect(() =>
78
+ defineFeature("mailer", (r) => {
79
+ r.defineEvent("attempt", attemptSchema, {
80
+ piiFields: { recipientAddress: { subjectField: "recipientAddress" } },
81
+ });
82
+ }),
83
+ ).toThrow(/cannot use itself as subjectField/);
84
+ });
85
+
86
+ test("events without piiFields do not enter the catalog", () => {
87
+ const feature = defineFeature("mailer", (r) => {
88
+ r.defineEvent("attempt", attemptSchema);
89
+ });
90
+ createRegistry([feature]);
91
+ expect(configuredEventPiiCatalog().size).toBe(0);
92
+ });
93
+ });
94
+
95
+ describe("encryptEventPayloadPii", () => {
96
+ const payload = { recipientId: "u-1", recipientAddress: "u1@example.com", status: "sent" };
97
+
98
+ test("uncatalogued event type returns the payload untouched (same reference)", async () => {
99
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
100
+ expect(await encryptEventPayloadPii("other:event:x", payload)).toBe(payload);
101
+ });
102
+
103
+ test("no KMS configured → plaintext passthrough (rollout mode)", async () => {
104
+ catalogWithAttempt();
105
+ expect(await encryptEventPayloadPii(EVENT_TYPE, payload)).toBe(payload);
106
+ });
107
+
108
+ test("encrypts under the subject's DEK; subject fk stays plaintext", async () => {
109
+ catalogWithAttempt();
110
+ const kms = new InMemoryKmsAdapter();
111
+ configurePiiSubjectKms(kms);
112
+
113
+ const out = await encryptEventPayloadPii(EVENT_TYPE, payload);
114
+ expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
115
+ expect(String(out["recipientAddress"])).toContain("user:u-1");
116
+ expect(out["recipientId"]).toBe("u-1");
117
+ expect(out["status"]).toBe("sent");
118
+
119
+ const back = await decryptPiiFieldValues(out, ["recipientAddress"], kms, {
120
+ requestId: "test",
121
+ });
122
+ expect(back["recipientAddress"]).toBe("u1@example.com");
123
+ });
124
+
125
+ test("null subject field → value stays plaintext (no user key to shred)", async () => {
126
+ catalogWithAttempt();
127
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
128
+ const systemPayload = {
129
+ recipientId: null,
130
+ recipientAddress: "ops@example.com",
131
+ status: "sent",
132
+ };
133
+ expect(await encryptEventPayloadPii(EVENT_TYPE, systemPayload)).toBe(systemPayload);
134
+ });
135
+
136
+ test("null pii value passes through", async () => {
137
+ catalogWithAttempt();
138
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
139
+ const skipped = { recipientId: "u-1", recipientAddress: null, status: "skipped" };
140
+ expect(await encryptEventPayloadPii(EVENT_TYPE, skipped)).toBe(skipped);
141
+ });
142
+
143
+ test("idempotent: ciphertext and erased sentinel stay as-is", async () => {
144
+ catalogWithAttempt();
145
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
146
+ const once = await encryptEventPayloadPii(EVENT_TYPE, payload);
147
+ const twice = await encryptEventPayloadPii(EVENT_TYPE, once);
148
+ expect(twice["recipientAddress"]).toBe(once["recipientAddress"]);
149
+
150
+ const erased = { ...payload, recipientAddress: PII_ERASED_SENTINEL };
151
+ const out = await encryptEventPayloadPii(EVENT_TYPE, erased);
152
+ expect(out["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
153
+ });
154
+
155
+ test("non-string pii value is a loud error, not a silent skip", async () => {
156
+ catalogWithAttempt();
157
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
158
+ const broken = { recipientId: "u-1", recipientAddress: 42, status: "sent" };
159
+ expect(encryptEventPayloadPii(EVENT_TYPE, broken)).rejects.toThrow(/must be a string/);
160
+ });
161
+ });