@cosmicdrift/kumiko-framework 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (424) hide show
  1. package/README.md +9 -38
  2. package/package.json +21 -2
  3. package/src/__tests__/anonymous-access.integration.test.ts +185 -0
  4. package/src/__tests__/consumer-cli.integration.test.ts +172 -0
  5. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  6. package/src/__tests__/full-stack.integration.test.ts +1 -1
  7. package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
  8. package/src/__tests__/schema-cli.integration.test.ts +50 -0
  9. package/src/__tests__/store-table.integration.test.ts +93 -0
  10. package/src/api/__tests__/api.test.ts +373 -0
  11. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  13. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
  14. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
  15. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
  16. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
  17. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  18. package/src/api/__tests__/batch.integration.test.ts +101 -11
  19. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  20. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  21. package/src/api/__tests__/jwt.test.ts +200 -1
  22. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
  23. package/src/api/__tests__/pat-scope.test.ts +36 -0
  24. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  25. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
  26. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  27. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  28. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  29. package/src/api/__tests__/sse-broker.test.ts +57 -0
  30. package/src/api/__tests__/sse-route.test.ts +4 -0
  31. package/src/api/api-constants.ts +11 -0
  32. package/src/api/auth-middleware.ts +248 -40
  33. package/src/api/auth-routes.ts +576 -95
  34. package/src/api/index.ts +13 -4
  35. package/src/api/jwt.ts +170 -11
  36. package/src/api/pat-scope.ts +14 -0
  37. package/src/api/pii-leak-guard.ts +47 -0
  38. package/src/api/request-context.ts +3 -0
  39. package/src/api/request-id-middleware.ts +2 -0
  40. package/src/api/routes.ts +169 -2
  41. package/src/api/server.ts +112 -16
  42. package/src/api/sse-broker.ts +39 -0
  43. package/src/bun-db/__tests__/PATTERN.md +0 -1
  44. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
  45. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  46. package/src/bun-db/__tests__/write-brand.test.ts +21 -0
  47. package/src/bun-db/connection.ts +3 -3
  48. package/src/bun-db/index.ts +2 -0
  49. package/src/bun-db/query.ts +105 -32
  50. package/src/consumer-cli.ts +134 -0
  51. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  52. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  53. package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
  54. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  55. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  56. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  57. package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
  58. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  59. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  60. package/src/crypto/blind-index.ts +118 -0
  61. package/src/crypto/event-pii.ts +70 -0
  62. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  63. package/src/crypto/index.ts +67 -0
  64. package/src/crypto/kms-adapter.ts +2 -0
  65. package/src/crypto/pg-kms-adapter.ts +295 -0
  66. package/src/crypto/pii-field-encryption.ts +248 -0
  67. package/src/crypto/request-kms-cache.ts +38 -0
  68. package/src/crypto/subject-resolver.ts +91 -0
  69. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
  70. package/src/db/__tests__/blind-index.integration.test.ts +248 -0
  71. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  72. package/src/db/__tests__/collect-table-metas.test.ts +10 -10
  73. package/src/db/__tests__/config-seed.integration.test.ts +13 -5
  74. package/src/db/__tests__/dialect-instant.test.ts +1 -4
  75. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  76. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  77. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  78. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
  79. package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
  80. package/src/db/__tests__/feature-table-sources.test.ts +34 -0
  81. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
  82. package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
  83. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  84. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  85. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  86. package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
  87. package/src/db/__tests__/rebuild-marker.test.ts +13 -3
  88. package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
  89. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  90. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  91. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  92. package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
  93. package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
  94. package/src/db/api.ts +2 -2
  95. package/src/db/apply-entity-event.ts +18 -14
  96. package/src/db/blind-index-cleanup.ts +55 -0
  97. package/src/db/bun-provider.ts +2 -2
  98. package/src/db/collect-table-metas.ts +6 -7
  99. package/src/db/config-seed.ts +9 -9
  100. package/src/db/connection.ts +7 -12
  101. package/src/db/cursor.ts +1 -18
  102. package/src/db/dialect.ts +20 -26
  103. package/src/db/entity-field-encryption.ts +81 -24
  104. package/src/db/entity-table-meta-types.ts +2 -0
  105. package/src/db/entity-table-meta.ts +63 -90
  106. package/src/db/event-store-executor-context.ts +404 -0
  107. package/src/db/event-store-executor-read.ts +275 -0
  108. package/src/db/event-store-executor-write.ts +644 -0
  109. package/src/db/event-store-executor.ts +28 -1155
  110. package/src/db/feature-table-sources.ts +6 -5
  111. package/src/db/index.ts +20 -3
  112. package/src/db/located-timestamp.ts +4 -0
  113. package/src/db/migrate-generator.ts +39 -6
  114. package/src/db/migrate-runner.ts +107 -11
  115. package/src/db/pg-error.ts +9 -1
  116. package/src/db/postgres-provider.ts +2 -2
  117. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  118. package/src/db/queries/backfill-pii.ts +277 -0
  119. package/src/db/queries/ddl.ts +45 -0
  120. package/src/db/queries/event-consumer.ts +35 -2
  121. package/src/db/queries/event-store.ts +126 -19
  122. package/src/db/queries/projection-rebuild.ts +22 -8
  123. package/src/db/queries/shadow-swap.ts +183 -0
  124. package/src/db/queries/test-stack.ts +4 -30
  125. package/src/db/query-api.ts +1 -0
  126. package/src/db/query.ts +1 -0
  127. package/src/db/rebuild-marker.ts +18 -2
  128. package/src/db/reference-data.ts +2 -3
  129. package/src/db/replay-migration-sql.ts +257 -0
  130. package/src/db/schema-inspection.ts +1 -1
  131. package/src/db/table-builder.ts +102 -71
  132. package/src/db/tenant-db.ts +15 -53
  133. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  134. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  135. package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
  136. package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
  137. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
  138. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
  139. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  140. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
  141. package/src/engine/__tests__/boot-validator.test.ts +219 -15
  142. package/src/engine/__tests__/build-app-schema.test.ts +82 -0
  143. package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
  144. package/src/engine/__tests__/build-target.test.ts +3 -11
  145. package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
  146. package/src/engine/__tests__/config-helpers.test.ts +16 -0
  147. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  148. package/src/engine/__tests__/define-roles.test.ts +21 -0
  149. package/src/engine/__tests__/engine.test.ts +163 -1
  150. package/src/engine/__tests__/entity-handlers.test.ts +80 -0
  151. package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
  152. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  153. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  154. package/src/engine/__tests__/factories-time.test.ts +2 -66
  155. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  156. package/src/engine/__tests__/feature-manifest.test.ts +31 -1
  157. package/src/engine/__tests__/field-access.test.ts +23 -1
  158. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  159. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  160. package/src/engine/__tests__/nav.test.ts +86 -1
  161. package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
  162. package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
  163. package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
  164. package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
  165. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
  166. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  167. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  168. package/src/engine/__tests__/registry.test.ts +144 -0
  169. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  170. package/src/engine/__tests__/screen.test.ts +147 -1
  171. package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
  172. package/src/engine/__tests__/store-table.test.ts +229 -0
  173. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  174. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
  175. package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
  176. package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
  177. package/src/engine/boot-validator/action-wiring.ts +149 -0
  178. package/src/engine/boot-validator/boot-check.ts +21 -0
  179. package/src/engine/boot-validator/config-deps.ts +40 -4
  180. package/src/engine/boot-validator/entity-handler.ts +20 -21
  181. package/src/engine/boot-validator/entity-list-screens.ts +88 -0
  182. package/src/engine/boot-validator/gdpr-storage.ts +0 -20
  183. package/src/engine/boot-validator/i18n-keys.ts +58 -4
  184. package/src/engine/boot-validator/index.ts +33 -12
  185. package/src/engine/boot-validator/nav.ts +125 -0
  186. package/src/engine/boot-validator/pii-retention.ts +95 -10
  187. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
  188. package/src/engine/boot-validator/workspaces.ts +68 -0
  189. package/src/engine/build-app-schema.ts +16 -0
  190. package/src/engine/build-config-feature-schema.ts +44 -7
  191. package/src/engine/codemod/pipeline-codemod.ts +5 -5
  192. package/src/engine/config-helpers.ts +15 -0
  193. package/src/engine/constants.ts +32 -6
  194. package/src/engine/create-app.ts +11 -0
  195. package/src/engine/define-feature.ts +95 -947
  196. package/src/engine/define-handler.ts +28 -94
  197. package/src/engine/define-workflow.ts +1 -1
  198. package/src/engine/effective-features.ts +12 -2
  199. package/src/engine/entity-handlers.ts +76 -11
  200. package/src/engine/extensions/tenant-data.ts +19 -0
  201. package/src/engine/extensions/user-data.ts +29 -2
  202. package/src/engine/factories.ts +8 -49
  203. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
  204. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  205. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  206. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  207. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  208. package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
  209. package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
  210. package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
  211. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
  212. package/src/engine/feature-ast/extractors/events.ts +322 -0
  213. package/src/engine/feature-ast/extractors/handlers.ts +234 -0
  214. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  215. package/src/engine/feature-ast/extractors/index.ts +34 -31
  216. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  217. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  218. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  219. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  220. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  221. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  222. package/src/engine/feature-ast/index.ts +2 -4
  223. package/src/engine/feature-ast/parse.ts +130 -23
  224. package/src/engine/feature-ast/patch.ts +39 -50
  225. package/src/engine/feature-ast/patcher.ts +37 -38
  226. package/src/engine/feature-ast/patterns.ts +50 -62
  227. package/src/engine/feature-ast/render.ts +67 -44
  228. package/src/engine/feature-builder-state.ts +168 -0
  229. package/src/engine/feature-config-events-jobs.ts +403 -0
  230. package/src/engine/feature-entity-handlers.ts +208 -0
  231. package/src/engine/feature-manifest.ts +2 -1
  232. package/src/engine/feature-ui-extensions.ts +500 -0
  233. package/src/engine/field-access.ts +13 -2
  234. package/src/engine/field-helpers.ts +31 -0
  235. package/src/engine/handler-helpers.ts +26 -0
  236. package/src/engine/hook-helpers.ts +16 -0
  237. package/src/engine/index.ts +23 -7
  238. package/src/engine/membership-roles.ts +13 -0
  239. package/src/engine/object-form.ts +27 -0
  240. package/src/engine/ownership.ts +26 -79
  241. package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
  242. package/src/engine/pattern-library/library.ts +44 -1152
  243. package/src/engine/pattern-library/mixed-schemas.ts +450 -0
  244. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  245. package/src/engine/pattern-library/shared-fields.ts +75 -0
  246. package/src/engine/pattern-library/static-schemas.ts +456 -0
  247. package/src/engine/pipeline.ts +6 -11
  248. package/src/engine/registry-facade.ts +362 -0
  249. package/src/engine/registry-ingest.ts +478 -0
  250. package/src/engine/registry-state.ts +388 -0
  251. package/src/engine/registry-validate.ts +642 -0
  252. package/src/engine/registry.ts +78 -1658
  253. package/src/engine/run-pipeline.ts +1 -1
  254. package/src/engine/schema-builder.ts +1 -0
  255. package/src/engine/screen-helpers.ts +54 -0
  256. package/src/engine/soft-delete-cleanup.ts +6 -2
  257. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  258. package/src/engine/steps/_duration-utils.ts +2 -0
  259. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  260. package/src/engine/tier-resolver-extension.ts +3 -2
  261. package/src/engine/types/config.ts +2 -482
  262. package/src/engine/types/define-handler.ts +2 -0
  263. package/src/engine/types/entity-handlers.ts +2 -0
  264. package/src/engine/types/event-type-map.ts +1 -37
  265. package/src/engine/types/feature.ts +2 -972
  266. package/src/engine/types/fields.ts +2 -675
  267. package/src/engine/types/handlers.ts +2 -774
  268. package/src/engine/types/hooks.ts +2 -184
  269. package/src/engine/types/http-route.ts +1 -54
  270. package/src/engine/types/identifiers.ts +1 -47
  271. package/src/engine/types/index.ts +86 -40
  272. package/src/engine/types/nav.ts +2 -63
  273. package/src/engine/types/ownership.ts +2 -0
  274. package/src/engine/types/projection.ts +2 -138
  275. package/src/engine/types/relations.ts +1 -51
  276. package/src/engine/types/screen.ts +2 -574
  277. package/src/engine/types/step.ts +2 -334
  278. package/src/engine/types/target-ref.ts +1 -21
  279. package/src/engine/types/tree-node.ts +1 -132
  280. package/src/engine/types/workspace.ts +2 -49
  281. package/src/engine/validate-projection-allowlist.ts +6 -6
  282. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
  283. package/src/entrypoint/index.ts +49 -6
  284. package/src/errors/classes.ts +22 -1
  285. package/src/errors/field-issue.ts +0 -3
  286. package/src/errors/index.ts +1 -1
  287. package/src/errors/write-error-info.ts +10 -22
  288. package/src/es-ops/README.md +1 -1
  289. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  290. package/src/es-ops/context.ts +4 -2
  291. package/src/es-ops/types.ts +8 -1
  292. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  293. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  294. package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
  295. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  296. package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
  297. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
  298. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  299. package/src/event-store/admin-api.ts +11 -4
  300. package/src/event-store/errors.ts +2 -35
  301. package/src/event-store/event-store.ts +64 -78
  302. package/src/event-store/events-schema.ts +11 -13
  303. package/src/event-store/index.ts +18 -3
  304. package/src/event-store/rebuild-dead-letter.ts +111 -0
  305. package/src/event-store/snapshot.ts +63 -40
  306. package/src/event-store/types.ts +2 -0
  307. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  308. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  309. package/src/files/__tests__/files.integration.test.ts +24 -0
  310. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  311. package/src/files/__tests__/local-provider.test.ts +31 -0
  312. package/src/files/__tests__/provider-resolver.test.ts +70 -0
  313. package/src/files/__tests__/write-stream.test.ts +13 -0
  314. package/src/files/file-handle.ts +2 -19
  315. package/src/files/file-ref-entity.ts +2 -2
  316. package/src/files/file-routes.ts +13 -0
  317. package/src/files/index.ts +1 -1
  318. package/src/files/local-provider.ts +27 -8
  319. package/src/files/provider-resolver.ts +31 -20
  320. package/src/files/types.ts +13 -55
  321. package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
  322. package/src/i18n/required-surface-keys.ts +120 -1
  323. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  324. package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
  325. package/src/jobs/job-runner.ts +82 -12
  326. package/src/logging/types.ts +1 -7
  327. package/src/migrations/pending-rebuilds.ts +1 -1
  328. package/src/observability/__tests__/observability.integration.test.ts +4 -1
  329. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  330. package/src/observability/index.ts +2 -0
  331. package/src/observability/noop-provider.ts +0 -9
  332. package/src/observability/recording-tracer.ts +0 -12
  333. package/src/observability/standard-metrics.ts +64 -2
  334. package/src/observability/types/index.ts +1 -29
  335. package/src/observability/types/metric.ts +1 -56
  336. package/src/observability/types/provider.ts +1 -32
  337. package/src/observability/types/span.ts +1 -64
  338. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  339. package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
  340. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  341. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  342. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
  343. package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
  344. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  345. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
  346. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  347. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  348. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
  349. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  350. package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
  351. package/src/pipeline/dispatch-batch.ts +187 -0
  352. package/src/pipeline/dispatch-query.ts +165 -0
  353. package/src/pipeline/dispatch-shared.ts +826 -0
  354. package/src/pipeline/dispatch-stream.ts +90 -0
  355. package/src/pipeline/dispatch-write.ts +451 -0
  356. package/src/pipeline/dispatcher-utils.ts +1 -1
  357. package/src/pipeline/dispatcher.ts +44 -1372
  358. package/src/pipeline/entity-cache.ts +2 -33
  359. package/src/pipeline/event-consumer-state.ts +30 -2
  360. package/src/pipeline/event-dispatcher-admin.ts +293 -0
  361. package/src/pipeline/event-dispatcher-delivery.ts +305 -0
  362. package/src/pipeline/event-dispatcher.ts +85 -542
  363. package/src/pipeline/index.ts +2 -0
  364. package/src/pipeline/msp-rebuild.ts +42 -3
  365. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  366. package/src/pipeline/projection-rebuild.ts +105 -3
  367. package/src/pipeline/system-hooks.ts +144 -1
  368. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  369. package/src/rate-limit/resolver.ts +16 -32
  370. package/src/schema-cli.ts +76 -16
  371. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
  372. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  373. package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
  374. package/src/search/index.ts +6 -0
  375. package/src/search/meilisearch-adapter.ts +13 -12
  376. package/src/search/reindex-entity.ts +177 -0
  377. package/src/search/types.ts +1 -39
  378. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  379. package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
  380. package/src/secrets/__tests__/envelope.test.ts +1 -1
  381. package/src/secrets/dek-cache.ts +26 -5
  382. package/src/secrets/envelope-cipher.ts +53 -0
  383. package/src/secrets/envelope.ts +5 -3
  384. package/src/secrets/index.ts +13 -1
  385. package/src/secrets/stored-envelope.ts +46 -0
  386. package/src/secrets/types.ts +2 -162
  387. package/src/stack/__tests__/event-collector.test.ts +42 -0
  388. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
  389. package/src/stack/db.ts +2 -1
  390. package/src/stack/push-entity-projection-tables.ts +4 -3
  391. package/src/stack/redis.ts +8 -0
  392. package/src/stack/request-helper.ts +38 -2
  393. package/src/stack/table-helpers.ts +6 -4
  394. package/src/stack/test-stack.ts +249 -131
  395. package/src/testing/__tests__/late-bound.test.ts +32 -0
  396. package/src/testing/__tests__/wait-for.test.ts +59 -0
  397. package/src/testing/boot-validator-fixture.ts +121 -0
  398. package/src/testing/e2e-generator.ts +8 -0
  399. package/src/testing/file-provider-contract.ts +104 -0
  400. package/src/testing/handler-context.ts +3 -1
  401. package/src/testing/index.ts +5 -0
  402. package/src/testing/late-bound.ts +5 -3
  403. package/src/testing/mutable-master-key-provider.ts +29 -3
  404. package/src/testing/wait-for.ts +3 -0
  405. package/src/testing/without-ambient-temporal.ts +14 -0
  406. package/src/time/__tests__/tz-dateline.test.ts +8 -10
  407. package/src/time/geo-tz.ts +1 -32
  408. package/src/time/index.ts +1 -0
  409. package/src/time/legacy-date.ts +18 -0
  410. package/src/time/polyfill.ts +21 -38
  411. package/src/time/tz-context.ts +44 -85
  412. package/src/ui-types/app-schema.ts +12 -0
  413. package/src/ui-types/index.ts +20 -7
  414. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  415. package/src/utils/safe-json.ts +13 -1
  416. package/src/__tests__/raw-table.integration.test.ts +0 -116
  417. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  418. package/src/db/__tests__/encryption.test.ts +0 -39
  419. package/src/db/encryption.ts +0 -39
  420. package/src/db/row-helpers.ts +0 -4
  421. package/src/engine/__tests__/raw-table.test.ts +0 -150
  422. package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
  423. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
  424. package/src/engine/feature-ast/extractors/round4.ts +0 -1366
@@ -1,165 +1,14 @@
1
- import { requestContext } from "../api/request-context";
2
- import { executeRawQuery } from "../db/queries/raw-sql";
3
- import type { WhereObject } from "../db/query";
4
- import { coerceRow, extractTableInfo, selectMany } from "../db/query";
5
- import { checkWriteFieldOwnership } from "../engine/field-access";
6
- import {
7
- buildOwnershipClause,
8
- shiftParams,
9
- userCanCreateFieldRow,
10
- userCanWriteFieldRow,
11
- } from "../engine/ownership";
12
- import type {
13
- DeleteContext,
14
- EntityDefinition,
15
- EntityId,
16
- FieldDefinition,
17
- SaveContext,
18
- SessionUser,
19
- TenantId,
20
- WriteResult,
21
- } from "../engine/types";
22
- import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
23
- import {
24
- VersionConflictError as FrameworkVersionConflict,
25
- InternalError,
26
- NotFoundError,
27
- UniqueViolationError,
28
- UnprocessableError,
29
- type WriteFailure,
30
- writeFailure,
31
- } from "../errors";
32
- import {
33
- ArchivedStreamError,
34
- append,
35
- type EventMetadata,
36
- VersionConflictError as EventStoreVersionConflict,
37
- getStreamVersion,
38
- isStreamArchived,
39
- } from "../event-store";
1
+ import type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
2
+ import type { LocalKeyKmsAdapter } from "../crypto";
3
+ import type { EntityDefinition } from "../engine/types";
40
4
  import type { EntityCache } from "../pipeline/entity-cache";
41
5
  import type { SearchAdapter } from "../search/types";
42
- import { assertUnreachable, generateId } from "../utils";
43
- import { applyEntityEvent } from "./apply-entity-event";
44
- import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
45
- import type { DbRow } from "./connection";
46
- import { decodeCursor, encodeCursor } from "./cursor";
47
- import type { TableColumns } from "./dialect";
48
- import type { EncryptionProvider } from "./encryption";
49
- import {
50
- collectEncryptedFieldNames,
51
- decryptEntityFieldValues,
52
- encryptEntityFieldValues,
53
- resolveEntityFieldEncryption,
54
- } from "./entity-field-encryption";
55
- import type { CursorResult } from "./index";
56
- import { constraintOf, isUniqueViolation } from "./pg-error";
57
- import { toSnakeCase } from "./table-builder";
58
- import type { TenantDb } from "./tenant-db";
6
+ import type { EnvelopeCipher } from "../secrets/envelope-cipher";
7
+ import { buildExecutorContext, type Table } from "./event-store-executor-context";
8
+ import { createReadVerbs } from "./event-store-executor-read";
9
+ import { createWriteVerbs } from "./event-store-executor-write";
59
10
 
60
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables
61
- type Table = TableColumns<any>;
62
-
63
- // Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
64
- // Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
65
- // `op` ist auf {eq,ne,lt,gt,in} normalisiert; "in" mit empty-array ist
66
- // explizit no-match.
67
- function buildFilterWhere(
68
- field: string,
69
- op: "eq" | "ne" | "lt" | "gt" | "in",
70
- value: unknown,
71
- ): WhereObject | null {
72
- switch (op) {
73
- case "eq":
74
- return { [field]: value };
75
- case "ne":
76
- return { [field]: { ne: value } };
77
- case "lt":
78
- return { [field]: { lt: value } };
79
- case "gt":
80
- return { [field]: { gt: value } };
81
- case "in":
82
- if (Array.isArray(value) && value.length > 0) {
83
- return { [field]: value };
84
- }
85
- return null; // no-match short-circuit
86
- default:
87
- assertUnreachable(op, "filter op");
88
- }
89
- }
90
-
91
- // Returns the scalar default of a field, or undefined if the field's type
92
- // doesn't carry a default or no default was declared. Only scalar types
93
- // (text/number/boolean/select) support creation-time defaults — money/date/
94
- // file/embedded fields don't.
95
- function scalarDefault(field: FieldDefinition): unknown {
96
- switch (field.type) {
97
- case "text":
98
- case "longText":
99
- case "number":
100
- case "boolean":
101
- case "select":
102
- return field.default;
103
- default:
104
- return undefined;
105
- }
106
- }
107
-
108
- // Lifecycle verbs the event-store-executor auto-emits. MSPs that react
109
- // to entity creates/updates/etc should reference this helper instead of
110
- // hardcoding the string — a future rename in the executor then surfaces
111
- // as a type error at every call site rather than a silent miss.
112
- export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
113
-
114
- export function entityEventName(entityName: string, verb: EntityLifecycleVerb): string {
115
- return `${entityName}.${verb}`;
116
- }
117
-
118
- export type EventStoreExecutorOptions = {
119
- searchAdapter?: SearchAdapter;
120
- entityName: string; // required — the aggregateType marker on every event
121
- entityCache?: EntityCache;
122
- /** Override ENCRYPTION_KEY for entity fields marked `encrypted: true`. */
123
- encryption?: EncryptionProvider;
124
- };
125
-
126
- // F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
127
- // (create + update Pfade) → WriteFailure(UniqueViolationError 409).
128
- // Andere Errors propagieren via re-throw. Lokal extrahiert weil das
129
- // Pattern an zwei Stellen im executor lebt — der Caller wrap't den
130
- // applyEntityEvent-call in try-catch und delegiert das Mapping hierher.
131
- //
132
- // Returns WriteFailure on match, null otherwise (caller re-throws).
133
- function tryMapUniqueViolation(e: unknown, entityName: string): WriteFailure | null {
134
- if (!isUniqueViolation(e)) return null;
135
- const constraintName = constraintOf(e);
136
- return writeFailure(
137
- new UniqueViolationError(
138
- {
139
- entityName,
140
- ...(constraintName !== undefined && { constraintName }),
141
- },
142
- { cause: e instanceof Error ? e : undefined },
143
- ),
144
- );
145
- }
146
-
147
- // Build the metadata envelope for an append. userId always set; requestId +
148
- // correlation + causation come from the AsyncLocalStorage request-context
149
- // when present (e.g. HTTP request, MSP-apply, job run). requestId is a pure
150
- // trace marker — HTTP-level retry idempotency runs separately via
151
- // pipeline/idempotency.ts (Redis-cached response replay), so a single
152
- // request can write N events freely without the events-table needing a
153
- // uniqueness constraint.
154
- function buildEventMetadata(user: SessionUser): EventMetadata {
155
- const reqCtx = requestContext.get();
156
- return {
157
- userId: String(user.id),
158
- ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
159
- ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
160
- ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
161
- };
162
- }
11
+ export type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
163
12
 
164
13
  // The executor writes events + auto-projection (entity table) in one TX.
165
14
  // It no longer knows about user projections — those are driven by the
@@ -167,91 +16,25 @@ function buildEventMetadata(user: SessionUser): EventMetadata {
167
16
  // and iterates the registry itself. Executor-level `registry` options were
168
17
  // removed to close the silent-bypass hole where a caller forgetting to pass
169
18
  // one would skip projections without any signal.
170
- export type EventStoreExecutor = {
171
- create: (
172
- payload: Record<string, unknown>,
173
- user: SessionUser,
174
- db: TenantDb,
175
- ) => Promise<WriteResult<SaveContext>>;
176
-
177
- update: (
178
- payload: { id: EntityId; version?: number | undefined; changes: Record<string, unknown> },
179
- user: SessionUser,
180
- db: TenantDb,
181
- options?: { skipOptimisticLock?: boolean },
182
- ) => Promise<WriteResult<SaveContext>>;
183
-
184
- delete: (
185
- payload: { id: EntityId },
186
- user: SessionUser,
187
- db: TenantDb,
188
- ) => Promise<WriteResult<DeleteContext>>;
189
-
190
- // Hard-purge (Art. 17 erasure). Like delete, but emits `<entity>.forgotten`
191
- // which hard-deletes the row even for softDelete entities — and, being an
192
- // auto-verb replayed by the implicit projection, the erasure survives a
193
- // rebuild (created → forgotten → row gone). Reaches soft-deleted rows too.
194
- forget: (
195
- payload: { id: EntityId },
196
- user: SessionUser,
197
- db: TenantDb,
198
- ) => Promise<WriteResult<DeleteContext>>;
199
-
200
- restore: (
201
- payload: { id: EntityId },
202
- user: SessionUser,
203
- db: TenantDb,
204
- ) => Promise<WriteResult<SaveContext>>;
19
+ //
20
+ // Split into three files (#1005, Welle 2): this facade holds the public
21
+ // types + the createEventStoreExecutor() factory. Context-building (crypto/
22
+ // ownership helpers shared by every verb) lives in
23
+ // event-store-executor-context.ts; the write verbs (create/update/delete/
24
+ // forget/restore) in event-store-executor-write.ts; the read verbs (list/
25
+ // detail) in event-store-executor-read.ts.
205
26
 
206
- list: (
207
- payload: {
208
- cursor?: string | undefined;
209
- limit?: number | undefined;
210
- search?: string | undefined;
211
- sort?: string | undefined;
212
- sortDirection?: "asc" | "desc" | undefined;
213
- offset?: number | undefined;
214
- totalCount?: boolean | undefined;
215
- filter?:
216
- | {
217
- readonly field: string;
218
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
219
- readonly value: unknown;
220
- }
221
- | undefined;
222
- // User-gewählte Faceted-Filter (dynamisch, additiv zum statischen
223
- // `filter`). Alle werden mit AND verknüpft.
224
- filters?:
225
- | ReadonlyArray<{
226
- readonly field: string;
227
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
228
- readonly value: unknown;
229
- }>
230
- | undefined;
231
- },
232
- user: SessionUser,
233
- db: TenantDb,
234
- /** Tier 2.7e Audit-Fix: per-Call SearchAdapter Override. Wenn der
235
- * Executor beim Build keinen SearchAdapter via Options bekommen
236
- * hat (defaultEntityQueryHandler-Pfad), kann der Caller (Handler)
237
- * hier zur Runtime einen aus ctx.searchAdapter durchreichen.
238
- * options.searchAdapter (build-time) gewinnt — runtime-Override
239
- * ist Fallback für die default-Wrapper. */
240
- runtimeOptions?: {
241
- readonly searchAdapter?: SearchAdapter;
242
- // Trash query: skip the implicit `isDeleted = FALSE` filter so soft-
243
- // deleted rows are returned too. Tenant + ownership clauses still apply
244
- // — includeDeleted only relaxes the soft-delete predicate, never the
245
- // visibility ones, so it can ride untrusted query input safely.
246
- readonly includeDeleted?: boolean;
247
- },
248
- ) => Promise<CursorResult<Record<string, unknown>>>;
27
+ export type { EntityLifecycleVerb } from "./event-store-executor-context";
28
+ export { entityEventName } from "./event-store-executor-context";
249
29
 
250
- detail: (
251
- payload: { id: EntityId },
252
- user: SessionUser,
253
- db: TenantDb,
254
- ) => Promise<Record<string, unknown> | null>;
30
+ export type EventStoreExecutorOptions = {
31
+ searchAdapter?: SearchAdapter;
32
+ entityName: string; // required — the aggregateType marker on every event
33
+ entityCache?: EntityCache;
34
+ /** Override the boot-injected cipher for fields marked `encrypted: true`. */
35
+ encryption?: EnvelopeCipher;
36
+ /** Override the boot-injected subject KMS for pii-annotated fields. */
37
+ kms?: LocalKeyKmsAdapter;
255
38
  };
256
39
 
257
40
  export function createEventStoreExecutor(
@@ -259,919 +42,9 @@ export function createEventStoreExecutor(
259
42
  entity: EntityDefinition,
260
43
  options: EventStoreExecutorOptions,
261
44
  ): EventStoreExecutor {
262
- const { searchAdapter, entityName, entityCache } = options;
263
- const softDelete = entity.softDelete ?? false;
264
-
265
- // Stream-tenant choke-point. A systemStream entity (tenant-independent, e.g.
266
- // user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
267
- // there. Everything else stays on the caller's tenant (byte-identical to the
268
- // old hardcoded user.tenantId). Single source of truth for the stream key.
269
- const streamTenantFor = (user: SessionUser): TenantId =>
270
- entity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
271
-
272
- // idType default (undefined) is now "uuid" — the ES-pivot made UUID the
273
- // only valid aggregate-id type. Explicit `idType: "serial"` is the only
274
- // shape that's incompatible with the event-store and still rejected.
275
- if (entity.idType !== undefined && entity.idType !== "uuid") {
276
- throw new Error(
277
- `event-store-executor requires entity "${entityName}" to declare idType: "uuid" — ` +
278
- `got idType: "${entity.idType}". ` +
279
- `The events-table keys aggregates by uuid(aggregate_id); non-UUID PKs would ` +
280
- `require a schema split the framework does not currently support. ` +
281
- `Fix: remove the \`idType\`-override from createEntity({...}) for "${entityName}" ` +
282
- `(the default is "uuid"). The framework auto-assigns UUIDs on create — ` +
283
- `you do not need to generate them yourself. ` +
284
- `See docs/plans/architecture/event-sourcing-pivot.md (section "UUID-only aggregate IDs") for the full rationale.`,
285
- );
286
- }
287
-
288
- // Pre-compute defaults once so create() doesn't loop the entity every call.
289
- const fieldDefaults: Record<string, unknown> = {};
290
- for (const [name, field] of Object.entries(entity.fields)) {
291
- const def = scalarDefault(field);
292
- if (def !== undefined) fieldDefaults[name] = def;
293
- }
294
-
295
- // Pre-compute the set of sensitive field names once. Every event payload
296
- // (create data, update changes + previous, delete previous, restore
297
- // previous) strips these before writing to the immutable event log. Keeps
298
- // GDPR right-to-be-forgotten tractable — only entity rows hold the
299
- // sensitive data, and entity rows can be deleted / re-encrypted.
300
- const sensitiveFields = new Set<string>();
301
- for (const [name, field] of Object.entries(entity.fields)) {
302
- if ("sensitive" in field && field.sensitive === true) {
303
- sensitiveFields.add(name);
304
- }
305
- }
306
-
307
- const encryptedFields = collectEncryptedFieldNames(entity);
308
- const hasEncryptedFields = encryptedFields.size > 0;
309
-
310
- function encryptionProvider(): EncryptionProvider {
311
- if (options.encryption) return options.encryption;
312
- return resolveEntityFieldEncryption();
313
- }
314
-
315
- function encryptForStorage(
316
- row: Record<string, unknown>,
317
- onlyKeys?: Iterable<string>,
318
- ): Record<string, unknown> {
319
- if (!hasEncryptedFields) return row;
320
- return encryptEntityFieldValues(row, encryptedFields, encryptionProvider(), {
321
- ...(onlyKeys !== undefined && { onlyKeys }),
322
- });
323
- }
324
-
325
- function decryptForRead(row: Record<string, unknown>): Record<string, unknown> {
326
- if (!hasEncryptedFields) return row;
327
- return decryptEntityFieldValues(row, encryptedFields, encryptionProvider());
328
- }
329
-
330
- function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
331
- if (Object.keys(fieldDefaults).length === 0) return payload;
332
- const result: Record<string, unknown> = { ...payload };
333
- for (const [name, def] of Object.entries(fieldDefaults)) {
334
- if (result[name] === undefined) result[name] = def;
335
- }
336
- return result;
337
- }
338
-
339
- function stripSensitive(payload: Record<string, unknown> | undefined): Record<string, unknown> {
340
- if (!payload) return {};
341
- if (sensitiveFields.size === 0) return payload;
342
- const result: Record<string, unknown> = {};
343
- for (const [key, value] of Object.entries(payload)) {
344
- if (sensitiveFields.has(key)) continue;
345
- result[key] = value;
346
- }
347
- return result;
348
- }
349
-
350
- function idFilter(id: EntityId): WhereObject {
351
- const filter: WhereObject = { id };
352
- if (softDelete && table["isDeleted"]) filter["isDeleted"] = false;
353
- return filter;
354
- }
355
-
356
- async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
357
- const row = await db.fetchOne(table, idFilter(id));
358
- if (!row) return null;
359
- return decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
360
- }
361
-
362
- // Archive guard for the CRUD write paths. Archived streams are read-only —
363
- // ctx.appendEvent (append-event-core) already enforces this, but the
364
- // executor appends directly via append() and getStreamVersion() ignores
365
- // the archive flag, so without this check a PATCH/DELETE on an archived
366
- // entity would silently land an event and break the read-only contract
367
- // (loadAggregate returns [] for the same stream). Throws ArchivedStreamError
368
- // to mirror the appendEvent path exactly — same 500 + rolled-back tx.
369
- // Creates skip this: a fresh UUID can't be archived, and a deterministic-id
370
- // re-create onto an archived stream collides on the unique index →
371
- // version_conflict, which already blocks the write.
372
- async function assertStreamWritable(
373
- db: TenantDb,
374
- id: EntityId,
375
- tenantId: TenantId,
376
- ): Promise<void> {
377
- if (await isStreamArchived(db.raw, tenantId, String(id))) {
378
- throw new ArchivedStreamError(tenantId, String(id));
379
- }
380
- }
381
-
382
- // SELECT a row by id with the ownership clause applied at the DB layer.
383
- // Detail() uses this both on cold path and as a cache-revalidation probe.
384
- async function loadWithOwnership(
385
- db: TenantDb,
386
- idWhere: WhereObject,
387
- ownership:
388
- | { kind: "pass" }
389
- | { kind: "empty" }
390
- | { kind: "sql"; sqlText: string; params: readonly unknown[] },
391
- ): Promise<Record<string, unknown>[]> {
392
- if (ownership.kind === "empty") return [];
393
- if (ownership.kind === "pass") {
394
- const row = await db.fetchOne(table, idWhere);
395
- return row ? [row as Record<string, unknown>] : [];
396
- }
397
- // ownership has raw SQL — splice it into a raw query alongside the
398
- // idFilter + tenant-filter that TenantDb would have added.
399
- const tableName = String(
400
- (table as unknown as Record<symbol, unknown>)[Symbol.for("kumiko:schema:Name")],
401
- );
402
- const colSql = (field: string): string =>
403
- `"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
404
- const whereParts: string[] = [];
405
- const params: unknown[] = [];
406
- if (table["tenantId"] !== undefined && db.mode === "tenant") {
407
- params.push(db.tenantId, SYSTEM_TENANT_ID);
408
- whereParts.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
409
- }
410
- for (const [field, value] of Object.entries(idWhere)) {
411
- if (typeof value === "boolean") {
412
- whereParts.push(`${colSql(field)} = ${value ? "TRUE" : "FALSE"}`);
413
- } else {
414
- params.push(value);
415
- whereParts.push(`${colSql(field)} = $${params.length}`);
416
- }
417
- }
418
- const shifted = shiftParams(ownership, params.length);
419
- whereParts.push(shifted.sqlText);
420
- for (const p of shifted.params) params.push(p);
421
- const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
422
- return [...(await executeRawQuery<Record<string, unknown>>(db.raw, sqlText, params))];
423
- }
424
-
45
+ const ctx = buildExecutorContext(table, entity, options);
425
46
  return {
426
- async create(payload, user, db) {
427
- // Respect an explicit id in the payload (seed pattern, SCIM import). Without
428
- // one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
429
- // event payload so defaults + downstream consumers don't see a redundant id field.
430
- const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
431
- const aggregateId = explicitId ?? generateId();
432
- const { id: _id, ...payloadWithoutId } = payload;
433
- const data = applyDefaults(payloadWithoutId);
434
-
435
- // H.2 — entity-level write-ownership on create. No oldRow exists, so
436
- // only the new row is checked. No Straddle concern for creates.
437
- if (!userCanCreateFieldRow(user, entity.access?.write, data)) {
438
- return writeFailure(
439
- new UnprocessableError("ownership_denied", {
440
- i18nKey: "errors.ownershipDenied",
441
- details: { scope: "entity", entityName, action: "create", userId: user.id },
442
- }),
443
- );
444
- }
445
-
446
- // Field-level write-ownership on create — mirror of entity-level but
447
- // per declared field. Role-level was already checked by the
448
- // dispatcher; here we enforce ownership-rules against the new row.
449
- const fieldDeniedCreate = checkWriteFieldOwnership(entity, data, user);
450
- if (fieldDeniedCreate) {
451
- return writeFailure(
452
- new UnprocessableError("ownership_denied", {
453
- i18nKey: "errors.ownershipDenied",
454
- details: {
455
- scope: "field",
456
- entityName,
457
- action: "create",
458
- field: fieldDeniedCreate,
459
- userId: user.id,
460
- },
461
- }),
462
- );
463
- }
464
-
465
- // Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
466
- // dieselbe Pipeline. Caller schickt combined API-Form, Framework
467
- // speichert flat DB-Form. Siehe db/compound-types.ts.
468
- const flatData = encryptForStorage(flattenCompoundTypes(data, entity));
469
-
470
- // 1. Append event (same TX as the projection write — both must succeed
471
- // or both roll back; the dispatcher wraps both in one transaction).
472
- // Sensitive fields are stripped from the event payload; the entity
473
- // row below still receives the full data.
474
- //
475
- // `expectedVersion: 0` heißt: stream existiert noch nicht. Bei
476
- // deterministic-aggregate-id-Patterns (z.B. uuidv5(tenantId|naturalKey))
477
- // ist es legitim dass create kollidiert — selbe id, schon vorhandener
478
- // stream → version_conflict statt internal_error. Update hat den
479
- // selben catch (siehe line 493+).
480
- let event: Awaited<ReturnType<typeof append>>;
481
- try {
482
- event = await append(db.raw, {
483
- aggregateId,
484
- aggregateType: entityName,
485
- tenantId: streamTenantFor(user),
486
- expectedVersion: 0,
487
- type: entityEventName(entityName, "created"),
488
- payload: stripSensitive(flatData),
489
- metadata: buildEventMetadata(user),
490
- });
491
- } catch (e) {
492
- if (e instanceof EventStoreVersionConflict) {
493
- // Try to look up the real stream-version for the diagnostic — but
494
- // wrap defensively: when `append` raised the unique-violation, the
495
- // current TX is already aborted, and a second query on the same
496
- // runner would re-throw "current transaction is aborted". Update-
497
- // path doesn't have this problem (it queries getStreamVersion
498
- // BEFORE the try-block). Falling back to a sentinel keeps the
499
- // version_conflict mapping reliable; the actual current version
500
- // is recoverable client-side via a fresh detail-query if needed.
501
- let currentVersion = -1;
502
- try {
503
- currentVersion = await getStreamVersion(db.raw, aggregateId, streamTenantFor(user));
504
- } catch {
505
- // Aborted TX or any lookup failure — keep the sentinel.
506
- }
507
- return writeFailure(
508
- new FrameworkVersionConflict({
509
- entityId: aggregateId,
510
- expectedVersion: 0,
511
- currentVersion,
512
- }),
513
- );
514
- }
515
- throw e;
516
- }
517
-
518
- // 2. Update projection via applyEntityEvent — derselbe Code-Pfad den
519
- // rebuildProjection für Replay nutzt → Live==Rebuild by-construction.
520
- // Wir bauen ein "live event" mit unstripped flatData (damit sensitive
521
- // Felder in der Read-Tabelle landen, aber nicht im Event-Log).
522
- //
523
- // F8-Patch: app-level unique-violations (z.B. (tenantId, email)
524
- // auf User-Entity, (tenantId, slug) auf Article) werfen pg-23505
525
- // aus der projection-INSERT. Ohne den catch propagiert das als
526
- // unhandled exception → 500 internal_error. Map auf
527
- // UniqueViolationError 409 damit Designer/Frontend einen sauberen
528
- // "duplicate" zeigen können statt cryptic "internal server error".
529
- const liveEvent = { ...event, payload: flatData };
530
- let result: Awaited<ReturnType<typeof applyEntityEvent>>;
531
- try {
532
- result = await applyEntityEvent(liveEvent, table, entity, db.raw);
533
- } catch (e) {
534
- const mapped = tryMapUniqueViolation(e, entityName);
535
- if (mapped) return mapped;
536
- throw e;
537
- }
538
- if (result.kind !== "applied" || result.row === null) {
539
- return writeFailure(new InternalError({ message: "projection insert returned no row" }));
540
- }
541
- const row = result.row;
542
- // Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
543
- // Compound-Types in einem Pass.
544
- const projection = decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
545
-
546
- if (entityCache && entityName) {
547
- await entityCache.del(user.tenantId, entityName, aggregateId);
548
- }
549
-
550
- return {
551
- isSuccess: true,
552
- data: {
553
- kind: "save",
554
- id: aggregateId,
555
- data: projection,
556
- changes: data,
557
- previous: {},
558
- isNew: true,
559
- entityName,
560
- event,
561
- },
562
- };
563
- },
564
-
565
- async update(payload, user, db, updateOptions) {
566
- const previous = await loadById(payload.id, db);
567
- if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
568
-
569
- // H.2 — entity-level write-ownership on update. Load old row (already
570
- // done above), build post-change row via shallow merge. Straddle-safe
571
- // multi-role check: at least one role must accept BOTH old and new —
572
- // prevents the attack where role A passes old, role B passes new and
573
- // aggregation would wrongly allow a row-grab.
574
- const mergedNew: Record<string, unknown> = { ...previous, ...payload.changes };
575
- if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
576
- return writeFailure(
577
- new UnprocessableError("ownership_denied", {
578
- i18nKey: "errors.ownershipDenied",
579
- details: {
580
- scope: "entity",
581
- entityName,
582
- action: "update",
583
- userId: user.id,
584
- entityId: payload.id,
585
- },
586
- }),
587
- );
588
- }
589
-
590
- // Field-level write-ownership on update — this is the path the
591
- // dispatcher could not evaluate (no oldRow). Now that we have
592
- // `previous`, we can run the ownership rules per field against both
593
- // sides and reject individual fields the user isn't entitled to
594
- // touch on this specific row.
595
- const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, user, previous);
596
- if (fieldDeniedUpdate) {
597
- return writeFailure(
598
- new UnprocessableError("ownership_denied", {
599
- i18nKey: "errors.ownershipDenied",
600
- details: {
601
- scope: "field",
602
- entityName,
603
- action: "update",
604
- field: fieldDeniedUpdate,
605
- userId: user.id,
606
- entityId: payload.id,
607
- },
608
- }),
609
- );
610
- }
611
-
612
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
613
-
614
- // Stream-version is authoritative, not row.version. `ctx.appendEvent`
615
- // can bump the stream between CRUD writes (domain event on the same
616
- // aggregate); a stale row.version here would make the next CRUD write
617
- // trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
618
- // with version_conflict.
619
- const currentVersion = await getStreamVersion(
620
- db.raw,
621
- String(payload.id),
622
- streamTenantFor(user),
623
- );
624
- if (!updateOptions?.skipOptimisticLock) {
625
- if (payload.version === undefined) {
626
- return writeFailure(
627
- new FrameworkVersionConflict({
628
- entityId: payload.id,
629
- expectedVersion: 0,
630
- currentVersion,
631
- }),
632
- );
633
- }
634
- if (currentVersion !== payload.version) {
635
- return writeFailure(
636
- new FrameworkVersionConflict({
637
- entityId: payload.id,
638
- expectedVersion: payload.version,
639
- currentVersion,
640
- }),
641
- );
642
- }
643
- }
644
-
645
- try {
646
- // Compound-Types Auto-Convert (alle in einem Pass).
647
- const flatChanges = encryptForStorage(
648
- flattenCompoundTypes(payload.changes, entity),
649
- Object.keys(payload.changes),
650
- );
651
-
652
- // The event payload carries BOTH `changes` (what the user asked for) AND
653
- // `previous` (the pre-update row). Cross-aggregate projections need the
654
- // previous value to decrement/undo when a parent-FK moves — without it
655
- // you'd have to snapshot-and-diff on every apply, and replays would
656
- // break. Storage cost is acceptable (rows are bounded), correctness is
657
- // not negotiable. Sensitive fields are stripped from BOTH halves so
658
- // they never reach the immutable event log. `previous` came from
659
- // loadById(), which decrypts — re-encrypt it before it's persisted so
660
- // an `encrypted` field's plaintext doesn't land in the immutable log
661
- // (flatChanges is already ciphertext from encryptForStorage above).
662
- const event = await append(db.raw, {
663
- aggregateId: String(payload.id),
664
- aggregateType: entityName,
665
- tenantId: streamTenantFor(user),
666
- expectedVersion: currentVersion,
667
- type: entityEventName(entityName, "updated"),
668
- payload: {
669
- changes: stripSensitive(flatChanges),
670
- previous: stripSensitive(encryptForStorage(previous)),
671
- },
672
- metadata: buildEventMetadata(user),
673
- });
674
-
675
- // Live==Rebuild via applyEntityEvent: live-event mit unstripped
676
- // flatChanges damit sensitive Felder in der Read-Tabelle landen.
677
- //
678
- // F8-Patch: dasselbe unique-violation-handling wie im create-Pfad
679
- // — ein update das einen unique-Index verletzt (z.B. email-update
680
- // auf einen schon-existierenden Wert) wird mit 409 unique_violation
681
- // statt 500 internal_error rückgemeldet.
682
- const liveEvent = {
683
- ...event,
684
- payload: { changes: flatChanges, previous },
685
- };
686
- let result: Awaited<ReturnType<typeof applyEntityEvent>>;
687
- try {
688
- result = await applyEntityEvent(liveEvent, table, entity, db.raw);
689
- } catch (e) {
690
- const mapped = tryMapUniqueViolation(e, entityName);
691
- if (mapped) return mapped;
692
- throw e;
693
- }
694
- if (result.kind !== "applied" || result.row === null) {
695
- return writeFailure(new InternalError({ message: "projection update returned no row" }));
696
- }
697
- const row = result.row;
698
- const data = decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
699
-
700
- if (entityCache && entityName) {
701
- await entityCache.del(user.tenantId, entityName, payload.id);
702
- }
703
-
704
- return {
705
- isSuccess: true,
706
- data: {
707
- kind: "save",
708
- id: data["id"] as EntityId, // @cast-boundary engine-payload
709
- data,
710
- changes: payload.changes,
711
- previous,
712
- isNew: false,
713
- entityName,
714
- event,
715
- },
716
- };
717
- } catch (e) {
718
- // The pre-check above eliminates the common stale-version case; this
719
- // branch catches the narrow race where two writers both read version=N
720
- // and both pass the local check — the unique index on (aggregate_id,
721
- // version) serializes them, one wins, the other lands here.
722
- if (e instanceof EventStoreVersionConflict) {
723
- return writeFailure(
724
- new FrameworkVersionConflict({
725
- entityId: payload.id,
726
- expectedVersion: payload.version ?? 0,
727
- currentVersion,
728
- }),
729
- );
730
- }
731
- throw e;
732
- }
733
- },
734
-
735
- async delete(payload, user, db) {
736
- const existing = await loadById(payload.id, db);
737
- if (!existing) return writeFailure(new NotFoundError(entityName, payload.id));
738
-
739
- // H.2 — entity-level write-ownership on delete. Only the pre-delete
740
- // row matters (there's no "new" row for a delete); passing existing
741
- // twice to userCanWriteFieldRow makes the Straddle check trivial
742
- // (same row on both sides) while keeping the multi-role-atomic shape.
743
- if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
744
- return writeFailure(
745
- new UnprocessableError("ownership_denied", {
746
- i18nKey: "errors.ownershipDenied",
747
- details: {
748
- scope: "entity",
749
- entityName,
750
- action: "delete",
751
- userId: user.id,
752
- entityId: payload.id,
753
- },
754
- }),
755
- );
756
- }
757
-
758
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
759
-
760
- // Stream-version authoritative (see update() for rationale).
761
- const currentVersion = await getStreamVersion(
762
- db.raw,
763
- String(payload.id),
764
- streamTenantFor(user),
765
- );
766
-
767
- // Deletes carry the full pre-delete row as `previous`. That's what
768
- // projections and downstream consumers need to reverse any aggregates —
769
- // a `{}`-payload delete would make cross-aggregate projections impossible
770
- // to rebuild from the event log alone. Sensitive fields are stripped.
771
- // `existing` came from loadById(), which decrypts — re-encrypt before
772
- // persisting so an `encrypted` field's plaintext doesn't land in the
773
- // immutable log.
774
- const event = await append(db.raw, {
775
- aggregateId: String(payload.id),
776
- aggregateType: entityName,
777
- tenantId: streamTenantFor(user),
778
- expectedVersion: currentVersion,
779
- type: entityEventName(entityName, "deleted"),
780
- payload: { previous: stripSensitive(encryptForStorage(existing)) },
781
- metadata: buildEventMetadata(user),
782
- });
783
-
784
- // Live==Rebuild via applyEntityEvent. Delete-Operation hat keine
785
- // sensitive-Drift weil das Event-Payload nur `previous` ist und das
786
- // wird vom soft/hard-delete-Code gar nicht in die Tabelle geschrieben
787
- // (nur isDeleted/deletedAt/version-Bump). Live + Replay schreiben
788
- // dasselbe — kein payload-override nötig.
789
- const deleteResult = await applyEntityEvent(event, table, entity, db.raw);
790
- if (deleteResult.kind !== "applied") {
791
- return writeFailure(
792
- new InternalError({ message: "projection delete: applyEntityEvent skipped" }),
793
- );
794
- }
795
-
796
- if (entityCache && entityName) {
797
- await entityCache.del(user.tenantId, entityName, payload.id);
798
- }
799
-
800
- return {
801
- isSuccess: true,
802
- data: { kind: "delete", id: payload.id, data: existing, entityName, event },
803
- };
804
- },
805
-
806
- // Hard-purge (Art. 17). Same shape as delete(), but emits `forgotten` which
807
- // hard-deletes the row regardless of softDelete — and, being an auto-verb,
808
- // the erasure replays on rebuild (created → forgotten → row gone). Loads
809
- // without the isDeleted filter so trashed (soft-deleted) rows are erased too.
810
- async forget(payload, user, db) {
811
- const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
812
- if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
813
- const existing = decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
814
-
815
- if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
816
- return writeFailure(
817
- new UnprocessableError("ownership_denied", {
818
- i18nKey: "errors.ownershipDenied",
819
- details: {
820
- scope: "entity",
821
- entityName,
822
- action: "delete",
823
- userId: user.id,
824
- entityId: payload.id,
825
- },
826
- }),
827
- );
828
- }
829
-
830
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
831
- const currentVersion = await getStreamVersion(
832
- db.raw,
833
- String(payload.id),
834
- streamTenantFor(user),
835
- );
836
-
837
- const event = await append(db.raw, {
838
- aggregateId: String(payload.id),
839
- aggregateType: entityName,
840
- tenantId: streamTenantFor(user),
841
- expectedVersion: currentVersion,
842
- type: entityEventName(entityName, "forgotten"),
843
- payload: { previous: stripSensitive(existing) },
844
- metadata: buildEventMetadata(user),
845
- });
846
-
847
- const forgetResult = await applyEntityEvent(event, table, entity, db.raw);
848
- if (forgetResult.kind !== "applied") {
849
- return writeFailure(
850
- new InternalError({ message: "projection forget: applyEntityEvent skipped" }),
851
- );
852
- }
853
-
854
- if (entityCache && entityName) {
855
- await entityCache.del(user.tenantId, entityName, payload.id);
856
- }
857
-
858
- return {
859
- isSuccess: true,
860
- data: { kind: "delete", id: payload.id, data: existing, entityName, event },
861
- };
862
- },
863
-
864
- async restore(payload, user, db) {
865
- if (!softDelete) {
866
- return writeFailure(
867
- new UnprocessableError("soft_delete_not_enabled", {
868
- i18nKey: "errors.softDeleteNotEnabled",
869
- }),
870
- );
871
- }
872
-
873
- const [row] = await selectMany(db.raw, table, { id: payload.id });
874
- if (!row) return writeFailure(new NotFoundError(entityName, payload.id));
875
- const data = row as DbRow;
876
- if (!data["isDeleted"]) {
877
- return writeFailure(
878
- new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" }),
879
- );
880
- }
881
-
882
- // H.2 — entity-level write-ownership on restore. Same shape as delete:
883
- // only the stored row matters. Stored row carries pre-soft-delete
884
- // teamId/... fields, so the ownership predicate still applies cleanly.
885
- if (!userCanWriteFieldRow(user, entity.access?.write, data, data)) {
886
- return writeFailure(
887
- new UnprocessableError("ownership_denied", {
888
- i18nKey: "errors.ownershipDenied",
889
- details: {
890
- scope: "entity",
891
- entityName,
892
- action: "restore",
893
- userId: user.id,
894
- entityId: payload.id,
895
- },
896
- }),
897
- );
898
- }
899
-
900
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
901
-
902
- // Stream-version authoritative (see update() for rationale).
903
- const currentVersion = await getStreamVersion(
904
- db.raw,
905
- String(payload.id),
906
- streamTenantFor(user),
907
- );
908
- // Restore carries the soft-deleted snapshot as `previous` — mirror of
909
- // delete for symmetry. Projections that decremented on delete use
910
- // `previous` to re-increment on restore without re-querying the entity
911
- // table. Sensitive fields are stripped.
912
- const event = await append(db.raw, {
913
- aggregateId: String(payload.id),
914
- aggregateType: entityName,
915
- tenantId: streamTenantFor(user),
916
- expectedVersion: currentVersion,
917
- type: entityEventName(entityName, "restored"),
918
- payload: { previous: stripSensitive(data) },
919
- metadata: buildEventMetadata(user),
920
- });
921
-
922
- // Live==Rebuild via applyEntityEvent. Restore schreibt nur isDeleted=
923
- // false + version-Bump in die Tabelle — keine sensitive-Drift, daher
924
- // kein payload-override nötig.
925
- const restoreResult = await applyEntityEvent(event, table, entity, db.raw);
926
- if (restoreResult.kind !== "applied" || restoreResult.row === null) {
927
- return writeFailure(new InternalError({ message: "projection restore returned no row" }));
928
- }
929
- const restored = restoreResult.row;
930
-
931
- if (entityCache && entityName) {
932
- await entityCache.del(user.tenantId, entityName, payload.id);
933
- }
934
-
935
- // Read-Side Auto-Convert für Compound-Types (parallel zu update/list).
936
- // decryptForRead matches create/update/list/detail: the caller-facing
937
- // row and `previous` snapshot must be plaintext for `encrypted` fields,
938
- // same as every other executor method — `data`/`restored` are raw rows
939
- // (selectMany / applyEntityEvent), never decrypted before this point.
940
- const restoredHydrated = decryptForRead(
941
- rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
942
- );
943
-
944
- return {
945
- isSuccess: true,
946
- data: {
947
- kind: "save",
948
- id: payload.id,
949
- data: restoredHydrated,
950
- changes: { isDeleted: false },
951
- previous: decryptForRead(data),
952
- isNew: false,
953
- entityName,
954
- event,
955
- },
956
- };
957
- },
958
-
959
- // list + detail are unchanged from crud-executor — projections are the
960
- // read-model and serve these queries directly.
961
- async list(payload, user, db, runtimeOptions) {
962
- const limit = payload.limit ?? 50;
963
- const offset = payload.offset ?? 0;
964
- const totalCount = payload.totalCount === true;
965
-
966
- // H.2 — entity-level read ownership. Decide before touching search or
967
- // the DB: `empty` means there's no row the user could ever see, so
968
- // skip both paths and return an empty page.
969
- const ownership = buildOwnershipClause(user, entity.access?.read, table);
970
- if (ownership.kind === "empty") {
971
- return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
972
- }
973
-
974
- let filterIds: EntityId[] | undefined;
975
- // Build-Time options.searchAdapter gewinnt; runtime-Override ist
976
- // Fallback für die defaultEntityQueryHandler-Pipe (die nutzt den
977
- // ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
978
- // beim Definition-Time noch keinen Server-Context hat).
979
- const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
980
- if (payload.search && effectiveSearchAdapter && entityName) {
981
- const results = await effectiveSearchAdapter.search(user.tenantId, payload.search, {
982
- filterType: entityName,
983
- });
984
- filterIds = results.map((r) => r.entityId);
985
- if (filterIds.length === 0) {
986
- return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
987
- }
988
- }
989
-
990
- // Build the WHERE clause as raw SQL — ownership produces a
991
- // parameterised fragment that we splice in alongside simple WhereObject
992
- // conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
993
- const tableName = String(
994
- (table as unknown as Record<symbol, unknown>)[Symbol.for("kumiko:schema:Name")],
995
- );
996
- const whereSql: string[] = [];
997
- const params: unknown[] = [];
998
- const colSql = (field: string): string =>
999
- `"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
1000
-
1001
- // Tenant-Filter (replicates TenantDb's readWhere semantics).
1002
- if (table["tenantId"] !== undefined && db.mode === "tenant") {
1003
- params.push(db.tenantId, SYSTEM_TENANT_ID);
1004
- whereSql.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
1005
- }
1006
- if (softDelete && table["isDeleted"] && runtimeOptions?.includeDeleted !== true) {
1007
- whereSql.push(`${colSql("isDeleted")} = FALSE`);
1008
- }
1009
- if (payload.cursor) {
1010
- params.push(decodeCursor(payload.cursor));
1011
- whereSql.push(`${colSql("id")} > $${params.length}`);
1012
- }
1013
- if (filterIds) {
1014
- const placeholders = filterIds.map((id) => {
1015
- params.push(id);
1016
- return `$${params.length}`;
1017
- });
1018
- whereSql.push(`${colSql("id")} IN (${placeholders.join(", ")})`);
1019
- }
1020
- if (ownership.kind === "sql") {
1021
- const shifted = shiftParams(
1022
- { sqlText: ownership.sqlText, params: ownership.params },
1023
- params.length,
1024
- );
1025
- whereSql.push(shifted.sqlText);
1026
- for (const p of shifted.params) params.push(p);
1027
- }
1028
- const applyFilter = (f: {
1029
- readonly field: string;
1030
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
1031
- readonly value: unknown;
1032
- }): void => {
1033
- if (table[f.field] === undefined) {
1034
- // skip: unknown field — not a real column, drop the filter (injection guard)
1035
- return;
1036
- }
1037
- const screen = buildFilterWhere(f.field, f.op, f.value);
1038
- if (screen === null) {
1039
- whereSql.push("FALSE");
1040
- // skip: filter is unsatisfiable → emit FALSE, no params to bind
1041
- return;
1042
- }
1043
- for (const [field, value] of Object.entries(screen)) {
1044
- if (Array.isArray(value)) {
1045
- const placeholders = value.map((v) => {
1046
- params.push(v);
1047
- return `$${params.length}`;
1048
- });
1049
- whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
1050
- } else if (typeof value === "object" && value !== null) {
1051
- const opMap: Record<string, string> = {
1052
- gt: ">",
1053
- gte: ">=",
1054
- lt: "<",
1055
- lte: "<=",
1056
- ne: "<>",
1057
- };
1058
- for (const [opKey, opSym] of Object.entries(opMap)) {
1059
- if (!(opKey in value)) continue;
1060
- params.push((value as Record<string, unknown>)[opKey]);
1061
- whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
1062
- }
1063
- } else {
1064
- params.push(value);
1065
- whereSql.push(`${colSql(field)} = $${params.length}`);
1066
- }
1067
- }
1068
- };
1069
- if (payload.filter !== undefined) applyFilter(payload.filter);
1070
- if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
1071
-
1072
- const orderByClause =
1073
- payload.sort && table[payload.sort]
1074
- ? ` ORDER BY ${colSql(payload.sort)} ${payload.sortDirection === "desc" ? "DESC" : "ASC"}`
1075
- : "";
1076
- const useOffset = !payload.cursor && offset > 0;
1077
- const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
1078
-
1079
- const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
1080
- const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
1081
-
1082
- const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
1083
- // Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen
1084
- const tableInfo = extractTableInfo(table);
1085
- const rows = rawRows.map((r) =>
1086
- coerceRow(decryptForRead(rehydrateCompoundTypes(r, entity)), tableInfo),
1087
- );
1088
-
1089
- // list rows carry the READ-ROW version (display-only), never an optimistic-lock
1090
- // base — edit flows reload via detail(), which reconciles the stream version.
1091
- if (entityCache && entityName && rows.length > 0) {
1092
- await entityCache.mset(
1093
- user.tenantId,
1094
- entityName,
1095
- rows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
1096
- );
1097
- }
1098
-
1099
- const lastRow = rows[rows.length - 1];
1100
- const nextCursor =
1101
- rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null; // @cast-boundary engine-payload
1102
-
1103
- // total: extra COUNT(*) — nur wenn explizit angefordert (Pager-UI).
1104
- // Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
1105
- // wie der entsprechende WHERE — bei indexed columns billig genug.
1106
- // Bei Search-Path ist `total = filterIds.length` ohne extra Query.
1107
- let total: number | undefined;
1108
- if (totalCount) {
1109
- if (filterIds) {
1110
- total = filterIds.length;
1111
- } else {
1112
- const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
1113
- const countRows = await executeRawQuery<{ count: number }>(db.raw, countSql, params);
1114
- total = countRows[0]?.count ?? 0;
1115
- }
1116
- }
1117
-
1118
- return { rows, nextCursor, ...(total !== undefined && { total }) };
1119
- },
1120
-
1121
- async detail(payload, user, db) {
1122
- // H.2 — ownership check. `empty` → the user can never see this row
1123
- // regardless of its id. Return null (same shape as "not found", so a
1124
- // probing attacker can't distinguish "no access" from "doesn't exist").
1125
- const ownership = buildOwnershipClause(user, entity.access?.read, table);
1126
- if (ownership.kind === "empty") return null;
1127
-
1128
- const idWhere = idFilter(payload.id);
1129
-
1130
- // Stream-version authoritative (same policy as update/Block 0):
1131
- // ctx.appendEvent (lifecycle-writes like incident:post-update) bumps
1132
- // the stream WITHOUT touching row.version — a detail-read that hands
1133
- // out the stale row.version dooms the next CRUD update built on it
1134
- // (entityEdit loads detail.version as its optimistic-lock base) to a
1135
- // guaranteed version_conflict.
1136
- const withStreamVersion = async (
1137
- row: Record<string, unknown>,
1138
- ): Promise<Record<string, unknown>> => {
1139
- const streamVersion = await getStreamVersion(
1140
- db.raw,
1141
- String(payload.id),
1142
- streamTenantFor(user),
1143
- );
1144
- return streamVersion > 0 ? { ...row, version: streamVersion } : row;
1145
- };
1146
-
1147
- if (entityCache && entityName) {
1148
- const cached = await entityCache.get(user.tenantId, entityName, payload.id);
1149
- if (cached) {
1150
- if (ownership.kind === "sql") {
1151
- // Re-check ownership predicate against the live row — the cache
1152
- // is keyed only by tenant + id, not by role.
1153
- const checkRows = await loadWithOwnership(db, idWhere, ownership);
1154
- if (checkRows.length === 0) return null;
1155
- }
1156
- // Cached rows are stored re-encrypted (see the `set` below) so an
1157
- // `encrypted` field's plaintext never sits in a second at-rest
1158
- // store (Redis) the field-encryption feature doesn't cover.
1159
- return withStreamVersion(decryptForRead(cached));
1160
- }
1161
- }
1162
-
1163
- const rows = await loadWithOwnership(db, idWhere, ownership);
1164
- const raw = rows[0];
1165
- if (!raw) return null;
1166
- const row = decryptForRead(rehydrateCompoundTypes(raw, entity));
1167
- const rowInfo = extractTableInfo(table);
1168
- const coerced = coerceRow(row, rowInfo);
1169
-
1170
- if (entityCache && entityName) {
1171
- await entityCache.set(user.tenantId, entityName, payload.id, encryptForStorage(coerced));
1172
- }
1173
-
1174
- return withStreamVersion(coerced);
1175
- },
47
+ ...createWriteVerbs(ctx),
48
+ ...createReadVerbs(ctx),
1176
49
  };
1177
50
  }