@cosmicdrift/kumiko-framework 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (424) hide show
  1. package/README.md +9 -38
  2. package/package.json +21 -2
  3. package/src/__tests__/anonymous-access.integration.test.ts +185 -0
  4. package/src/__tests__/consumer-cli.integration.test.ts +172 -0
  5. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  6. package/src/__tests__/full-stack.integration.test.ts +1 -1
  7. package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
  8. package/src/__tests__/schema-cli.integration.test.ts +50 -0
  9. package/src/__tests__/store-table.integration.test.ts +93 -0
  10. package/src/api/__tests__/api.test.ts +373 -0
  11. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  13. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
  14. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
  15. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
  16. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
  17. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  18. package/src/api/__tests__/batch.integration.test.ts +101 -11
  19. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  20. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  21. package/src/api/__tests__/jwt.test.ts +200 -1
  22. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
  23. package/src/api/__tests__/pat-scope.test.ts +36 -0
  24. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  25. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
  26. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  27. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  28. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  29. package/src/api/__tests__/sse-broker.test.ts +57 -0
  30. package/src/api/__tests__/sse-route.test.ts +4 -0
  31. package/src/api/api-constants.ts +11 -0
  32. package/src/api/auth-middleware.ts +248 -40
  33. package/src/api/auth-routes.ts +576 -95
  34. package/src/api/index.ts +13 -4
  35. package/src/api/jwt.ts +170 -11
  36. package/src/api/pat-scope.ts +14 -0
  37. package/src/api/pii-leak-guard.ts +47 -0
  38. package/src/api/request-context.ts +3 -0
  39. package/src/api/request-id-middleware.ts +2 -0
  40. package/src/api/routes.ts +169 -2
  41. package/src/api/server.ts +112 -16
  42. package/src/api/sse-broker.ts +39 -0
  43. package/src/bun-db/__tests__/PATTERN.md +0 -1
  44. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
  45. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  46. package/src/bun-db/__tests__/write-brand.test.ts +21 -0
  47. package/src/bun-db/connection.ts +3 -3
  48. package/src/bun-db/index.ts +2 -0
  49. package/src/bun-db/query.ts +105 -32
  50. package/src/consumer-cli.ts +134 -0
  51. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  52. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  53. package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
  54. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  55. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  56. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  57. package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
  58. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  59. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  60. package/src/crypto/blind-index.ts +118 -0
  61. package/src/crypto/event-pii.ts +70 -0
  62. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  63. package/src/crypto/index.ts +67 -0
  64. package/src/crypto/kms-adapter.ts +2 -0
  65. package/src/crypto/pg-kms-adapter.ts +295 -0
  66. package/src/crypto/pii-field-encryption.ts +248 -0
  67. package/src/crypto/request-kms-cache.ts +38 -0
  68. package/src/crypto/subject-resolver.ts +91 -0
  69. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
  70. package/src/db/__tests__/blind-index.integration.test.ts +248 -0
  71. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  72. package/src/db/__tests__/collect-table-metas.test.ts +10 -10
  73. package/src/db/__tests__/config-seed.integration.test.ts +13 -5
  74. package/src/db/__tests__/dialect-instant.test.ts +1 -4
  75. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  76. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  77. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  78. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
  79. package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
  80. package/src/db/__tests__/feature-table-sources.test.ts +34 -0
  81. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
  82. package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
  83. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  84. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  85. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  86. package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
  87. package/src/db/__tests__/rebuild-marker.test.ts +13 -3
  88. package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
  89. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  90. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  91. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  92. package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
  93. package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
  94. package/src/db/api.ts +2 -2
  95. package/src/db/apply-entity-event.ts +18 -14
  96. package/src/db/blind-index-cleanup.ts +55 -0
  97. package/src/db/bun-provider.ts +2 -2
  98. package/src/db/collect-table-metas.ts +6 -7
  99. package/src/db/config-seed.ts +9 -9
  100. package/src/db/connection.ts +7 -12
  101. package/src/db/cursor.ts +1 -18
  102. package/src/db/dialect.ts +20 -26
  103. package/src/db/entity-field-encryption.ts +81 -24
  104. package/src/db/entity-table-meta-types.ts +2 -0
  105. package/src/db/entity-table-meta.ts +63 -90
  106. package/src/db/event-store-executor-context.ts +404 -0
  107. package/src/db/event-store-executor-read.ts +275 -0
  108. package/src/db/event-store-executor-write.ts +644 -0
  109. package/src/db/event-store-executor.ts +28 -1155
  110. package/src/db/feature-table-sources.ts +6 -5
  111. package/src/db/index.ts +20 -3
  112. package/src/db/located-timestamp.ts +4 -0
  113. package/src/db/migrate-generator.ts +39 -6
  114. package/src/db/migrate-runner.ts +107 -11
  115. package/src/db/pg-error.ts +9 -1
  116. package/src/db/postgres-provider.ts +2 -2
  117. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  118. package/src/db/queries/backfill-pii.ts +277 -0
  119. package/src/db/queries/ddl.ts +45 -0
  120. package/src/db/queries/event-consumer.ts +35 -2
  121. package/src/db/queries/event-store.ts +126 -19
  122. package/src/db/queries/projection-rebuild.ts +22 -8
  123. package/src/db/queries/shadow-swap.ts +183 -0
  124. package/src/db/queries/test-stack.ts +4 -30
  125. package/src/db/query-api.ts +1 -0
  126. package/src/db/query.ts +1 -0
  127. package/src/db/rebuild-marker.ts +18 -2
  128. package/src/db/reference-data.ts +2 -3
  129. package/src/db/replay-migration-sql.ts +257 -0
  130. package/src/db/schema-inspection.ts +1 -1
  131. package/src/db/table-builder.ts +102 -71
  132. package/src/db/tenant-db.ts +15 -53
  133. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  134. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  135. package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
  136. package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
  137. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
  138. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
  139. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  140. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
  141. package/src/engine/__tests__/boot-validator.test.ts +219 -15
  142. package/src/engine/__tests__/build-app-schema.test.ts +82 -0
  143. package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
  144. package/src/engine/__tests__/build-target.test.ts +3 -11
  145. package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
  146. package/src/engine/__tests__/config-helpers.test.ts +16 -0
  147. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  148. package/src/engine/__tests__/define-roles.test.ts +21 -0
  149. package/src/engine/__tests__/engine.test.ts +163 -1
  150. package/src/engine/__tests__/entity-handlers.test.ts +80 -0
  151. package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
  152. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  153. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  154. package/src/engine/__tests__/factories-time.test.ts +2 -66
  155. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  156. package/src/engine/__tests__/feature-manifest.test.ts +31 -1
  157. package/src/engine/__tests__/field-access.test.ts +23 -1
  158. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  159. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  160. package/src/engine/__tests__/nav.test.ts +86 -1
  161. package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
  162. package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
  163. package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
  164. package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
  165. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
  166. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  167. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  168. package/src/engine/__tests__/registry.test.ts +144 -0
  169. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  170. package/src/engine/__tests__/screen.test.ts +147 -1
  171. package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
  172. package/src/engine/__tests__/store-table.test.ts +229 -0
  173. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  174. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
  175. package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
  176. package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
  177. package/src/engine/boot-validator/action-wiring.ts +149 -0
  178. package/src/engine/boot-validator/boot-check.ts +21 -0
  179. package/src/engine/boot-validator/config-deps.ts +40 -4
  180. package/src/engine/boot-validator/entity-handler.ts +20 -21
  181. package/src/engine/boot-validator/entity-list-screens.ts +88 -0
  182. package/src/engine/boot-validator/gdpr-storage.ts +0 -20
  183. package/src/engine/boot-validator/i18n-keys.ts +58 -4
  184. package/src/engine/boot-validator/index.ts +33 -12
  185. package/src/engine/boot-validator/nav.ts +125 -0
  186. package/src/engine/boot-validator/pii-retention.ts +95 -10
  187. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
  188. package/src/engine/boot-validator/workspaces.ts +68 -0
  189. package/src/engine/build-app-schema.ts +16 -0
  190. package/src/engine/build-config-feature-schema.ts +44 -7
  191. package/src/engine/codemod/pipeline-codemod.ts +5 -5
  192. package/src/engine/config-helpers.ts +15 -0
  193. package/src/engine/constants.ts +32 -6
  194. package/src/engine/create-app.ts +11 -0
  195. package/src/engine/define-feature.ts +95 -947
  196. package/src/engine/define-handler.ts +28 -94
  197. package/src/engine/define-workflow.ts +1 -1
  198. package/src/engine/effective-features.ts +12 -2
  199. package/src/engine/entity-handlers.ts +76 -11
  200. package/src/engine/extensions/tenant-data.ts +19 -0
  201. package/src/engine/extensions/user-data.ts +29 -2
  202. package/src/engine/factories.ts +8 -49
  203. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
  204. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  205. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  206. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  207. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  208. package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
  209. package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
  210. package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
  211. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
  212. package/src/engine/feature-ast/extractors/events.ts +322 -0
  213. package/src/engine/feature-ast/extractors/handlers.ts +234 -0
  214. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  215. package/src/engine/feature-ast/extractors/index.ts +34 -31
  216. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  217. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  218. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  219. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  220. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  221. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  222. package/src/engine/feature-ast/index.ts +2 -4
  223. package/src/engine/feature-ast/parse.ts +130 -23
  224. package/src/engine/feature-ast/patch.ts +39 -50
  225. package/src/engine/feature-ast/patcher.ts +37 -38
  226. package/src/engine/feature-ast/patterns.ts +50 -62
  227. package/src/engine/feature-ast/render.ts +67 -44
  228. package/src/engine/feature-builder-state.ts +168 -0
  229. package/src/engine/feature-config-events-jobs.ts +403 -0
  230. package/src/engine/feature-entity-handlers.ts +208 -0
  231. package/src/engine/feature-manifest.ts +2 -1
  232. package/src/engine/feature-ui-extensions.ts +500 -0
  233. package/src/engine/field-access.ts +13 -2
  234. package/src/engine/field-helpers.ts +31 -0
  235. package/src/engine/handler-helpers.ts +26 -0
  236. package/src/engine/hook-helpers.ts +16 -0
  237. package/src/engine/index.ts +23 -7
  238. package/src/engine/membership-roles.ts +13 -0
  239. package/src/engine/object-form.ts +27 -0
  240. package/src/engine/ownership.ts +26 -79
  241. package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
  242. package/src/engine/pattern-library/library.ts +44 -1152
  243. package/src/engine/pattern-library/mixed-schemas.ts +450 -0
  244. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  245. package/src/engine/pattern-library/shared-fields.ts +75 -0
  246. package/src/engine/pattern-library/static-schemas.ts +456 -0
  247. package/src/engine/pipeline.ts +6 -11
  248. package/src/engine/registry-facade.ts +362 -0
  249. package/src/engine/registry-ingest.ts +478 -0
  250. package/src/engine/registry-state.ts +388 -0
  251. package/src/engine/registry-validate.ts +642 -0
  252. package/src/engine/registry.ts +78 -1658
  253. package/src/engine/run-pipeline.ts +1 -1
  254. package/src/engine/schema-builder.ts +1 -0
  255. package/src/engine/screen-helpers.ts +54 -0
  256. package/src/engine/soft-delete-cleanup.ts +6 -2
  257. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  258. package/src/engine/steps/_duration-utils.ts +2 -0
  259. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  260. package/src/engine/tier-resolver-extension.ts +3 -2
  261. package/src/engine/types/config.ts +2 -482
  262. package/src/engine/types/define-handler.ts +2 -0
  263. package/src/engine/types/entity-handlers.ts +2 -0
  264. package/src/engine/types/event-type-map.ts +1 -37
  265. package/src/engine/types/feature.ts +2 -972
  266. package/src/engine/types/fields.ts +2 -675
  267. package/src/engine/types/handlers.ts +2 -774
  268. package/src/engine/types/hooks.ts +2 -184
  269. package/src/engine/types/http-route.ts +1 -54
  270. package/src/engine/types/identifiers.ts +1 -47
  271. package/src/engine/types/index.ts +86 -40
  272. package/src/engine/types/nav.ts +2 -63
  273. package/src/engine/types/ownership.ts +2 -0
  274. package/src/engine/types/projection.ts +2 -138
  275. package/src/engine/types/relations.ts +1 -51
  276. package/src/engine/types/screen.ts +2 -574
  277. package/src/engine/types/step.ts +2 -334
  278. package/src/engine/types/target-ref.ts +1 -21
  279. package/src/engine/types/tree-node.ts +1 -132
  280. package/src/engine/types/workspace.ts +2 -49
  281. package/src/engine/validate-projection-allowlist.ts +6 -6
  282. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
  283. package/src/entrypoint/index.ts +49 -6
  284. package/src/errors/classes.ts +22 -1
  285. package/src/errors/field-issue.ts +0 -3
  286. package/src/errors/index.ts +1 -1
  287. package/src/errors/write-error-info.ts +10 -22
  288. package/src/es-ops/README.md +1 -1
  289. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  290. package/src/es-ops/context.ts +4 -2
  291. package/src/es-ops/types.ts +8 -1
  292. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  293. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  294. package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
  295. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  296. package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
  297. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
  298. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  299. package/src/event-store/admin-api.ts +11 -4
  300. package/src/event-store/errors.ts +2 -35
  301. package/src/event-store/event-store.ts +64 -78
  302. package/src/event-store/events-schema.ts +11 -13
  303. package/src/event-store/index.ts +18 -3
  304. package/src/event-store/rebuild-dead-letter.ts +111 -0
  305. package/src/event-store/snapshot.ts +63 -40
  306. package/src/event-store/types.ts +2 -0
  307. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  308. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  309. package/src/files/__tests__/files.integration.test.ts +24 -0
  310. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  311. package/src/files/__tests__/local-provider.test.ts +31 -0
  312. package/src/files/__tests__/provider-resolver.test.ts +70 -0
  313. package/src/files/__tests__/write-stream.test.ts +13 -0
  314. package/src/files/file-handle.ts +2 -19
  315. package/src/files/file-ref-entity.ts +2 -2
  316. package/src/files/file-routes.ts +13 -0
  317. package/src/files/index.ts +1 -1
  318. package/src/files/local-provider.ts +27 -8
  319. package/src/files/provider-resolver.ts +31 -20
  320. package/src/files/types.ts +13 -55
  321. package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
  322. package/src/i18n/required-surface-keys.ts +120 -1
  323. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  324. package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
  325. package/src/jobs/job-runner.ts +82 -12
  326. package/src/logging/types.ts +1 -7
  327. package/src/migrations/pending-rebuilds.ts +1 -1
  328. package/src/observability/__tests__/observability.integration.test.ts +4 -1
  329. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  330. package/src/observability/index.ts +2 -0
  331. package/src/observability/noop-provider.ts +0 -9
  332. package/src/observability/recording-tracer.ts +0 -12
  333. package/src/observability/standard-metrics.ts +64 -2
  334. package/src/observability/types/index.ts +1 -29
  335. package/src/observability/types/metric.ts +1 -56
  336. package/src/observability/types/provider.ts +1 -32
  337. package/src/observability/types/span.ts +1 -64
  338. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  339. package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
  340. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  341. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  342. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
  343. package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
  344. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  345. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
  346. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  347. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  348. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
  349. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  350. package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
  351. package/src/pipeline/dispatch-batch.ts +187 -0
  352. package/src/pipeline/dispatch-query.ts +165 -0
  353. package/src/pipeline/dispatch-shared.ts +826 -0
  354. package/src/pipeline/dispatch-stream.ts +90 -0
  355. package/src/pipeline/dispatch-write.ts +451 -0
  356. package/src/pipeline/dispatcher-utils.ts +1 -1
  357. package/src/pipeline/dispatcher.ts +44 -1372
  358. package/src/pipeline/entity-cache.ts +2 -33
  359. package/src/pipeline/event-consumer-state.ts +30 -2
  360. package/src/pipeline/event-dispatcher-admin.ts +293 -0
  361. package/src/pipeline/event-dispatcher-delivery.ts +305 -0
  362. package/src/pipeline/event-dispatcher.ts +85 -542
  363. package/src/pipeline/index.ts +2 -0
  364. package/src/pipeline/msp-rebuild.ts +42 -3
  365. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  366. package/src/pipeline/projection-rebuild.ts +105 -3
  367. package/src/pipeline/system-hooks.ts +144 -1
  368. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  369. package/src/rate-limit/resolver.ts +16 -32
  370. package/src/schema-cli.ts +76 -16
  371. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
  372. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  373. package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
  374. package/src/search/index.ts +6 -0
  375. package/src/search/meilisearch-adapter.ts +13 -12
  376. package/src/search/reindex-entity.ts +177 -0
  377. package/src/search/types.ts +1 -39
  378. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  379. package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
  380. package/src/secrets/__tests__/envelope.test.ts +1 -1
  381. package/src/secrets/dek-cache.ts +26 -5
  382. package/src/secrets/envelope-cipher.ts +53 -0
  383. package/src/secrets/envelope.ts +5 -3
  384. package/src/secrets/index.ts +13 -1
  385. package/src/secrets/stored-envelope.ts +46 -0
  386. package/src/secrets/types.ts +2 -162
  387. package/src/stack/__tests__/event-collector.test.ts +42 -0
  388. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
  389. package/src/stack/db.ts +2 -1
  390. package/src/stack/push-entity-projection-tables.ts +4 -3
  391. package/src/stack/redis.ts +8 -0
  392. package/src/stack/request-helper.ts +38 -2
  393. package/src/stack/table-helpers.ts +6 -4
  394. package/src/stack/test-stack.ts +249 -131
  395. package/src/testing/__tests__/late-bound.test.ts +32 -0
  396. package/src/testing/__tests__/wait-for.test.ts +59 -0
  397. package/src/testing/boot-validator-fixture.ts +121 -0
  398. package/src/testing/e2e-generator.ts +8 -0
  399. package/src/testing/file-provider-contract.ts +104 -0
  400. package/src/testing/handler-context.ts +3 -1
  401. package/src/testing/index.ts +5 -0
  402. package/src/testing/late-bound.ts +5 -3
  403. package/src/testing/mutable-master-key-provider.ts +29 -3
  404. package/src/testing/wait-for.ts +3 -0
  405. package/src/testing/without-ambient-temporal.ts +14 -0
  406. package/src/time/__tests__/tz-dateline.test.ts +8 -10
  407. package/src/time/geo-tz.ts +1 -32
  408. package/src/time/index.ts +1 -0
  409. package/src/time/legacy-date.ts +18 -0
  410. package/src/time/polyfill.ts +21 -38
  411. package/src/time/tz-context.ts +44 -85
  412. package/src/ui-types/app-schema.ts +12 -0
  413. package/src/ui-types/index.ts +20 -7
  414. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  415. package/src/utils/safe-json.ts +13 -1
  416. package/src/__tests__/raw-table.integration.test.ts +0 -116
  417. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  418. package/src/db/__tests__/encryption.test.ts +0 -39
  419. package/src/db/encryption.ts +0 -39
  420. package/src/db/row-helpers.ts +0 -4
  421. package/src/engine/__tests__/raw-table.test.ts +0 -150
  422. package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
  423. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
  424. package/src/engine/feature-ast/extractors/round4.ts +0 -1366
@@ -0,0 +1,404 @@
1
+ import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
+ import { requestContext } from "../api/request-context";
3
+ import {
4
+ collectPiiSubjectFields,
5
+ configuredPiiSubjectKms,
6
+ decryptPiiFieldValues,
7
+ encryptPiiFieldValues,
8
+ type KmsContext,
9
+ type LocalKeyKmsAdapter,
10
+ } from "../crypto";
11
+ import { executeRawQuery } from "../db/queries/raw-sql";
12
+ import type { WhereObject } from "../db/query";
13
+ import { shiftParams } from "../engine/ownership";
14
+ import type {
15
+ EntityDefinition,
16
+ EntityId,
17
+ FieldDefinition,
18
+ SessionUser,
19
+ TenantId,
20
+ } from "../engine/types";
21
+ import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
22
+ import { UniqueViolationError, type WriteFailure, writeFailure } from "../errors";
23
+ import { ArchivedStreamError, type EventMetadata, isStreamArchived } from "../event-store";
24
+ import type { EntityCache } from "../pipeline/entity-cache";
25
+ import type { SearchAdapter } from "../search/types";
26
+ import type { EnvelopeCipher } from "../secrets/envelope-cipher";
27
+ import { assertUnreachable } from "../utils";
28
+ import { rehydrateCompoundTypes } from "./compound-types";
29
+ import type { DbRow } from "./connection";
30
+ import type { TableColumns } from "./dialect";
31
+ import {
32
+ collectEncryptedFieldNames,
33
+ decryptEntityFieldValues,
34
+ encryptEntityFieldValues,
35
+ resolveEntityFieldEncryption,
36
+ } from "./entity-field-encryption";
37
+ import type { EventStoreExecutorOptions } from "./event-store-executor";
38
+ import { constraintOf, isUniqueViolation } from "./pg-error";
39
+ import { toSnakeCase } from "./table-builder";
40
+ import type { TenantDb } from "./tenant-db";
41
+
42
+ // Shared context-building for the event-store-executor CRUD verbs (create/
43
+ // update/delete/forget/restore/list/detail — see event-store-executor-write.ts
44
+ // and event-store-executor-read.ts). Precomputes entity-derived state once
45
+ // per createEventStoreExecutor() call and bundles the crypto/ownership
46
+ // helpers the verbs need, so the verb modules don't each re-derive them.
47
+
48
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables
49
+ export type Table = TableColumns<any>;
50
+
51
+ // Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
52
+ // Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
53
+ // `op` ist auf {eq,ne,lt,gt,in} normalisiert; "in" mit empty-array ist
54
+ // explizit no-match.
55
+ export function buildFilterWhere(
56
+ field: string,
57
+ op: "eq" | "ne" | "lt" | "gt" | "in",
58
+ value: unknown,
59
+ ): WhereObject | null {
60
+ switch (op) {
61
+ case "eq":
62
+ return { [field]: value };
63
+ case "ne":
64
+ return { [field]: { ne: value } };
65
+ case "lt":
66
+ return { [field]: { lt: value } };
67
+ case "gt":
68
+ return { [field]: { gt: value } };
69
+ case "in":
70
+ if (Array.isArray(value) && value.length > 0) {
71
+ return { [field]: value };
72
+ }
73
+ return null; // no-match short-circuit
74
+ default:
75
+ return assertUnreachable(op, "filter op");
76
+ }
77
+ }
78
+
79
+ // Returns the scalar default of a field, or undefined if the field's type
80
+ // doesn't carry a default or no default was declared. Only scalar types
81
+ // (text/number/boolean/select) support creation-time defaults — money/date/
82
+ // file/embedded fields don't.
83
+ function scalarDefault(field: FieldDefinition): unknown {
84
+ switch (field.type) {
85
+ case "text":
86
+ case "longText":
87
+ case "number":
88
+ case "boolean":
89
+ case "select":
90
+ return field.default;
91
+ default:
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ // F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
97
+ // (create + update Pfade) → WriteFailure(UniqueViolationError 409).
98
+ // Andere Errors propagieren via re-throw. Lokal extrahiert weil das
99
+ // Pattern an zwei Stellen im executor lebt — der Caller wrap't den
100
+ // applyEntityEvent-call in try-catch und delegiert das Mapping hierher.
101
+ //
102
+ // Returns WriteFailure on match, null otherwise (caller re-throws).
103
+ export function tryMapUniqueViolation(e: unknown, entityName: string): WriteFailure | null {
104
+ if (!isUniqueViolation(e)) return null;
105
+ const constraintName = constraintOf(e);
106
+ return writeFailure(
107
+ new UniqueViolationError(
108
+ {
109
+ entityName,
110
+ ...(constraintName !== undefined && { constraintName }),
111
+ },
112
+ { cause: e instanceof Error ? e : undefined },
113
+ ),
114
+ );
115
+ }
116
+
117
+ // Build the metadata envelope for an append. userId always set; requestId +
118
+ // correlation + causation come from the AsyncLocalStorage request-context
119
+ // when present (e.g. HTTP request, MSP-apply, job run). requestId is a pure
120
+ // trace marker — HTTP-level retry idempotency runs separately via
121
+ // pipeline/idempotency.ts (Redis-cached response replay), so a single
122
+ // request can write N events freely without the events-table needing a
123
+ // uniqueness constraint.
124
+ export function buildEventMetadata(user: SessionUser): EventMetadata {
125
+ const reqCtx = requestContext.get();
126
+ return {
127
+ userId: String(user.id),
128
+ ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
129
+ ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
130
+ ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
131
+ };
132
+ }
133
+
134
+ // Lifecycle verbs the event-store-executor auto-emits. MSPs that react
135
+ // to entity creates/updates/etc should reference this helper instead of
136
+ // hardcoding the string — a future rename in the executor then surfaces
137
+ // as a type error at every call site rather than a silent miss.
138
+ export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
139
+
140
+ export function entityEventName(entityName: string, verb: EntityLifecycleVerb): string {
141
+ return `${entityName}.${verb}`;
142
+ }
143
+
144
+ export type ExecutorContext = {
145
+ readonly table: Table;
146
+ readonly entity: EntityDefinition;
147
+ readonly entityName: string;
148
+ readonly entityCache?: EntityCache;
149
+ readonly searchAdapter?: SearchAdapter;
150
+ readonly softDelete: boolean;
151
+ readonly streamTenantFor: (user: SessionUser) => TenantId;
152
+ readonly idFilter: (id: EntityId) => WhereObject;
153
+ readonly loadById: (id: EntityId, db: TenantDb) => Promise<Record<string, unknown> | null>;
154
+ readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
155
+ readonly loadWithOwnership: (
156
+ db: TenantDb,
157
+ idWhere: WhereObject,
158
+ ownership:
159
+ | { kind: "pass" }
160
+ | { kind: "empty" }
161
+ | { kind: "sql"; sqlText: string; params: readonly unknown[] },
162
+ ) => Promise<Record<string, unknown>[]>;
163
+ readonly encryptForStorage: (
164
+ row: Record<string, unknown>,
165
+ user: SessionUser,
166
+ opts?: { onlyKeys?: Iterable<string>; subjectSource?: Record<string, unknown> },
167
+ ) => Promise<Record<string, unknown>>;
168
+ readonly decryptForRead: (row: Record<string, unknown>) => Promise<Record<string, unknown>>;
169
+ readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
170
+ readonly stripSensitive: (
171
+ payload: Record<string, unknown> | undefined,
172
+ ) => Record<string, unknown>;
173
+ };
174
+
175
+ export function buildExecutorContext(
176
+ table: Table,
177
+ entity: EntityDefinition,
178
+ options: EventStoreExecutorOptions,
179
+ ): ExecutorContext {
180
+ const { searchAdapter, entityName, entityCache } = options;
181
+ const softDelete = entity.softDelete ?? false;
182
+
183
+ // Stream-tenant choke-point. A systemStream entity (tenant-independent, e.g.
184
+ // user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
185
+ // there. Everything else stays on the caller's tenant (byte-identical to the
186
+ // old hardcoded user.tenantId). Single source of truth for the stream key.
187
+ const streamTenantFor = (user: SessionUser): TenantId =>
188
+ entity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
189
+
190
+ // idType default (undefined) is now "uuid" — the ES-pivot made UUID the
191
+ // only valid aggregate-id type. Explicit `idType: "serial"` is the only
192
+ // shape that's incompatible with the event-store and still rejected.
193
+ if (entity.idType !== undefined && entity.idType !== "uuid") {
194
+ throw new Error(
195
+ `event-store-executor requires entity "${entityName}" to declare idType: "uuid" — ` +
196
+ `got idType: "${entity.idType}". ` +
197
+ `The events-table keys aggregates by uuid(aggregate_id); non-UUID PKs would ` +
198
+ `require a schema split the framework does not currently support. ` +
199
+ `Fix: remove the \`idType\`-override from createEntity({...}) for "${entityName}" ` +
200
+ `(the default is "uuid"). The framework auto-assigns UUIDs on create — ` +
201
+ `you do not need to generate them yourself. ` +
202
+ `See docs/plans/architecture/event-sourcing-pivot.md (section "UUID-only aggregate IDs") for the full rationale.`,
203
+ );
204
+ }
205
+
206
+ // Pre-compute defaults once so create() doesn't loop the entity every call.
207
+ const fieldDefaults: Record<string, unknown> = {};
208
+ for (const [name, field] of Object.entries(entity.fields)) {
209
+ const def = scalarDefault(field);
210
+ if (def !== undefined) fieldDefaults[name] = def;
211
+ }
212
+
213
+ // Pre-compute the set of sensitive field names once. The event log stores
214
+ // these fields as table ciphertext (boot validates sensitive ⇒ pii |
215
+ // encrypted, #967) — the set only strips the caller-facing event echo so
216
+ // responses never carry the value (#820).
217
+ const sensitiveFields = new Set<string>();
218
+ for (const [name, field] of Object.entries(entity.fields)) {
219
+ if ("sensitive" in field && field.sensitive === true) {
220
+ sensitiveFields.add(name);
221
+ }
222
+ }
223
+
224
+ const encryptedFields = collectEncryptedFieldNames(entity);
225
+ const hasEncryptedFields = encryptedFields.size > 0;
226
+
227
+ const piiSubjectFields = collectPiiSubjectFields(entity);
228
+ const hasPiiFields = piiSubjectFields.length > 0;
229
+
230
+ function fieldCipher(): EnvelopeCipher {
231
+ if (options.encryption) return options.encryption;
232
+ return resolveEntityFieldEncryption();
233
+ }
234
+
235
+ // No adapter configured = crypto-shredding off; pii fields stay plaintext
236
+ // (pre-#724 behavior). The hard boot gate ships with the prod-grade
237
+ // PgKmsAdapter (phase E).
238
+ function piiKms(): LocalKeyKmsAdapter | undefined {
239
+ return options.kms ?? configuredPiiSubjectKms();
240
+ }
241
+
242
+ function kmsContextFor(user?: SessionUser): KmsContext {
243
+ return {
244
+ requestId: requestContext.get()?.requestId ?? "event-store-executor",
245
+ ...(user && { tenantId: user.tenantId, userId: String(user.id) }),
246
+ };
247
+ }
248
+
249
+ // Async on purpose: the envelope cipher wraps/unwraps DEKs via the
250
+ // MasterKeyProvider. Callers MUST await — a missed await here writes
251
+ // "[object Promise]" into the projection, which the Promise return
252
+ // types turn into a compile error at every call site.
253
+ async function encryptForStorage(
254
+ row: Record<string, unknown>,
255
+ user: SessionUser,
256
+ opts?: { onlyKeys?: Iterable<string>; subjectSource?: Record<string, unknown> },
257
+ ): Promise<Record<string, unknown>> {
258
+ let out = row;
259
+ if (hasEncryptedFields) {
260
+ out = await encryptEntityFieldValues(out, encryptedFields, fieldCipher(), {
261
+ ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
262
+ });
263
+ }
264
+ const kms = piiKms();
265
+ if (hasPiiFields && kms) {
266
+ out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
267
+ tenantId: user.tenantId,
268
+ ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
269
+ ...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
270
+ });
271
+ }
272
+ return out;
273
+ }
274
+
275
+ // Mirror of encryptForStorage IN REVERSE — a field that carries both
276
+ // `encrypted: true` and a subject marker (userOwned/tenantOwned/pii) is
277
+ // written as PII(envelope(plaintext)) (envelope wraps first, PII wraps
278
+ // that as its outer layer). Decrypt must peel the OUTER layer first: PII
279
+ // unwrap before envelope decrypt, or the envelope cipher chokes on a
280
+ // still-PII-wrapped string (auth-mfa.totpSecret/recoveryCodes combine both
281
+ // markers; see pii-subject-encryption.integration.test.ts).
282
+ async function decryptForRead(row: Record<string, unknown>): Promise<Record<string, unknown>> {
283
+ let out = row;
284
+ const kms = piiKms();
285
+ if (hasPiiFields && kms) {
286
+ out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
287
+ }
288
+ if (hasEncryptedFields) {
289
+ out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher());
290
+ }
291
+ return out;
292
+ }
293
+
294
+ function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
295
+ if (Object.keys(fieldDefaults).length === 0) return payload;
296
+ const result: Record<string, unknown> = { ...payload };
297
+ for (const [name, def] of Object.entries(fieldDefaults)) {
298
+ if (result[name] === undefined) result[name] = def;
299
+ }
300
+ return result;
301
+ }
302
+
303
+ function stripSensitive(payload: Record<string, unknown> | undefined): Record<string, unknown> {
304
+ if (!payload) return {};
305
+ if (sensitiveFields.size === 0) return payload;
306
+ const result: Record<string, unknown> = {};
307
+ for (const [key, value] of Object.entries(payload)) {
308
+ if (sensitiveFields.has(key)) continue;
309
+ result[key] = value;
310
+ }
311
+ return result;
312
+ }
313
+
314
+ function idFilter(id: EntityId): WhereObject {
315
+ const filter: WhereObject = { id };
316
+ if (softDelete && table["isDeleted"]) filter["isDeleted"] = false;
317
+ return filter;
318
+ }
319
+
320
+ async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
321
+ const row = await db.fetchOne(table, idFilter(id));
322
+ if (!row) return null;
323
+ return await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
324
+ }
325
+
326
+ // Archive guard for the CRUD write paths. Archived streams are read-only —
327
+ // ctx.appendEvent (append-event-core) already enforces this, but the
328
+ // executor appends directly via append() and getStreamVersion() ignores
329
+ // the archive flag, so without this check a PATCH/DELETE on an archived
330
+ // entity would silently land an event and break the read-only contract
331
+ // (loadAggregate returns [] for the same stream). Throws ArchivedStreamError
332
+ // to mirror the appendEvent path exactly — same 500 + rolled-back tx.
333
+ // Creates skip this: a fresh UUID can't be archived, and a deterministic-id
334
+ // re-create onto an archived stream collides on the unique index →
335
+ // version_conflict, which already blocks the write.
336
+ async function assertStreamWritable(
337
+ db: TenantDb,
338
+ id: EntityId,
339
+ tenantId: TenantId,
340
+ ): Promise<void> {
341
+ if (await isStreamArchived(db.raw, tenantId, String(id))) {
342
+ throw new ArchivedStreamError(tenantId, String(id));
343
+ }
344
+ }
345
+
346
+ // SELECT a row by id with the ownership clause applied at the DB layer.
347
+ // Detail() uses this both on cold path and as a cache-revalidation probe.
348
+ async function loadWithOwnership(
349
+ db: TenantDb,
350
+ idWhere: WhereObject,
351
+ ownership:
352
+ | { kind: "pass" }
353
+ | { kind: "empty" }
354
+ | { kind: "sql"; sqlText: string; params: readonly unknown[] },
355
+ ): Promise<Record<string, unknown>[]> {
356
+ if (ownership.kind === "empty") return [];
357
+ if (ownership.kind === "pass") {
358
+ const row = await db.fetchOne(table, idWhere);
359
+ return row ? [row as Record<string, unknown>] : [];
360
+ }
361
+ // ownership has raw SQL — splice it into a raw query alongside the
362
+ // idFilter + tenant-filter that TenantDb would have added.
363
+ const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
364
+ const colSql = (field: string): string =>
365
+ `"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
366
+ const whereParts: string[] = [];
367
+ const params: unknown[] = [];
368
+ if (table["tenantId"] !== undefined && db.mode === "tenant") {
369
+ params.push(db.tenantId, SYSTEM_TENANT_ID);
370
+ whereParts.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
371
+ }
372
+ for (const [field, value] of Object.entries(idWhere)) {
373
+ if (typeof value === "boolean") {
374
+ whereParts.push(`${colSql(field)} = ${value ? "TRUE" : "FALSE"}`);
375
+ } else {
376
+ params.push(value);
377
+ whereParts.push(`${colSql(field)} = $${params.length}`);
378
+ }
379
+ }
380
+ const shifted = shiftParams(ownership, params.length);
381
+ whereParts.push(shifted.sqlText);
382
+ for (const p of shifted.params) params.push(p);
383
+ const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
384
+ return [...(await executeRawQuery<Record<string, unknown>>(db.raw, sqlText, params))];
385
+ }
386
+
387
+ return {
388
+ table,
389
+ entity,
390
+ entityName,
391
+ entityCache,
392
+ searchAdapter,
393
+ softDelete,
394
+ streamTenantFor,
395
+ idFilter,
396
+ loadById,
397
+ assertStreamWritable,
398
+ loadWithOwnership,
399
+ encryptForStorage,
400
+ decryptForRead,
401
+ applyDefaults,
402
+ stripSensitive,
403
+ };
404
+ }
@@ -0,0 +1,275 @@
1
+ import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
+ import { computeBlindIndex, configuredBlindIndexKey } from "../crypto";
3
+ import { executeRawQuery } from "../db/queries/raw-sql";
4
+ import { coerceRow, extractTableInfo } from "../db/query";
5
+ import { buildOwnershipClause, shiftParams } from "../engine/ownership";
6
+ import type { EntityId } from "../engine/types";
7
+ import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
8
+ import { getStreamVersion } from "../event-store";
9
+ import { rehydrateCompoundTypes } from "./compound-types";
10
+ import { decodeCursor, encodeCursor } from "./cursor";
11
+ import type { EventStoreExecutor } from "./event-store-executor";
12
+ import { buildFilterWhere, type ExecutorContext } from "./event-store-executor-context";
13
+ import { toSnakeCase } from "./table-builder";
14
+
15
+ // The two read verbs (list/detail) of the event-store-executor. Split out
16
+ // of event-store-executor.ts (#1005, Welle 2) — behavior-preserving
17
+ // relocation, not a redesign: unchanged from the original, now behind an
18
+ // explicit ExecutorContext instead of the factory's local scope.
19
+
20
+ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
21
+ const {
22
+ table,
23
+ entity,
24
+ entityName,
25
+ entityCache,
26
+ searchAdapter,
27
+ softDelete,
28
+ streamTenantFor,
29
+ idFilter,
30
+ loadWithOwnership,
31
+ decryptForRead,
32
+ encryptForStorage,
33
+ } = ctx;
34
+
35
+ return {
36
+ // list + detail are unchanged from crud-executor — projections are the
37
+ // read-model and serve these queries directly.
38
+ async list(payload, user, db, runtimeOptions) {
39
+ const limit = payload.limit ?? 50;
40
+ const offset = payload.offset ?? 0;
41
+ const totalCount = payload.totalCount === true;
42
+
43
+ // H.2 — entity-level read ownership. Decide before touching search or
44
+ // the DB: `empty` means there's no row the user could ever see, so
45
+ // skip both paths and return an empty page.
46
+ const ownership = buildOwnershipClause(user, entity.access?.read, table);
47
+ if (ownership.kind === "empty") {
48
+ return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
49
+ }
50
+
51
+ let filterIds: EntityId[] | undefined;
52
+ // Build-Time options.searchAdapter gewinnt; runtime-Override ist
53
+ // Fallback für die defaultEntityQueryHandler-Pipe (die nutzt den
54
+ // ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
55
+ // beim Definition-Time noch keinen Server-Context hat).
56
+ const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
57
+ if (payload.search && effectiveSearchAdapter && entityName) {
58
+ const results = await effectiveSearchAdapter.search(user.tenantId, payload.search, {
59
+ filterType: entityName,
60
+ });
61
+ filterIds = results.map((r) => r.entityId);
62
+ if (filterIds.length === 0) {
63
+ return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
64
+ }
65
+ }
66
+
67
+ // Build the WHERE clause as raw SQL — ownership produces a
68
+ // parameterised fragment that we splice in alongside simple WhereObject
69
+ // conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
70
+ const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
71
+ const whereSql: string[] = [];
72
+ const params: unknown[] = [];
73
+ const colSql = (field: string): string =>
74
+ `"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
75
+
76
+ // Tenant-Filter (replicates TenantDb's readWhere semantics).
77
+ if (table["tenantId"] !== undefined && db.mode === "tenant") {
78
+ params.push(db.tenantId, SYSTEM_TENANT_ID);
79
+ whereSql.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
80
+ }
81
+ if (softDelete && table["isDeleted"] && runtimeOptions?.includeDeleted !== true) {
82
+ whereSql.push(`${colSql("isDeleted")} = FALSE`);
83
+ }
84
+ if (payload.cursor) {
85
+ params.push(decodeCursor(payload.cursor));
86
+ whereSql.push(`${colSql("id")} > $${params.length}`);
87
+ }
88
+ if (filterIds) {
89
+ const placeholders = filterIds.map((id) => {
90
+ params.push(id);
91
+ return `$${params.length}`;
92
+ });
93
+ whereSql.push(`${colSql("id")} IN (${placeholders.join(", ")})`);
94
+ }
95
+ if (ownership.kind === "sql") {
96
+ const shifted = shiftParams(
97
+ { sqlText: ownership.sqlText, params: ownership.params },
98
+ params.length,
99
+ );
100
+ whereSql.push(shifted.sqlText);
101
+ for (const p of shifted.params) params.push(p);
102
+ }
103
+ const applyFilter = (f: {
104
+ readonly field: string;
105
+ readonly op: "eq" | "ne" | "lt" | "gt" | "in";
106
+ readonly value: unknown;
107
+ }): void => {
108
+ if (table[f.field] === undefined) {
109
+ // skip: unknown field — not a real column, drop the filter (injection guard)
110
+ return;
111
+ }
112
+ const screen = buildFilterWhere(f.field, f.op, f.value);
113
+ if (screen === null) {
114
+ whereSql.push("FALSE");
115
+ // skip: filter is unsatisfiable → emit FALSE, no params to bind
116
+ return;
117
+ }
118
+ for (const [field, value] of Object.entries(screen)) {
119
+ if (Array.isArray(value)) {
120
+ const placeholders = value.map((v) => {
121
+ params.push(v);
122
+ return `$${params.length}`;
123
+ });
124
+ whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
125
+ } else if (typeof value === "object" && value !== null) {
126
+ const opMap: Record<string, string> = {
127
+ gt: ">",
128
+ gte: ">=",
129
+ lt: "<",
130
+ lte: "<=",
131
+ ne: "<>",
132
+ };
133
+ for (const [opKey, opSym] of Object.entries(opMap)) {
134
+ if (!(opKey in value)) continue;
135
+ params.push((value as Record<string, unknown>)[opKey]);
136
+ whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
137
+ }
138
+ } else {
139
+ // Blind-Index-OR-Rewrite (#818), lock-step mit buildWhereClause
140
+ // in bun-db/query.ts — Equality auf lookupable-Feldern matcht
141
+ // Klartext-Arm ODER HMAC-Arm.
142
+ const bidxKey = configuredBlindIndexKey();
143
+ if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
144
+ params.push(value, computeBlindIndex(bidxKey, value));
145
+ whereSql.push(
146
+ `(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`,
147
+ );
148
+ } else {
149
+ params.push(value);
150
+ whereSql.push(`${colSql(field)} = $${params.length}`);
151
+ }
152
+ }
153
+ }
154
+ };
155
+ if (payload.filter !== undefined) applyFilter(payload.filter);
156
+ if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
157
+
158
+ const orderByClause =
159
+ payload.sort && table[payload.sort]
160
+ ? ` ORDER BY ${colSql(payload.sort)} ${payload.sortDirection === "desc" ? "DESC" : "ASC"}`
161
+ : "";
162
+ const useOffset = !payload.cursor && offset > 0;
163
+ const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
164
+
165
+ const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
166
+ const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
167
+
168
+ const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
169
+ // Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen.
170
+ // Coerce BEFORE decrypt: the raw SELECT * rows carry snake_case column
171
+ // names, while the encrypted/pii field lists are camelCase — decrypting
172
+ // first silently skipped every multi-word field (ciphertext leaked to
173
+ // the caller).
174
+ const tableInfo = extractTableInfo(table);
175
+ const encryptedRows = rawRows.map((r) =>
176
+ coerceRow(rehydrateCompoundTypes(r, entity), tableInfo),
177
+ );
178
+ const rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
179
+
180
+ // list rows carry the READ-ROW version (display-only), never an optimistic-lock
181
+ // base — edit flows reload via detail(), which reconciles the stream version.
182
+ // Cache the still-encrypted form: same at-rest guarantee as detail()'s
183
+ // encryptForStorage round-trip, without paying a re-encrypt.
184
+ if (entityCache && entityName && rows.length > 0) {
185
+ await entityCache.mset(
186
+ user.tenantId,
187
+ entityName,
188
+ encryptedRows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
189
+ );
190
+ }
191
+
192
+ const lastRow = rows[rows.length - 1];
193
+ const nextCursor =
194
+ rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null; // @cast-boundary engine-payload
195
+
196
+ // total: extra COUNT(*) — nur wenn explizit angefordert (Pager-UI).
197
+ // Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
198
+ // wie der entsprechende WHERE — bei indexed columns billig genug.
199
+ // Bei Search-Path ist `total = filterIds.length` ohne extra Query.
200
+ let total: number | undefined;
201
+ if (totalCount) {
202
+ if (filterIds) {
203
+ total = filterIds.length;
204
+ } else {
205
+ const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
206
+ const countRows = await executeRawQuery<{ count: number }>(db.raw, countSql, params);
207
+ total = countRows[0]?.count ?? 0;
208
+ }
209
+ }
210
+
211
+ return { rows, nextCursor, ...(total !== undefined && { total }) };
212
+ },
213
+
214
+ async detail(payload, user, db) {
215
+ // H.2 — ownership check. `empty` → the user can never see this row
216
+ // regardless of its id. Return null (same shape as "not found", so a
217
+ // probing attacker can't distinguish "no access" from "doesn't exist").
218
+ const ownership = buildOwnershipClause(user, entity.access?.read, table);
219
+ if (ownership.kind === "empty") return null;
220
+
221
+ const idWhere = idFilter(payload.id);
222
+
223
+ // Stream-version authoritative (same policy as update/Block 0):
224
+ // ctx.appendEvent (lifecycle-writes like incident:post-update) bumps
225
+ // the stream WITHOUT touching row.version — a detail-read that hands
226
+ // out the stale row.version dooms the next CRUD update built on it
227
+ // (entityEdit loads detail.version as its optimistic-lock base) to a
228
+ // guaranteed version_conflict.
229
+ const withStreamVersion = async (
230
+ row: Record<string, unknown>,
231
+ ): Promise<Record<string, unknown>> => {
232
+ const streamVersion = await getStreamVersion(
233
+ db.raw,
234
+ String(payload.id),
235
+ streamTenantFor(user),
236
+ );
237
+ return streamVersion > 0 ? { ...row, version: streamVersion } : row;
238
+ };
239
+
240
+ if (entityCache && entityName) {
241
+ const cached = await entityCache.get(user.tenantId, entityName, payload.id);
242
+ if (cached) {
243
+ if (ownership.kind === "sql") {
244
+ // Re-check ownership predicate against the live row — the cache
245
+ // is keyed only by tenant + id, not by role.
246
+ const checkRows = await loadWithOwnership(db, idWhere, ownership);
247
+ if (checkRows.length === 0) return null;
248
+ }
249
+ // Cached rows are stored re-encrypted (see the `set` below) so an
250
+ // `encrypted` field's plaintext never sits in a second at-rest
251
+ // store (Redis) the field-encryption feature doesn't cover.
252
+ return withStreamVersion(await decryptForRead(cached));
253
+ }
254
+ }
255
+
256
+ const rows = await loadWithOwnership(db, idWhere, ownership);
257
+ const raw = rows[0];
258
+ if (!raw) return null;
259
+ const row = await decryptForRead(rehydrateCompoundTypes(raw, entity));
260
+ const rowInfo = extractTableInfo(table);
261
+ const coerced = coerceRow(row, rowInfo);
262
+
263
+ if (entityCache && entityName) {
264
+ await entityCache.set(
265
+ user.tenantId,
266
+ entityName,
267
+ payload.id,
268
+ await encryptForStorage(coerced, user),
269
+ );
270
+ }
271
+
272
+ return withStreamVersion(coerced);
273
+ },
274
+ };
275
+ }