@cosmicdrift/kumiko-framework 0.1.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 (388) hide show
  1. package/README.md +159 -0
  2. package/package.json +91 -0
  3. package/src/__tests__/anonymous-access.integration.ts +325 -0
  4. package/src/__tests__/error-contract.integration.ts +435 -0
  5. package/src/__tests__/field-access.integration.ts +269 -0
  6. package/src/__tests__/full-stack.integration.ts +914 -0
  7. package/src/__tests__/ownership.integration.ts +449 -0
  8. package/src/__tests__/reference-data.integration.ts +198 -0
  9. package/src/__tests__/transition-guard.integration.ts +340 -0
  10. package/src/api/__tests__/api.test.ts +337 -0
  11. package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
  13. package/src/api/__tests__/batch.integration.ts +404 -0
  14. package/src/api/__tests__/body-limit.test.ts +88 -0
  15. package/src/api/__tests__/csrf-middleware.test.ts +97 -0
  16. package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
  17. package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
  18. package/src/api/__tests__/nested-write.integration.ts +213 -0
  19. package/src/api/__tests__/readiness.test.ts +76 -0
  20. package/src/api/__tests__/request-id-middleware.test.ts +72 -0
  21. package/src/api/__tests__/sse-broker.test.ts +58 -0
  22. package/src/api/__tests__/sse-route.test.ts +112 -0
  23. package/src/api/anonymous-cookie.ts +60 -0
  24. package/src/api/api-constants.ts +64 -0
  25. package/src/api/auth-middleware.ts +418 -0
  26. package/src/api/auth-routes.ts +982 -0
  27. package/src/api/csrf-middleware.ts +77 -0
  28. package/src/api/index.ts +31 -0
  29. package/src/api/jwt.ts +66 -0
  30. package/src/api/observability-middleware.ts +89 -0
  31. package/src/api/readiness.ts +132 -0
  32. package/src/api/request-context.ts +49 -0
  33. package/src/api/request-id-middleware.ts +50 -0
  34. package/src/api/route-registrars.ts +195 -0
  35. package/src/api/routes.ts +135 -0
  36. package/src/api/server.ts +640 -0
  37. package/src/api/sse-broker.ts +71 -0
  38. package/src/api/sse-route.ts +62 -0
  39. package/src/api/tokens.ts +16 -0
  40. package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
  41. package/src/db/__tests__/compound-types.test.ts +114 -0
  42. package/src/db/__tests__/connection-options.test.ts +68 -0
  43. package/src/db/__tests__/cursor.test.ts +41 -0
  44. package/src/db/__tests__/db-helpers.test.ts +369 -0
  45. package/src/db/__tests__/dialect-instant.test.ts +50 -0
  46. package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
  47. package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
  48. package/src/db/__tests__/encryption.test.ts +39 -0
  49. package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
  50. package/src/db/__tests__/event-store-executor.integration.ts +235 -0
  51. package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
  52. package/src/db/__tests__/located-timestamp.test.ts +184 -0
  53. package/src/db/__tests__/money.test.ts +199 -0
  54. package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
  55. package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
  56. package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
  57. package/src/db/__tests__/row-helpers.test.ts +59 -0
  58. package/src/db/__tests__/schema-migration.integration.ts +273 -0
  59. package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
  60. package/src/db/__tests__/table-builder-required.test.ts +216 -0
  61. package/src/db/__tests__/tenant-db.integration.ts +606 -0
  62. package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
  63. package/src/db/apply-entity-event.ts +188 -0
  64. package/src/db/assert-exists-in.ts +59 -0
  65. package/src/db/compound-types.ts +47 -0
  66. package/src/db/connection.ts +104 -0
  67. package/src/db/cursor.ts +83 -0
  68. package/src/db/dialect.ts +109 -0
  69. package/src/db/eagerload.ts +174 -0
  70. package/src/db/encryption.ts +39 -0
  71. package/src/db/event-store-executor.ts +906 -0
  72. package/src/db/index.ts +55 -0
  73. package/src/db/located-timestamp.ts +114 -0
  74. package/src/db/money.ts +120 -0
  75. package/src/db/pg-error.ts +46 -0
  76. package/src/db/reference-data.ts +77 -0
  77. package/src/db/row-helpers.ts +53 -0
  78. package/src/db/schema-inspection.ts +25 -0
  79. package/src/db/table-builder.ts +475 -0
  80. package/src/db/tenant-db.ts +434 -0
  81. package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
  82. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
  83. package/src/engine/__tests__/boot-validator.test.ts +1865 -0
  84. package/src/engine/__tests__/build-app-schema.test.ts +154 -0
  85. package/src/engine/__tests__/claim-keys.test.ts +274 -0
  86. package/src/engine/__tests__/config-helpers.test.ts +236 -0
  87. package/src/engine/__tests__/effective-features.test.ts +86 -0
  88. package/src/engine/__tests__/engine.test.ts +1461 -0
  89. package/src/engine/__tests__/entity-handlers.test.ts +274 -0
  90. package/src/engine/__tests__/event-helpers.test.ts +68 -0
  91. package/src/engine/__tests__/extends-registrar.test.ts +159 -0
  92. package/src/engine/__tests__/factories-long-text.test.ts +84 -0
  93. package/src/engine/__tests__/factories-time.test.ts +158 -0
  94. package/src/engine/__tests__/field-predicates.test.ts +48 -0
  95. package/src/engine/__tests__/hook-phases.test.ts +132 -0
  96. package/src/engine/__tests__/identifiers.test.ts +35 -0
  97. package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
  98. package/src/engine/__tests__/nav.test.ts +267 -0
  99. package/src/engine/__tests__/ownership.test.ts +421 -0
  100. package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
  101. package/src/engine/__tests__/projection-helpers.test.ts +62 -0
  102. package/src/engine/__tests__/projection.test.ts +191 -0
  103. package/src/engine/__tests__/qualified-name.test.ts +264 -0
  104. package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
  105. package/src/engine/__tests__/run-in.test.ts +38 -0
  106. package/src/engine/__tests__/schema-builder.test.ts +380 -0
  107. package/src/engine/__tests__/screen.test.ts +408 -0
  108. package/src/engine/__tests__/state-machine.test.ts +148 -0
  109. package/src/engine/__tests__/system-user.test.ts +57 -0
  110. package/src/engine/__tests__/validation-hooks.test.ts +71 -0
  111. package/src/engine/access.ts +23 -0
  112. package/src/engine/boot-validator.ts +1528 -0
  113. package/src/engine/build-app-schema.ts +125 -0
  114. package/src/engine/config-helpers.ts +115 -0
  115. package/src/engine/constants.ts +85 -0
  116. package/src/engine/create-app.ts +98 -0
  117. package/src/engine/define-feature.ts +702 -0
  118. package/src/engine/define-handler.ts +78 -0
  119. package/src/engine/define-roles.ts +19 -0
  120. package/src/engine/effective-features.ts +87 -0
  121. package/src/engine/entity-handlers.ts +364 -0
  122. package/src/engine/event-helpers.ts +73 -0
  123. package/src/engine/factories.ts +328 -0
  124. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
  125. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
  126. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
  127. package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
  128. package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
  129. package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
  130. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
  131. package/src/engine/feature-ast/extractors.ts +2562 -0
  132. package/src/engine/feature-ast/index.ts +105 -0
  133. package/src/engine/feature-ast/parse.ts +369 -0
  134. package/src/engine/feature-ast/patch.ts +525 -0
  135. package/src/engine/feature-ast/patcher.ts +518 -0
  136. package/src/engine/feature-ast/patterns.ts +434 -0
  137. package/src/engine/feature-ast/render.ts +602 -0
  138. package/src/engine/feature-ast/source-location.ts +45 -0
  139. package/src/engine/field-access.ts +120 -0
  140. package/src/engine/index.ts +254 -0
  141. package/src/engine/ownership.ts +337 -0
  142. package/src/engine/parse-ref-target.ts +22 -0
  143. package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
  144. package/src/engine/pattern-library/index.ts +24 -0
  145. package/src/engine/pattern-library/library.ts +1117 -0
  146. package/src/engine/pattern-library/types.ts +255 -0
  147. package/src/engine/projection-helpers.ts +85 -0
  148. package/src/engine/qualified-name.ts +122 -0
  149. package/src/engine/read-claim.ts +31 -0
  150. package/src/engine/registry.ts +1325 -0
  151. package/src/engine/resolve-config-or-param.ts +153 -0
  152. package/src/engine/run-in.ts +29 -0
  153. package/src/engine/schema-builder.ts +175 -0
  154. package/src/engine/screen-filter-ops.ts +51 -0
  155. package/src/engine/state-machine.ts +70 -0
  156. package/src/engine/system-user.ts +32 -0
  157. package/src/engine/types/config.ts +306 -0
  158. package/src/engine/types/event-type-map.ts +37 -0
  159. package/src/engine/types/feature.ts +574 -0
  160. package/src/engine/types/fields.ts +422 -0
  161. package/src/engine/types/handlers.ts +742 -0
  162. package/src/engine/types/hooks.ts +142 -0
  163. package/src/engine/types/http-route.ts +54 -0
  164. package/src/engine/types/identifiers.ts +47 -0
  165. package/src/engine/types/index.ts +208 -0
  166. package/src/engine/types/nav.ts +46 -0
  167. package/src/engine/types/projection.ts +132 -0
  168. package/src/engine/types/relations.ts +51 -0
  169. package/src/engine/types/screen.ts +452 -0
  170. package/src/engine/types/workspace.ts +42 -0
  171. package/src/engine/validation.ts +33 -0
  172. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
  173. package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
  174. package/src/entrypoint/index.ts +442 -0
  175. package/src/errors/__tests__/classes.test.ts +371 -0
  176. package/src/errors/__tests__/write-failures.test.ts +109 -0
  177. package/src/errors/classes.ts +249 -0
  178. package/src/errors/i18n/de.yaml +83 -0
  179. package/src/errors/i18n/en.yaml +80 -0
  180. package/src/errors/index.ts +41 -0
  181. package/src/errors/kumiko-error.ts +67 -0
  182. package/src/errors/reasons.ts +36 -0
  183. package/src/errors/serialize.ts +136 -0
  184. package/src/errors/transition-details.ts +30 -0
  185. package/src/errors/write-error-info.ts +123 -0
  186. package/src/errors/zod-bridge.ts +49 -0
  187. package/src/event-store/__tests__/admin-api.integration.ts +361 -0
  188. package/src/event-store/__tests__/event-store.integration.ts +584 -0
  189. package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
  190. package/src/event-store/__tests__/perf.integration.ts +255 -0
  191. package/src/event-store/__tests__/snapshot.integration.ts +267 -0
  192. package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
  193. package/src/event-store/__tests__/upcaster.integration.ts +460 -0
  194. package/src/event-store/admin-api.ts +257 -0
  195. package/src/event-store/archive.ts +106 -0
  196. package/src/event-store/errors.ts +35 -0
  197. package/src/event-store/event-store.ts +405 -0
  198. package/src/event-store/events-schema.ts +90 -0
  199. package/src/event-store/index.ts +50 -0
  200. package/src/event-store/snapshot.ts +210 -0
  201. package/src/event-store/upcaster-dead-letter.ts +119 -0
  202. package/src/event-store/upcaster.ts +147 -0
  203. package/src/files/__tests__/content-disposition.test.ts +123 -0
  204. package/src/files/__tests__/file-field-column.integration.ts +103 -0
  205. package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
  206. package/src/files/__tests__/file-handle.test.ts +122 -0
  207. package/src/files/__tests__/files.integration.ts +830 -0
  208. package/src/files/__tests__/storage-tracking.integration.ts +153 -0
  209. package/src/files/content-disposition.ts +55 -0
  210. package/src/files/file-handle.ts +63 -0
  211. package/src/files/file-ref-table.ts +22 -0
  212. package/src/files/file-routes.ts +353 -0
  213. package/src/files/in-memory-provider.ts +62 -0
  214. package/src/files/index.ts +29 -0
  215. package/src/files/local-provider.ts +35 -0
  216. package/src/files/storage-tracking.ts +60 -0
  217. package/src/files/types.ts +118 -0
  218. package/src/i18n/__tests__/i18n.test.ts +72 -0
  219. package/src/i18n/index.ts +29 -0
  220. package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
  221. package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
  222. package/src/jobs/__tests__/jobs.integration.ts +566 -0
  223. package/src/jobs/index.ts +2 -0
  224. package/src/jobs/job-runner.ts +574 -0
  225. package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
  226. package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
  227. package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
  228. package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
  229. package/src/lifecycle/index.ts +13 -0
  230. package/src/lifecycle/lifecycle.ts +160 -0
  231. package/src/lifecycle/signal-handlers.ts +62 -0
  232. package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
  233. package/src/logging/index.ts +3 -0
  234. package/src/logging/pino-logger.ts +64 -0
  235. package/src/logging/types.ts +7 -0
  236. package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
  237. package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
  238. package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
  239. package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
  240. package/src/migrations/index.ts +28 -0
  241. package/src/migrations/projection-detection.ts +149 -0
  242. package/src/migrations/rebuild-marker.ts +64 -0
  243. package/src/migrations/schema-drift.ts +395 -0
  244. package/src/observability/__tests__/console-provider.test.ts +67 -0
  245. package/src/observability/__tests__/metric-validator.test.ts +87 -0
  246. package/src/observability/__tests__/noop-provider.test.ts +82 -0
  247. package/src/observability/__tests__/observability.integration.ts +559 -0
  248. package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
  249. package/src/observability/__tests__/recording-meter.test.ts +101 -0
  250. package/src/observability/__tests__/recording-tracer.test.ts +110 -0
  251. package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
  252. package/src/observability/console-provider.ts +130 -0
  253. package/src/observability/context.ts +26 -0
  254. package/src/observability/fallback.ts +34 -0
  255. package/src/observability/ids.ts +25 -0
  256. package/src/observability/index.ts +79 -0
  257. package/src/observability/metric-validator.ts +86 -0
  258. package/src/observability/metrics-handle.ts +56 -0
  259. package/src/observability/noop-provider.ts +146 -0
  260. package/src/observability/prometheus-meter.ts +284 -0
  261. package/src/observability/recording-meter.ts +156 -0
  262. package/src/observability/recording-tracer.ts +198 -0
  263. package/src/observability/redis-wrapper.ts +132 -0
  264. package/src/observability/sensitive-filter.ts +108 -0
  265. package/src/observability/standard-metrics.ts +213 -0
  266. package/src/observability/types/index.ts +29 -0
  267. package/src/observability/types/metric.ts +56 -0
  268. package/src/observability/types/provider.ts +32 -0
  269. package/src/observability/types/span.ts +64 -0
  270. package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
  271. package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
  272. package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
  273. package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
  274. package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
  275. package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
  276. package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
  277. package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
  278. package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
  279. package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
  280. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
  281. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
  282. package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
  283. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
  284. package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
  285. package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
  286. package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
  287. package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
  288. package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
  289. package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
  290. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
  291. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
  292. package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
  293. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
  294. package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
  295. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
  296. package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
  297. package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
  298. package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
  299. package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
  300. package/src/pipeline/append-event-core.ts +117 -0
  301. package/src/pipeline/auth-claims-resolver.ts +103 -0
  302. package/src/pipeline/cascade-handler.ts +113 -0
  303. package/src/pipeline/dispatcher.ts +1585 -0
  304. package/src/pipeline/distributed-lock.ts +37 -0
  305. package/src/pipeline/entity-cache.ts +113 -0
  306. package/src/pipeline/event-consumer-state.ts +108 -0
  307. package/src/pipeline/event-dedup.ts +23 -0
  308. package/src/pipeline/event-dispatcher.ts +1016 -0
  309. package/src/pipeline/event-retention.ts +154 -0
  310. package/src/pipeline/idempotency.ts +76 -0
  311. package/src/pipeline/index.ts +66 -0
  312. package/src/pipeline/lifecycle-pipeline.ts +409 -0
  313. package/src/pipeline/msp-rebuild.ts +242 -0
  314. package/src/pipeline/multi-stream-apply-context.ts +115 -0
  315. package/src/pipeline/projection-rebuild.ts +334 -0
  316. package/src/pipeline/projection-state.ts +72 -0
  317. package/src/pipeline/projections-runner.ts +56 -0
  318. package/src/pipeline/redis-keys.ts +11 -0
  319. package/src/pipeline/system-hooks.ts +190 -0
  320. package/src/random/__tests__/generate.test.ts +149 -0
  321. package/src/random/generate.ts +141 -0
  322. package/src/random/index.ts +8 -0
  323. package/src/random/words.ts +392 -0
  324. package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
  325. package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
  326. package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
  327. package/src/rate-limit/bucket.ts +36 -0
  328. package/src/rate-limit/index.ts +14 -0
  329. package/src/rate-limit/middleware.ts +152 -0
  330. package/src/rate-limit/resolver.ts +267 -0
  331. package/src/redis/__tests__/redis-options.test.ts +54 -0
  332. package/src/redis/index.ts +74 -0
  333. package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
  334. package/src/search/__tests__/search-adapter.test.ts +256 -0
  335. package/src/search/in-memory-adapter.ts +123 -0
  336. package/src/search/index.ts +12 -0
  337. package/src/search/meilisearch-adapter.ts +106 -0
  338. package/src/search/types.ts +39 -0
  339. package/src/secrets/__tests__/dek-cache.test.ts +213 -0
  340. package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
  341. package/src/secrets/__tests__/envelope.test.ts +74 -0
  342. package/src/secrets/__tests__/leak-guard.test.ts +92 -0
  343. package/src/secrets/__tests__/rotation.test.ts +149 -0
  344. package/src/secrets/dek-cache.ts +116 -0
  345. package/src/secrets/env-master-key-provider.ts +162 -0
  346. package/src/secrets/envelope.ts +55 -0
  347. package/src/secrets/index.ts +19 -0
  348. package/src/secrets/leak-guard.ts +87 -0
  349. package/src/secrets/rotation.ts +34 -0
  350. package/src/secrets/types.ts +107 -0
  351. package/src/stack/db.ts +104 -0
  352. package/src/stack/event-collector.ts +23 -0
  353. package/src/stack/index.ts +32 -0
  354. package/src/stack/redis.ts +44 -0
  355. package/src/stack/request-helper.ts +168 -0
  356. package/src/stack/table-helpers.ts +104 -0
  357. package/src/stack/test-stack.ts +357 -0
  358. package/src/stack/test-users.ts +37 -0
  359. package/src/testing/__tests__/e2e-generator.test.ts +230 -0
  360. package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
  361. package/src/testing/access-assertions.ts +15 -0
  362. package/src/testing/assertions.ts +35 -0
  363. package/src/testing/e2e-generator.ts +465 -0
  364. package/src/testing/expect-error.ts +25 -0
  365. package/src/testing/handler-context.ts +125 -0
  366. package/src/testing/http-cookies.ts +52 -0
  367. package/src/testing/index.ts +41 -0
  368. package/src/testing/late-bound.ts +39 -0
  369. package/src/testing/mutable-master-key-provider.ts +31 -0
  370. package/src/testing/observability-recorder.ts +54 -0
  371. package/src/testing/shared-entities.ts +49 -0
  372. package/src/testing/utils.ts +1 -0
  373. package/src/testing/wait-for.ts +31 -0
  374. package/src/time/__tests__/polyfill.test.ts +73 -0
  375. package/src/time/__tests__/tz-context.test.ts +121 -0
  376. package/src/time/index.ts +21 -0
  377. package/src/time/polyfill.ts +70 -0
  378. package/src/time/tz-context.ts +107 -0
  379. package/src/ui-types/app-schema.ts +57 -0
  380. package/src/ui-types/index.ts +65 -0
  381. package/src/utils/__tests__/assert.test.ts +17 -0
  382. package/src/utils/__tests__/env-parse.test.ts +54 -0
  383. package/src/utils/assert.ts +18 -0
  384. package/src/utils/env-parse.ts +16 -0
  385. package/src/utils/ids.ts +16 -0
  386. package/src/utils/index.ts +5 -0
  387. package/src/utils/safe-json.ts +30 -0
  388. package/src/utils/serialization.ts +7 -0
@@ -0,0 +1,906 @@
1
+ import { and, asc, desc, eq, gt, inArray, lt, ne, type SQL, sql } from "drizzle-orm";
2
+ import { requestContext } from "../api/request-context";
3
+ import { checkWriteFieldOwnership } from "../engine/field-access";
4
+ import {
5
+ buildOwnershipClause,
6
+ userCanCreateFieldRow,
7
+ userCanWriteFieldRow,
8
+ } from "../engine/ownership";
9
+ import type {
10
+ DeleteContext,
11
+ EntityDefinition,
12
+ EntityId,
13
+ FieldDefinition,
14
+ SaveContext,
15
+ SessionUser,
16
+ WriteResult,
17
+ } from "../engine/types";
18
+ import {
19
+ VersionConflictError as FrameworkVersionConflict,
20
+ InternalError,
21
+ NotFoundError,
22
+ UniqueViolationError,
23
+ UnprocessableError,
24
+ type WriteFailure,
25
+ writeFailure,
26
+ } from "../errors";
27
+ import {
28
+ append,
29
+ type EventMetadata,
30
+ VersionConflictError as EventStoreVersionConflict,
31
+ getStreamVersion,
32
+ } from "../event-store";
33
+ import type { EntityCache } from "../pipeline/entity-cache";
34
+ import type { SearchAdapter } from "../search/types";
35
+ import { generateId } from "../utils";
36
+ import { applyEntityEvent } from "./apply-entity-event";
37
+ import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
38
+ import type { DbRow } from "./connection";
39
+ import { decodeCursor, encodeCursor } from "./cursor";
40
+ import type { TableColumns } from "./dialect";
41
+ import type { CursorResult } from "./index";
42
+ import { constraintOf, isUniqueViolation } from "./pg-error";
43
+ import type { TenantDb } from "./tenant-db";
44
+
45
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables
46
+ type Table = TableColumns<any>;
47
+
48
+ // Screen-Filter (Tier 2.7c) — Op-Mapping zur Drizzle-WHERE-Clause.
49
+ // Lebt isoliert hier (statt inline im list-Body) damit der einzige
50
+ // Wire-Boundary `as never`-Cast lokal bleibt: payload.filter.value ist
51
+ // `unknown` (Wire-Boundary), Drizzle's eq/ne/lt/gt/inArray verlangen
52
+ // den Column-Type. Type-Mismatch wirft erst der PostgreSQL-Driver zur
53
+ // Laufzeit; Author hat über `filterable: true` + Boot-Validator op-
54
+ // vs-Type-Compat ohnehin Kontrolle was reinkommt.
55
+ //
56
+ // Empty-array IN ist explizit "no match" (SQL false), nicht "match all".
57
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle-Column ist generic; siehe oben.
58
+ function buildFilterCondition(col: any, op: "eq" | "ne" | "lt" | "gt" | "in", value: unknown): SQL {
59
+ switch (op) {
60
+ case "eq":
61
+ return eq(col, value as never); // @cast-boundary db-operator
62
+ case "ne":
63
+ return ne(col, value as never); // @cast-boundary db-operator
64
+ case "lt":
65
+ return lt(col, value as never); // @cast-boundary db-operator
66
+ case "gt":
67
+ return gt(col, value as never); // @cast-boundary db-operator
68
+ case "in":
69
+ if (Array.isArray(value) && value.length > 0) {
70
+ return inArray(col, value as never); // @cast-boundary db-operator
71
+ }
72
+ return sql`false`;
73
+ }
74
+ }
75
+
76
+ // Returns the scalar default of a field, or undefined if the field's type
77
+ // doesn't carry a default or no default was declared. Only scalar types
78
+ // (text/number/boolean/select) support creation-time defaults — money/date/
79
+ // file/embedded fields don't.
80
+ function scalarDefault(field: FieldDefinition): unknown {
81
+ switch (field.type) {
82
+ case "text":
83
+ case "longText":
84
+ case "number":
85
+ case "boolean":
86
+ case "select":
87
+ return field.default;
88
+ default:
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ // Lifecycle verbs the event-store-executor auto-emits. MSPs that react
94
+ // to entity creates/updates/etc should reference this helper instead of
95
+ // hardcoding the string — a future rename in the executor then surfaces
96
+ // as a type error at every call site rather than a silent miss.
97
+ export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored";
98
+
99
+ export function entityEventName(entityName: string, verb: EntityLifecycleVerb): string {
100
+ return `${entityName}.${verb}`;
101
+ }
102
+
103
+ export type EventStoreExecutorOptions = {
104
+ searchAdapter?: SearchAdapter;
105
+ entityName: string; // required — the aggregateType marker on every event
106
+ entityCache?: EntityCache;
107
+ };
108
+
109
+ // F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
110
+ // (create + update Pfade) → WriteFailure(UniqueViolationError 409).
111
+ // Andere Errors propagieren via re-throw. Lokal extrahiert weil das
112
+ // Pattern an zwei Stellen im executor lebt — der Caller wrap't den
113
+ // applyEntityEvent-call in try-catch und delegiert das Mapping hierher.
114
+ //
115
+ // Returns WriteFailure on match, null otherwise (caller re-throws).
116
+ function tryMapUniqueViolation(e: unknown, entityName: string): WriteFailure | null {
117
+ if (!isUniqueViolation(e)) return null;
118
+ const constraintName = constraintOf(e);
119
+ return writeFailure(
120
+ new UniqueViolationError(
121
+ {
122
+ entityName,
123
+ ...(constraintName !== undefined && { constraintName }),
124
+ },
125
+ { cause: e instanceof Error ? e : undefined },
126
+ ),
127
+ );
128
+ }
129
+
130
+ // Build the metadata envelope for an append. userId always set; requestId +
131
+ // correlation + causation come from the AsyncLocalStorage request-context
132
+ // when present (e.g. HTTP request, MSP-apply, job run). requestId is a pure
133
+ // trace marker — HTTP-level retry idempotency runs separately via
134
+ // pipeline/idempotency.ts (Redis-cached response replay), so a single
135
+ // request can write N events freely without the events-table needing a
136
+ // uniqueness constraint.
137
+ function buildEventMetadata(user: SessionUser): EventMetadata {
138
+ const reqCtx = requestContext.get();
139
+ return {
140
+ userId: String(user.id),
141
+ ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
142
+ ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
143
+ ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
144
+ };
145
+ }
146
+
147
+ // The executor writes events + auto-projection (entity table) in one TX.
148
+ // It no longer knows about user projections — those are driven by the
149
+ // pipeline, which reads the StoredEvent surfaced on SaveContext/DeleteContext
150
+ // and iterates the registry itself. Executor-level `registry` options were
151
+ // removed to close the silent-bypass hole where a caller forgetting to pass
152
+ // one would skip projections without any signal.
153
+ export type EventStoreExecutor = {
154
+ create: (
155
+ payload: Record<string, unknown>,
156
+ user: SessionUser,
157
+ db: TenantDb,
158
+ ) => Promise<WriteResult<SaveContext>>;
159
+
160
+ update: (
161
+ payload: { id: EntityId; version?: number | undefined; changes: Record<string, unknown> },
162
+ user: SessionUser,
163
+ db: TenantDb,
164
+ options?: { skipOptimisticLock?: boolean },
165
+ ) => Promise<WriteResult<SaveContext>>;
166
+
167
+ delete: (
168
+ payload: { id: EntityId },
169
+ user: SessionUser,
170
+ db: TenantDb,
171
+ ) => Promise<WriteResult<DeleteContext>>;
172
+
173
+ restore: (
174
+ payload: { id: EntityId },
175
+ user: SessionUser,
176
+ db: TenantDb,
177
+ ) => Promise<WriteResult<SaveContext>>;
178
+
179
+ list: (
180
+ payload: {
181
+ cursor?: string | undefined;
182
+ limit?: number | undefined;
183
+ search?: string | undefined;
184
+ sort?: string | undefined;
185
+ sortDirection?: "asc" | "desc" | undefined;
186
+ offset?: number | undefined;
187
+ totalCount?: boolean | undefined;
188
+ filter?:
189
+ | {
190
+ readonly field: string;
191
+ readonly op: "eq" | "ne" | "lt" | "gt" | "in";
192
+ readonly value: unknown;
193
+ }
194
+ | undefined;
195
+ },
196
+ user: SessionUser,
197
+ db: TenantDb,
198
+ /** Tier 2.7e Audit-Fix: per-Call SearchAdapter Override. Wenn der
199
+ * Executor beim Build keinen SearchAdapter via Options bekommen
200
+ * hat (defaultEntityQueryHandler-Pfad), kann der Caller (Handler)
201
+ * hier zur Runtime einen aus ctx.searchAdapter durchreichen.
202
+ * options.searchAdapter (build-time) gewinnt — runtime-Override
203
+ * ist Fallback für die default-Wrapper. */
204
+ runtimeOptions?: { readonly searchAdapter?: SearchAdapter },
205
+ ) => Promise<CursorResult<Record<string, unknown>>>;
206
+
207
+ detail: (
208
+ payload: { id: EntityId },
209
+ user: SessionUser,
210
+ db: TenantDb,
211
+ ) => Promise<Record<string, unknown> | null>;
212
+ };
213
+
214
+ export function createEventStoreExecutor(
215
+ table: Table,
216
+ entity: EntityDefinition,
217
+ options: EventStoreExecutorOptions,
218
+ ): EventStoreExecutor {
219
+ const { searchAdapter, entityName, entityCache } = options;
220
+ const softDelete = entity.softDelete ?? false;
221
+
222
+ // idType default (undefined) is now "uuid" — the ES-pivot made UUID the
223
+ // only valid aggregate-id type. Explicit `idType: "serial"` is the only
224
+ // shape that's incompatible with the event-store and still rejected.
225
+ if (entity.idType !== undefined && entity.idType !== "uuid") {
226
+ throw new Error(
227
+ `event-store-executor requires entity "${entityName}" to declare idType: "uuid" — ` +
228
+ `got idType: "${entity.idType}". ` +
229
+ `The events-table keys aggregates by uuid(aggregate_id); non-UUID PKs would ` +
230
+ `require a schema split the framework does not currently support. ` +
231
+ `Fix: remove the \`idType\`-override from createEntity({...}) for "${entityName}" ` +
232
+ `(the default is "uuid"). The framework auto-assigns UUIDs on create — ` +
233
+ `you do not need to generate them yourself. ` +
234
+ `See docs/plans/architecture/event-sourcing-pivot.md (section "UUID-only aggregate IDs") for the full rationale.`,
235
+ );
236
+ }
237
+
238
+ // Pre-compute defaults once so create() doesn't loop the entity every call.
239
+ const fieldDefaults: Record<string, unknown> = {};
240
+ for (const [name, field] of Object.entries(entity.fields)) {
241
+ const def = scalarDefault(field);
242
+ if (def !== undefined) fieldDefaults[name] = def;
243
+ }
244
+
245
+ // Pre-compute the set of sensitive field names once. Every event payload
246
+ // (create data, update changes + previous, delete previous, restore
247
+ // previous) strips these before writing to the immutable event log. Keeps
248
+ // GDPR right-to-be-forgotten tractable — only entity rows hold the
249
+ // sensitive data, and entity rows can be deleted / re-encrypted.
250
+ const sensitiveFields = new Set<string>();
251
+ for (const [name, field] of Object.entries(entity.fields)) {
252
+ if ("sensitive" in field && field.sensitive === true) {
253
+ sensitiveFields.add(name);
254
+ }
255
+ }
256
+
257
+ function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
258
+ if (Object.keys(fieldDefaults).length === 0) return payload;
259
+ const result: Record<string, unknown> = { ...payload };
260
+ for (const [name, def] of Object.entries(fieldDefaults)) {
261
+ if (result[name] === undefined) result[name] = def;
262
+ }
263
+ return result;
264
+ }
265
+
266
+ function stripSensitive(payload: Record<string, unknown> | undefined): Record<string, unknown> {
267
+ if (!payload) return {};
268
+ if (sensitiveFields.size === 0) return payload;
269
+ const result: Record<string, unknown> = {};
270
+ for (const [key, value] of Object.entries(payload)) {
271
+ if (sensitiveFields.has(key)) continue;
272
+ result[key] = value;
273
+ }
274
+ return result;
275
+ }
276
+
277
+ function idFilter(id: EntityId) {
278
+ const conditions = [eq(table["id"], id)];
279
+ if (softDelete && table["isDeleted"]) {
280
+ conditions.push(eq(table["isDeleted"], false));
281
+ }
282
+ // Drizzle's variadic `and()` is typed `SQL | undefined`; conditions is
283
+ // guaranteed non-empty above (we pushed at least one).
284
+ return and(...conditions) as SQL;
285
+ }
286
+
287
+ async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
288
+ const [row] = await db.select().from(table).where(idFilter(id));
289
+ if (!row) return null;
290
+ return rehydrateCompoundTypes(row as DbRow, entity);
291
+ }
292
+
293
+ return {
294
+ async create(payload, user, db) {
295
+ // Respect an explicit id in the payload (seed pattern, SCIM import). Without
296
+ // one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
297
+ // event payload so defaults + downstream consumers don't see a redundant id field.
298
+ const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined;
299
+ const aggregateId = explicitId ?? generateId();
300
+ const { id: _id, ...payloadWithoutId } = payload;
301
+ const data = applyDefaults(payloadWithoutId);
302
+
303
+ // H.2 — entity-level write-ownership on create. No oldRow exists, so
304
+ // only the new row is checked. No Straddle concern for creates.
305
+ if (!userCanCreateFieldRow(user, entity.access?.write, data)) {
306
+ return writeFailure(
307
+ new UnprocessableError("ownership_denied", {
308
+ i18nKey: "errors.ownershipDenied",
309
+ details: { scope: "entity", entityName, action: "create", userId: user.id },
310
+ }),
311
+ );
312
+ }
313
+
314
+ // Field-level write-ownership on create — mirror of entity-level but
315
+ // per declared field. Role-level was already checked by the
316
+ // dispatcher; here we enforce ownership-rules against the new row.
317
+ const fieldDeniedCreate = checkWriteFieldOwnership(entity, data, user);
318
+ if (fieldDeniedCreate) {
319
+ return writeFailure(
320
+ new UnprocessableError("ownership_denied", {
321
+ i18nKey: "errors.ownershipDenied",
322
+ details: {
323
+ scope: "field",
324
+ entityName,
325
+ action: "create",
326
+ field: fieldDeniedCreate,
327
+ userId: user.id,
328
+ },
329
+ }),
330
+ );
331
+ }
332
+
333
+ // Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
334
+ // dieselbe Pipeline. Caller schickt combined API-Form, Framework
335
+ // speichert flat DB-Form. Siehe db/compound-types.ts.
336
+ const flatData = flattenCompoundTypes(data, entity);
337
+
338
+ // 1. Append event (same TX as the projection write — both must succeed
339
+ // or both roll back; the dispatcher wraps both in one transaction).
340
+ // Sensitive fields are stripped from the event payload; the entity
341
+ // row below still receives the full data.
342
+ //
343
+ // `expectedVersion: 0` heißt: stream existiert noch nicht. Bei
344
+ // deterministic-aggregate-id-Patterns (z.B. uuidv5(tenantId|naturalKey))
345
+ // ist es legitim dass create kollidiert — selbe id, schon vorhandener
346
+ // stream → version_conflict statt internal_error. Update hat den
347
+ // selben catch (siehe line 493+).
348
+ let event: Awaited<ReturnType<typeof append>>;
349
+ try {
350
+ event = await append(db.raw, {
351
+ aggregateId,
352
+ aggregateType: entityName,
353
+ tenantId: user.tenantId,
354
+ expectedVersion: 0,
355
+ type: entityEventName(entityName, "created"),
356
+ payload: stripSensitive(flatData),
357
+ metadata: buildEventMetadata(user),
358
+ });
359
+ } catch (e) {
360
+ if (e instanceof EventStoreVersionConflict) {
361
+ // Try to look up the real stream-version for the diagnostic — but
362
+ // wrap defensively: when `append` raised the unique-violation, the
363
+ // current TX is already aborted, and a second query on the same
364
+ // runner would re-throw "current transaction is aborted". Update-
365
+ // path doesn't have this problem (it queries getStreamVersion
366
+ // BEFORE the try-block). Falling back to a sentinel keeps the
367
+ // version_conflict mapping reliable; the actual current version
368
+ // is recoverable client-side via a fresh detail-query if needed.
369
+ let currentVersion = -1;
370
+ try {
371
+ currentVersion = await getStreamVersion(db.raw, aggregateId, user.tenantId);
372
+ } catch {
373
+ // Aborted TX or any lookup failure — keep the sentinel.
374
+ }
375
+ return writeFailure(
376
+ new FrameworkVersionConflict({
377
+ entityId: aggregateId,
378
+ expectedVersion: 0,
379
+ currentVersion,
380
+ }),
381
+ );
382
+ }
383
+ throw e;
384
+ }
385
+
386
+ // 2. Update projection via applyEntityEvent — derselbe Code-Pfad den
387
+ // rebuildProjection für Replay nutzt → Live==Rebuild by-construction.
388
+ // Wir bauen ein "live event" mit unstripped flatData (damit sensitive
389
+ // Felder in der Read-Tabelle landen, aber nicht im Event-Log).
390
+ //
391
+ // F8-Patch: app-level unique-violations (z.B. (tenantId, email)
392
+ // auf User-Entity, (tenantId, slug) auf Article) werfen pg-23505
393
+ // aus der projection-INSERT. Ohne den catch propagiert das als
394
+ // unhandled exception → 500 internal_error. Map auf
395
+ // UniqueViolationError 409 damit Designer/Frontend einen sauberen
396
+ // "duplicate" zeigen können statt cryptic "internal server error".
397
+ const liveEvent = { ...event, payload: flatData };
398
+ let result: Awaited<ReturnType<typeof applyEntityEvent>>;
399
+ try {
400
+ result = await applyEntityEvent(liveEvent, table, entity, db.raw);
401
+ } catch (e) {
402
+ const mapped = tryMapUniqueViolation(e, entityName);
403
+ if (mapped) return mapped;
404
+ throw e;
405
+ }
406
+ if (result.kind !== "applied" || result.row === null) {
407
+ return writeFailure(new InternalError({ message: "projection insert returned no row" }));
408
+ }
409
+ const row = result.row;
410
+ // Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
411
+ // Compound-Types in einem Pass.
412
+ const projection = rehydrateCompoundTypes(row as DbRow, entity) as DbRow;
413
+
414
+ if (entityCache && entityName) {
415
+ await entityCache.del(user.tenantId, entityName, aggregateId);
416
+ }
417
+
418
+ return {
419
+ isSuccess: true,
420
+ data: {
421
+ kind: "save",
422
+ id: aggregateId,
423
+ data: projection,
424
+ changes: data,
425
+ previous: {},
426
+ isNew: true,
427
+ entityName,
428
+ event,
429
+ },
430
+ };
431
+ },
432
+
433
+ async update(payload, user, db, updateOptions) {
434
+ const previous = await loadById(payload.id, db);
435
+ if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
436
+
437
+ // H.2 — entity-level write-ownership on update. Load old row (already
438
+ // done above), build post-change row via shallow merge. Straddle-safe
439
+ // multi-role check: at least one role must accept BOTH old and new —
440
+ // prevents the attack where role A passes old, role B passes new and
441
+ // aggregation would wrongly allow a row-grab.
442
+ const mergedNew: Record<string, unknown> = { ...previous, ...payload.changes };
443
+ if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
444
+ return writeFailure(
445
+ new UnprocessableError("ownership_denied", {
446
+ i18nKey: "errors.ownershipDenied",
447
+ details: {
448
+ scope: "entity",
449
+ entityName,
450
+ action: "update",
451
+ userId: user.id,
452
+ entityId: payload.id,
453
+ },
454
+ }),
455
+ );
456
+ }
457
+
458
+ // Field-level write-ownership on update — this is the path the
459
+ // dispatcher could not evaluate (no oldRow). Now that we have
460
+ // `previous`, we can run the ownership rules per field against both
461
+ // sides and reject individual fields the user isn't entitled to
462
+ // touch on this specific row.
463
+ const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, user, previous);
464
+ if (fieldDeniedUpdate) {
465
+ return writeFailure(
466
+ new UnprocessableError("ownership_denied", {
467
+ i18nKey: "errors.ownershipDenied",
468
+ details: {
469
+ scope: "field",
470
+ entityName,
471
+ action: "update",
472
+ field: fieldDeniedUpdate,
473
+ userId: user.id,
474
+ entityId: payload.id,
475
+ },
476
+ }),
477
+ );
478
+ }
479
+
480
+ // Stream-version is authoritative, not row.version. `ctx.appendEvent`
481
+ // can bump the stream between CRUD writes (domain event on the same
482
+ // aggregate); a stale row.version here would make the next CRUD write
483
+ // trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
484
+ // with version_conflict.
485
+ const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
486
+ if (!updateOptions?.skipOptimisticLock) {
487
+ if (payload.version === undefined) {
488
+ return writeFailure(
489
+ new FrameworkVersionConflict({
490
+ entityId: payload.id,
491
+ expectedVersion: 0,
492
+ currentVersion,
493
+ }),
494
+ );
495
+ }
496
+ if (currentVersion !== payload.version) {
497
+ return writeFailure(
498
+ new FrameworkVersionConflict({
499
+ entityId: payload.id,
500
+ expectedVersion: payload.version,
501
+ currentVersion,
502
+ }),
503
+ );
504
+ }
505
+ }
506
+
507
+ try {
508
+ // Compound-Types Auto-Convert (alle in einem Pass).
509
+ const flatChanges = flattenCompoundTypes(payload.changes, entity);
510
+
511
+ // The event payload carries BOTH `changes` (what the user asked for) AND
512
+ // `previous` (the pre-update row). Cross-aggregate projections need the
513
+ // previous value to decrement/undo when a parent-FK moves — without it
514
+ // you'd have to snapshot-and-diff on every apply, and replays would
515
+ // break. Storage cost is acceptable (rows are bounded), correctness is
516
+ // not negotiable. Sensitive fields are stripped from BOTH halves so
517
+ // they never reach the immutable event log.
518
+ const event = await append(db.raw, {
519
+ aggregateId: String(payload.id),
520
+ aggregateType: entityName,
521
+ tenantId: user.tenantId,
522
+ expectedVersion: currentVersion,
523
+ type: entityEventName(entityName, "updated"),
524
+ payload: {
525
+ changes: stripSensitive(flatChanges),
526
+ previous: stripSensitive(previous),
527
+ },
528
+ metadata: buildEventMetadata(user),
529
+ });
530
+
531
+ // Live==Rebuild via applyEntityEvent: live-event mit unstripped
532
+ // flatChanges damit sensitive Felder in der Read-Tabelle landen.
533
+ //
534
+ // F8-Patch: dasselbe unique-violation-handling wie im create-Pfad
535
+ // — ein update das einen unique-Index verletzt (z.B. email-update
536
+ // auf einen schon-existierenden Wert) wird mit 409 unique_violation
537
+ // statt 500 internal_error rückgemeldet.
538
+ const liveEvent = {
539
+ ...event,
540
+ payload: { changes: flatChanges, previous },
541
+ };
542
+ let result: Awaited<ReturnType<typeof applyEntityEvent>>;
543
+ try {
544
+ result = await applyEntityEvent(liveEvent, table, entity, db.raw);
545
+ } catch (e) {
546
+ const mapped = tryMapUniqueViolation(e, entityName);
547
+ if (mapped) return mapped;
548
+ throw e;
549
+ }
550
+ if (result.kind !== "applied" || result.row === null) {
551
+ return writeFailure(new InternalError({ message: "projection update returned no row" }));
552
+ }
553
+ const row = result.row;
554
+ const data = rehydrateCompoundTypes(row as DbRow, entity) as DbRow;
555
+
556
+ if (entityCache && entityName) {
557
+ await entityCache.del(user.tenantId, entityName, payload.id);
558
+ }
559
+
560
+ return {
561
+ isSuccess: true,
562
+ data: {
563
+ kind: "save",
564
+ id: data["id"] as EntityId,
565
+ data,
566
+ changes: payload.changes,
567
+ previous,
568
+ isNew: false,
569
+ entityName,
570
+ event,
571
+ },
572
+ };
573
+ } catch (e) {
574
+ // The pre-check above eliminates the common stale-version case; this
575
+ // branch catches the narrow race where two writers both read version=N
576
+ // and both pass the local check — the unique index on (aggregate_id,
577
+ // version) serializes them, one wins, the other lands here.
578
+ if (e instanceof EventStoreVersionConflict) {
579
+ return writeFailure(
580
+ new FrameworkVersionConflict({
581
+ entityId: payload.id,
582
+ expectedVersion: payload.version ?? 0,
583
+ currentVersion,
584
+ }),
585
+ );
586
+ }
587
+ throw e;
588
+ }
589
+ },
590
+
591
+ async delete(payload, user, db) {
592
+ const existing = await loadById(payload.id, db);
593
+ if (!existing) return writeFailure(new NotFoundError(entityName, payload.id));
594
+
595
+ // H.2 — entity-level write-ownership on delete. Only the pre-delete
596
+ // row matters (there's no "new" row for a delete); passing existing
597
+ // twice to userCanWriteFieldRow makes the Straddle check trivial
598
+ // (same row on both sides) while keeping the multi-role-atomic shape.
599
+ if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
600
+ return writeFailure(
601
+ new UnprocessableError("ownership_denied", {
602
+ i18nKey: "errors.ownershipDenied",
603
+ details: {
604
+ scope: "entity",
605
+ entityName,
606
+ action: "delete",
607
+ userId: user.id,
608
+ entityId: payload.id,
609
+ },
610
+ }),
611
+ );
612
+ }
613
+
614
+ // Stream-version authoritative (see update() for rationale).
615
+ const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
616
+
617
+ // Deletes carry the full pre-delete row as `previous`. That's what
618
+ // projections and downstream consumers need to reverse any aggregates —
619
+ // a `{}`-payload delete would make cross-aggregate projections impossible
620
+ // to rebuild from the event log alone. Sensitive fields are stripped.
621
+ const event = await append(db.raw, {
622
+ aggregateId: String(payload.id),
623
+ aggregateType: entityName,
624
+ tenantId: user.tenantId,
625
+ expectedVersion: currentVersion,
626
+ type: entityEventName(entityName, "deleted"),
627
+ payload: { previous: stripSensitive(existing) },
628
+ metadata: buildEventMetadata(user),
629
+ });
630
+
631
+ // Live==Rebuild via applyEntityEvent. Delete-Operation hat keine
632
+ // sensitive-Drift weil das Event-Payload nur `previous` ist und das
633
+ // wird vom soft/hard-delete-Code gar nicht in die Tabelle geschrieben
634
+ // (nur isDeleted/deletedAt/version-Bump). Live + Replay schreiben
635
+ // dasselbe — kein payload-override nötig.
636
+ const deleteResult = await applyEntityEvent(event, table, entity, db.raw);
637
+ if (deleteResult.kind !== "applied") {
638
+ return writeFailure(
639
+ new InternalError({ message: "projection delete: applyEntityEvent skipped" }),
640
+ );
641
+ }
642
+
643
+ if (entityCache && entityName) {
644
+ await entityCache.del(user.tenantId, entityName, payload.id);
645
+ }
646
+
647
+ return {
648
+ isSuccess: true,
649
+ data: { kind: "delete", id: payload.id, data: existing, entityName, event },
650
+ };
651
+ },
652
+
653
+ async restore(payload, user, db) {
654
+ if (!softDelete) {
655
+ return writeFailure(
656
+ new UnprocessableError("soft_delete_not_enabled", {
657
+ i18nKey: "errors.softDeleteNotEnabled",
658
+ }),
659
+ );
660
+ }
661
+
662
+ const [row] = await db.select().from(table).where(eq(table["id"], payload.id));
663
+ if (!row) return writeFailure(new NotFoundError(entityName, payload.id));
664
+ const data = row as DbRow;
665
+ if (!data["isDeleted"]) {
666
+ return writeFailure(
667
+ new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" }),
668
+ );
669
+ }
670
+
671
+ // H.2 — entity-level write-ownership on restore. Same shape as delete:
672
+ // only the stored row matters. Stored row carries pre-soft-delete
673
+ // teamId/... fields, so the ownership predicate still applies cleanly.
674
+ if (!userCanWriteFieldRow(user, entity.access?.write, data, data)) {
675
+ return writeFailure(
676
+ new UnprocessableError("ownership_denied", {
677
+ i18nKey: "errors.ownershipDenied",
678
+ details: {
679
+ scope: "entity",
680
+ entityName,
681
+ action: "restore",
682
+ userId: user.id,
683
+ entityId: payload.id,
684
+ },
685
+ }),
686
+ );
687
+ }
688
+
689
+ // Stream-version authoritative (see update() for rationale).
690
+ const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
691
+ // Restore carries the soft-deleted snapshot as `previous` — mirror of
692
+ // delete for symmetry. Projections that decremented on delete use
693
+ // `previous` to re-increment on restore without re-querying the entity
694
+ // table. Sensitive fields are stripped.
695
+ const event = await append(db.raw, {
696
+ aggregateId: String(payload.id),
697
+ aggregateType: entityName,
698
+ tenantId: user.tenantId,
699
+ expectedVersion: currentVersion,
700
+ type: entityEventName(entityName, "restored"),
701
+ payload: { previous: stripSensitive(data) },
702
+ metadata: buildEventMetadata(user),
703
+ });
704
+
705
+ // Live==Rebuild via applyEntityEvent. Restore schreibt nur isDeleted=
706
+ // false + version-Bump in die Tabelle — keine sensitive-Drift, daher
707
+ // kein payload-override nötig.
708
+ const restoreResult = await applyEntityEvent(event, table, entity, db.raw);
709
+ if (restoreResult.kind !== "applied" || restoreResult.row === null) {
710
+ return writeFailure(new InternalError({ message: "projection restore returned no row" }));
711
+ }
712
+ const restored = restoreResult.row;
713
+
714
+ if (entityCache && entityName) {
715
+ await entityCache.del(user.tenantId, entityName, payload.id);
716
+ }
717
+
718
+ // Read-Side Auto-Convert für Compound-Types (parallel zu update/list).
719
+ const restoredHydrated = rehydrateCompoundTypes(restored as DbRow, entity) as DbRow;
720
+
721
+ return {
722
+ isSuccess: true,
723
+ data: {
724
+ kind: "save",
725
+ id: payload.id,
726
+ data: restoredHydrated,
727
+ changes: { isDeleted: false },
728
+ previous: data,
729
+ isNew: false,
730
+ entityName,
731
+ event,
732
+ },
733
+ };
734
+ },
735
+
736
+ // list + detail are unchanged from crud-executor — projections are the
737
+ // read-model and serve these queries directly.
738
+ async list(payload, user, db, runtimeOptions) {
739
+ const limit = payload.limit ?? 50;
740
+ const offset = payload.offset ?? 0;
741
+ const totalCount = payload.totalCount === true;
742
+
743
+ // H.2 — entity-level read ownership. Decide before touching search or
744
+ // the DB: `empty` means there's no row the user could ever see, so
745
+ // skip both paths and return an empty page.
746
+ const ownership = buildOwnershipClause(user, entity.access?.read, table);
747
+ if (ownership.kind === "empty") {
748
+ return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
749
+ }
750
+
751
+ let filterIds: EntityId[] | undefined;
752
+ // Build-Time options.searchAdapter gewinnt; runtime-Override ist
753
+ // Fallback für die defaultEntityQueryHandler-Pipe (die nutzt den
754
+ // ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
755
+ // beim Definition-Time noch keinen Server-Context hat).
756
+ const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
757
+ if (payload.search && effectiveSearchAdapter && entityName) {
758
+ const results = await effectiveSearchAdapter.search(user.tenantId, payload.search, {
759
+ filterType: entityName,
760
+ });
761
+ filterIds = results.map((r) => r.entityId);
762
+ if (filterIds.length === 0) {
763
+ return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
764
+ }
765
+ }
766
+
767
+ const conditions: SQL[] = [];
768
+ if (softDelete && table["isDeleted"]) {
769
+ conditions.push(eq(table["isDeleted"], false));
770
+ }
771
+ // Cursor und Offset schließen sich aus: Cursor ist DB-stable (gt id),
772
+ // Offset ist für klassische Page-Navigation. Wenn beide gesetzt sind,
773
+ // gewinnt Cursor — Caller hätte eh nicht gleichzeitig beide nutzen
774
+ // sollen, das pinnt die Verteidigung.
775
+ if (payload.cursor) {
776
+ conditions.push(gt(table["id"], decodeCursor(payload.cursor)));
777
+ }
778
+ if (filterIds) {
779
+ conditions.push(inArray(table["id"], filterIds));
780
+ }
781
+ if (ownership.kind === "sql") {
782
+ conditions.push(ownership.sql);
783
+ }
784
+ // Screen-Filter (Tier 2.7c) — Boot-Validator hat field-Existenz
785
+ // + filterable + op-vs-Type-Compat schon gepinnt. Runtime-Defense:
786
+ // undefined-column → silent skip (kein Crash). Op-Mapping läuft
787
+ // durch buildFilterCondition() — da lebt auch der einzige
788
+ // `as never`-Cast (Wire-Boundary).
789
+ if (payload.filter !== undefined) {
790
+ const col = table[payload.filter.field];
791
+ if (col !== undefined) {
792
+ conditions.push(buildFilterCondition(col, payload.filter.op, payload.filter.value));
793
+ }
794
+ }
795
+
796
+ const whereClause = conditions.length > 0 ? (and(...conditions) as SQL) : undefined;
797
+ let query = whereClause
798
+ ? db.select().from(table).where(whereClause)
799
+ : db.select().from(table);
800
+
801
+ query = query.limit(limit);
802
+ // Offset NUR wenn kein Cursor — sonst kombinieren wir zwei
803
+ // Pagination-Schemes und der Caller bekommt unverhoffte Skips.
804
+ if (!payload.cursor && offset > 0) {
805
+ query = query.offset(offset);
806
+ }
807
+
808
+ if (payload.sort && table[payload.sort]) {
809
+ const column = table[payload.sort];
810
+ query =
811
+ payload.sortDirection === "desc"
812
+ ? query.orderBy(desc(column))
813
+ : query.orderBy(asc(column));
814
+ }
815
+
816
+ const rawRows = (await query) as Record<string, unknown>[]; // @cast-boundary engine-payload
817
+ // Read-Side rehydrate pro Row. Cache speichert die hydrated Form,
818
+ // damit Cache-Hits dieselbe API-Form liefern.
819
+ const rows = rawRows.map((r) => rehydrateCompoundTypes(r, entity));
820
+
821
+ if (entityCache && entityName && rows.length > 0) {
822
+ await entityCache.mset(
823
+ user.tenantId,
824
+ entityName,
825
+ rows.map((r) => ({ id: r["id"] as EntityId, data: r })),
826
+ );
827
+ }
828
+
829
+ const lastRow = rows[rows.length - 1];
830
+ const nextCursor =
831
+ rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null;
832
+
833
+ // total: extra COUNT(*) — nur wenn explizit angefordert (Pager-UI).
834
+ // Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
835
+ // wie der entsprechende WHERE — bei indexed columns billig genug.
836
+ // Bei Search-Path ist `total = filterIds.length` ohne extra Query.
837
+ let total: number | undefined;
838
+ if (totalCount) {
839
+ if (filterIds) {
840
+ total = filterIds.length;
841
+ } else {
842
+ const countQuery = whereClause
843
+ ? db.select({ count: sql<number>`count(*)::int` }).from(table).where(whereClause)
844
+ : db.select({ count: sql<number>`count(*)::int` }).from(table);
845
+ const countRow = (await countQuery) as Array<{ count: number }>;
846
+ total = countRow[0]?.count ?? 0;
847
+ }
848
+ }
849
+
850
+ return { rows, nextCursor, ...(total !== undefined && { total }) };
851
+ },
852
+
853
+ async detail(payload, user, db) {
854
+ // H.2 — ownership check. `empty` → the user can never see this row
855
+ // regardless of its id. Return null (same shape as "not found", so a
856
+ // probing attacker can't distinguish "no access" from "doesn't exist").
857
+ const ownership = buildOwnershipClause(user, entity.access?.read, table);
858
+ if (ownership.kind === "empty") return null;
859
+
860
+ if (entityCache && entityName) {
861
+ const cached = await entityCache.get(user.tenantId, entityName, payload.id);
862
+ if (cached) {
863
+ // Even with a cache hit the ownership predicate must hold. The
864
+ // cache is keyed only by tenant + id, not by role, so a cached
865
+ // row may be visible to caller A but not caller B — re-check
866
+ // per request.
867
+ if (ownership.kind === "sql") {
868
+ // Reuse the clause by querying the row with it. Cheaper than
869
+ // SQL-parsing the predicate: just re-issue detail-by-id with
870
+ // the ownership-AND and see if the DB returns it. idFilter()
871
+ // handles the soft-delete guard.
872
+ const checked = await db
873
+ .select()
874
+ .from(table)
875
+ .where(and(idFilter(payload.id), ownership.sql) as SQL)
876
+ .limit(1);
877
+ if (checked.length === 0) return null;
878
+ }
879
+ return cached;
880
+ }
881
+ }
882
+
883
+ // Cold path: load the row with the ownership predicate applied so the
884
+ // DB does the filtering (cheaper than load-then-filter-in-JS). Reuse
885
+ // idFilter() — it handles the soft-delete guard consistently with
886
+ // loadById(), which we can't just call directly because it doesn't
887
+ // thread the ownership clause.
888
+ const baseFilter = idFilter(payload.id);
889
+ const whereClause =
890
+ ownership.kind === "sql" ? (and(baseFilter, ownership.sql) as SQL) : baseFilter;
891
+ const rows = (await db.select().from(table).where(whereClause).limit(1)) as Record<
892
+ string,
893
+ unknown
894
+ >[];
895
+ const raw = rows[0];
896
+ if (!raw) return null;
897
+ const row = rehydrateCompoundTypes(raw, entity);
898
+
899
+ if (entityCache && entityName) {
900
+ await entityCache.set(user.tenantId, entityName, payload.id, row);
901
+ }
902
+
903
+ return row;
904
+ },
905
+ };
906
+ }