@cosmicdrift/kumiko-framework 0.386.0 → 0.388.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 (468) hide show
  1. package/AGENTS.md +6 -1
  2. package/LICENSE +21 -57
  3. package/README.md +2 -2
  4. package/agents/examples/anonymous-access.md +1 -1
  5. package/agents/examples/apex-landing.md +204 -0
  6. package/agents/examples/api-worker-split.md +64 -0
  7. package/agents/examples/app-config.md +14 -0
  8. package/agents/examples/auth-mfa.md +115 -0
  9. package/agents/examples/env-composition.md +28 -1
  10. package/agents/examples/legal-pages.md +1 -1
  11. package/agents/examples/prod-boot-options.md +16 -0
  12. package/agents/examples/rate-limiting.md +48 -0
  13. package/agents/examples/record-detail-layout.md +692 -0
  14. package/agents/examples/seed-migration.md +212 -0
  15. package/agents/examples/wizard-form.md +941 -0
  16. package/agents/examples/writeform-section.md +548 -0
  17. package/agents/features.md +19 -2
  18. package/agents/widgets.md +40 -39
  19. package/dist/api/api-constants.js +2 -2
  20. package/dist/api/auth-middleware.js +7 -7
  21. package/dist/api/auth-rate-limiters.js +7 -3
  22. package/dist/api/auth-routes-mfa.js +1 -1
  23. package/dist/api/auth-routes-session.js +1 -1
  24. package/dist/api/auth-routes-shared.js +7 -3
  25. package/dist/api/auth-routes-signup.js +1 -1
  26. package/dist/api/client-error-route.js +8 -2
  27. package/dist/api/client-ip.js +3 -1
  28. package/dist/api/extra-route.js +1 -1
  29. package/dist/api/index.d.ts +3 -1
  30. package/dist/api/index.js +3 -1
  31. package/dist/api/pii-leak-guard.js +4 -2
  32. package/dist/api/readiness.js +5 -5
  33. package/dist/api/redis-sse-broker.js +8 -6
  34. package/dist/api/request-context.d.ts +1 -1
  35. package/dist/api/request-context.js +1 -1
  36. package/dist/api/routes.js +2 -2
  37. package/dist/api/server-boot-checks.d.ts +10 -0
  38. package/dist/api/server-boot-checks.js +157 -0
  39. package/dist/api/server-event-dispatcher.d.ts +26 -0
  40. package/dist/api/server-event-dispatcher.js +208 -0
  41. package/dist/api/server-extra-routes.d.ts +11 -0
  42. package/dist/api/server-extra-routes.js +203 -0
  43. package/dist/api/server-middleware.d.ts +20 -0
  44. package/dist/api/server-middleware.js +211 -0
  45. package/dist/api/server-routes.d.ts +19 -0
  46. package/dist/api/server-routes.js +293 -0
  47. package/dist/api/server-websocket-routes.d.ts +4 -0
  48. package/dist/api/server-websocket-routes.js +108 -0
  49. package/dist/api/server-wiring.d.ts +32 -0
  50. package/dist/api/server-wiring.js +236 -0
  51. package/dist/api/server.d.ts +10 -15
  52. package/dist/api/server.js +48 -1250
  53. package/dist/api/session-cookies.js +4 -4
  54. package/dist/api/sse-broker.js +1 -1
  55. package/dist/api/sse-route.d.ts +13 -13
  56. package/dist/api/sse-route.js +31 -27
  57. package/dist/auth/index.js +4 -4
  58. package/dist/auth/roles.d.ts +4 -4
  59. package/dist/auth/roles.js +24 -24
  60. package/dist/compliance/duration-spec.js +18 -20
  61. package/dist/compliance/index.js +2 -3
  62. package/dist/compliance/override-schema.d.ts +4 -4
  63. package/dist/compliance/override-schema.js +19 -22
  64. package/dist/compliance/profiles.d.ts +26 -29
  65. package/dist/compliance/profiles.js +62 -69
  66. package/dist/compliance/sub-processors.d.ts +28 -30
  67. package/dist/compliance/sub-processors.js +17 -21
  68. package/dist/consumer-cli.js +4 -4
  69. package/dist/crypto/blind-index.js +18 -17
  70. package/dist/crypto/ciphertext-pattern.js +1 -1
  71. package/dist/crypto/event-pii.js +3 -3
  72. package/dist/crypto/kek-source.d.ts +1 -1
  73. package/dist/crypto/kek-source.js +3 -2
  74. package/dist/crypto/kms-adapter.d.ts +1 -1
  75. package/dist/crypto/kms-adapter.js +4 -6
  76. package/dist/crypto/kms-wiring.d.ts +1 -2
  77. package/dist/crypto/kms-wiring.js +2 -2
  78. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  79. package/dist/crypto/owner-transition-rekey.js +1 -1
  80. package/dist/crypto/pg-kms-adapter.js +2 -6
  81. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  82. package/dist/crypto/pii-field-encryption.js +7 -5
  83. package/dist/crypto/subject-resolver.d.ts +1 -1
  84. package/dist/crypto/subject-resolver.js +2 -2
  85. package/dist/db/aggregate-sql.d.ts +21 -0
  86. package/dist/db/aggregate-sql.js +155 -0
  87. package/dist/db/apply-entity-event.d.ts +6 -6
  88. package/dist/db/apply-entity-event.js +55 -55
  89. package/dist/db/blind-index-cleanup.js +7 -7
  90. package/dist/db/closed-connection.d.ts +9 -0
  91. package/dist/db/closed-connection.js +95 -0
  92. package/dist/db/collect-table-metas.js +10 -11
  93. package/dist/db/compound-types.d.ts +1 -1
  94. package/dist/db/compound-types.js +12 -12
  95. package/dist/db/cursor.js +3 -4
  96. package/dist/db/dialect.d.ts +2 -2
  97. package/dist/db/dialect.js +3 -3
  98. package/dist/db/distinct-on.d.ts +1 -1
  99. package/dist/db/driver-row-coercion.d.ts +2 -0
  100. package/dist/db/driver-row-coercion.js +186 -0
  101. package/dist/db/driver-value-preparation.d.ts +16 -0
  102. package/dist/db/driver-value-preparation.js +99 -0
  103. package/dist/db/eagerload.d.ts +3 -3
  104. package/dist/db/eagerload.js +21 -12
  105. package/dist/db/entity-field-encryption.d.ts +1 -0
  106. package/dist/db/entity-field-encryption.js +1 -1
  107. package/dist/db/entity-table-meta.d.ts +2 -2
  108. package/dist/db/entity-table-meta.js +105 -112
  109. package/dist/db/event-store-executor-context.d.ts +3 -2
  110. package/dist/db/event-store-executor-context.js +13 -13
  111. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  112. package/dist/db/event-store-executor-read-aggregate.js +112 -0
  113. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  114. package/dist/db/event-store-executor-read-filter.js +203 -0
  115. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  116. package/dist/db/event-store-executor-read-paging.js +99 -0
  117. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  118. package/dist/db/event-store-executor-read-rows.js +32 -0
  119. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  120. package/dist/db/event-store-executor-read-search.js +221 -0
  121. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  122. package/dist/db/event-store-executor-read-sort.js +130 -0
  123. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  124. package/dist/db/event-store-executor-read-where.js +81 -0
  125. package/dist/db/event-store-executor-read.d.ts +2 -9
  126. package/dist/db/event-store-executor-read.js +50 -704
  127. package/dist/db/event-store-executor-write-steps.d.ts +20 -0
  128. package/dist/db/event-store-executor-write-steps.js +100 -0
  129. package/dist/db/event-store-executor-write.js +401 -510
  130. package/dist/db/index.d.ts +6 -2
  131. package/dist/db/index.js +5 -1
  132. package/dist/db/list-aggregate-labels.d.ts +14 -0
  133. package/dist/db/list-aggregate-labels.js +26 -0
  134. package/dist/db/located-timestamp.d.ts +11 -11
  135. package/dist/db/located-timestamp.js +29 -30
  136. package/dist/db/migrate-generator.d.ts +1 -1
  137. package/dist/db/migrate-generator.js +97 -105
  138. package/dist/db/migrate-runner.js +29 -26
  139. package/dist/db/money.d.ts +6 -6
  140. package/dist/db/money.js +29 -27
  141. package/dist/db/queries/backfill-pii.js +5 -5
  142. package/dist/db/queries/ddl.js +1 -1
  143. package/dist/db/queries/es-ops.d.ts +1 -1
  144. package/dist/db/queries/es-ops.js +1 -1
  145. package/dist/db/queries/event-consumer.js +3 -3
  146. package/dist/db/queries/event-store.js +4 -2
  147. package/dist/db/queries/projection-rebuild.js +3 -3
  148. package/dist/db/queries/raw-sql.d.ts +2 -2
  149. package/dist/db/queries/raw-sql.js +2 -2
  150. package/dist/db/queries/shadow-swap.js +19 -19
  151. package/dist/db/query.d.ts +3 -16
  152. package/dist/db/query.js +8 -761
  153. package/dist/db/rebuild-marker.js +20 -21
  154. package/dist/db/reference-data.js +6 -1
  155. package/dist/db/replay-migration-sql.js +2 -2
  156. package/dist/db/schema-inspection.js +4 -5
  157. package/dist/db/table-builder.d.ts +1 -2
  158. package/dist/db/table-builder.js +137 -190
  159. package/dist/db/table-info.d.ts +10 -0
  160. package/dist/db/table-info.js +45 -0
  161. package/dist/db/temporal-value-guards.d.ts +6 -0
  162. package/dist/db/temporal-value-guards.js +15 -0
  163. package/dist/db/tenant-db-runner.d.ts +1 -0
  164. package/dist/db/tenant-db-runner.js +3 -0
  165. package/dist/db/tenant-db.d.ts +2 -2
  166. package/dist/db/tenant-db.js +48 -11
  167. package/dist/db/where-clause.d.ts +6 -0
  168. package/dist/db/where-clause.js +236 -0
  169. package/dist/derivatives/delete-stored-file.js +6 -2
  170. package/dist/derivatives/derivatives-context.js +1 -1
  171. package/dist/engine/boot-validator/access-roles.js +4 -3
  172. package/dist/engine/boot-validator/api-ext.js +14 -14
  173. package/dist/engine/boot-validator/boot-check.js +1 -1
  174. package/dist/engine/boot-validator/config-deps.js +7 -6
  175. package/dist/engine/boot-validator/detail-screens.js +4 -4
  176. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  177. package/dist/engine/boot-validator/entity-handler.js +77 -61
  178. package/dist/engine/boot-validator/form-derivations.js +35 -1
  179. package/dist/engine/boot-validator/gdpr-storage.js +4 -4
  180. package/dist/engine/boot-validator/i18n-keys.js +6 -4
  181. package/dist/engine/boot-validator/index.d.ts +2 -2
  182. package/dist/engine/boot-validator/index.js +22 -20
  183. package/dist/engine/boot-validator/nav.js +5 -5
  184. package/dist/engine/boot-validator/ownership.js +12 -9
  185. package/dist/engine/boot-validator/parent-ref.js +1 -1
  186. package/dist/engine/boot-validator/pii-retention.js +258 -187
  187. package/dist/engine/boot-validator/projection-list-screens.js +7 -7
  188. package/dist/engine/boot-validator/query-refs.js +1 -1
  189. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  190. package/dist/engine/boot-validator/screens-actions.js +23 -17
  191. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  192. package/dist/engine/boot-validator/screens-forms.js +12 -17
  193. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  194. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  195. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  196. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  197. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  198. package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
  199. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  200. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  201. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  202. package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
  203. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  204. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  205. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  206. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  207. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  208. package/dist/engine/boot-validator/screens-lists.js +26 -24
  209. package/dist/engine/boot-validator/screens.d.ts +0 -2
  210. package/dist/engine/boot-validator/screens.js +102 -869
  211. package/dist/engine/boot-validator/security-baseline.js +3 -2
  212. package/dist/engine/boot-validator/transfer-graph.js +2 -2
  213. package/dist/engine/boot-validator/workspaces.js +3 -3
  214. package/dist/engine/bridge-stub.js +3 -4
  215. package/dist/engine/build-app-schema.js +130 -170
  216. package/dist/engine/build-config-feature-schema.js +50 -50
  217. package/dist/engine/build-target.js +20 -23
  218. package/dist/engine/codemod/pipeline-codemod.js +20 -13
  219. package/dist/engine/config-helpers.js +4 -4
  220. package/dist/engine/constants.js +2 -2
  221. package/dist/engine/create-app.js +2 -2
  222. package/dist/engine/define-feature.js +21 -13
  223. package/dist/engine/define-handler.js +13 -11
  224. package/dist/engine/define-step.js +1 -1
  225. package/dist/engine/define-workflow.d.ts +5 -5
  226. package/dist/engine/define-workflow.js +6 -6
  227. package/dist/engine/entity-handlers.d.ts +13 -0
  228. package/dist/engine/entity-handlers.js +22 -34
  229. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  230. package/dist/engine/entity-list-runtime-options.js +18 -0
  231. package/dist/engine/event-def.js +2 -2
  232. package/dist/engine/extension-names.d.ts +16 -17
  233. package/dist/engine/extension-names.js +37 -38
  234. package/dist/engine/extensions/escape-hatch-usage.js +1 -1
  235. package/dist/engine/extensions/tenant-data.js +2 -2
  236. package/dist/engine/extensions/user-data.d.ts +23 -24
  237. package/dist/engine/factories.d.ts +14 -13
  238. package/dist/engine/factories.js +38 -37
  239. package/dist/engine/feature-ast/entity-field-types.js +1 -1
  240. package/dist/engine/feature-ast/extractors/handlers.js +2 -1
  241. package/dist/engine/feature-ast/extractors/hooks.js +1 -1
  242. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  243. package/dist/engine/feature-ast/extractors/shared.d.ts +2 -3
  244. package/dist/engine/feature-ast/extractors/shared.js +2 -3
  245. package/dist/engine/feature-ast/parse.js +13 -13
  246. package/dist/engine/feature-ast/patch.js +4 -182
  247. package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
  248. package/dist/engine/feature-ast/pattern-change-schema.js +2 -1
  249. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  250. package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
  251. package/dist/engine/feature-ast/patterns.d.ts +11 -2
  252. package/dist/engine/feature-ast/patterns.js +43 -51
  253. package/dist/engine/feature-ast/render.js +50 -89
  254. package/dist/engine/feature-config-events-jobs.js +15 -12
  255. package/dist/engine/feature-entity-handlers.js +12 -5
  256. package/dist/engine/feature-manifest.d.ts +9 -9
  257. package/dist/engine/feature-manifest.js +6 -7
  258. package/dist/engine/feature-ui-extensions.d.ts +3 -3
  259. package/dist/engine/feature-ui-extensions.js +30 -22
  260. package/dist/engine/field-access.js +5 -6
  261. package/dist/engine/form-derivations.js +18 -7
  262. package/dist/engine/handler-field-references.js +1 -1
  263. package/dist/engine/index.d.ts +1 -0
  264. package/dist/engine/index.js +4 -4
  265. package/dist/engine/object-form.d.ts +1 -0
  266. package/dist/engine/object-form.js +14 -4
  267. package/dist/engine/ownership.js +3 -3
  268. package/dist/engine/parse-ref-target.js +5 -7
  269. package/dist/engine/pattern-library/index.js +2 -2
  270. package/dist/engine/pattern-library/library.js +2 -2
  271. package/dist/engine/pattern-library/types.js +3 -3
  272. package/dist/engine/pipeline.js +2 -2
  273. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  274. package/dist/engine/project-app-schema-for-roles.js +7 -5
  275. package/dist/engine/projection-helpers.js +9 -9
  276. package/dist/engine/qualified-name.js +2 -2
  277. package/dist/engine/registry-facade.js +1 -1
  278. package/dist/engine/registry-ingest.js +13 -13
  279. package/dist/engine/registry-state.js +17 -19
  280. package/dist/engine/registry-validate.js +12 -12
  281. package/dist/engine/run-in.js +2 -2
  282. package/dist/engine/run-pipeline.js +7 -0
  283. package/dist/engine/schema-builder.js +251 -233
  284. package/dist/engine/screen-filter-ops.js +17 -20
  285. package/dist/engine/screen-helpers.js +4 -3
  286. package/dist/engine/screen-kind.d.ts +7 -0
  287. package/dist/engine/screen-kind.js +1 -0
  288. package/dist/engine/state-machine.d.ts +12 -12
  289. package/dist/engine/state-machine.js +7 -7
  290. package/dist/engine/steps/_resolver-utils.js +4 -3
  291. package/dist/engine/steps/for-each.js +4 -4
  292. package/dist/engine/steps/unsafe-projection-delete.js +1 -1
  293. package/dist/engine/steps/unsafe-projection-upsert.js +4 -4
  294. package/dist/engine/steps/wait-for-event.js +4 -4
  295. package/dist/engine/steps/workflow-system-events.js +3 -3
  296. package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
  297. package/dist/engine/tier-resolver-extension.d.ts +29 -27
  298. package/dist/engine/tier-resolver-extension.js +26 -26
  299. package/dist/engine/transfer-adjacency.js +2 -2
  300. package/dist/engine/validate-projection-allowlist.js +9 -28
  301. package/dist/engine/walk-step-instances.d.ts +2 -0
  302. package/dist/engine/walk-step-instances.js +38 -0
  303. package/dist/engine/where-rule-lint.js +1 -1
  304. package/dist/engine/write-handler-qn-extract.js +1 -1
  305. package/dist/entrypoint/index.d.ts +4 -1
  306. package/dist/entrypoint/index.js +10 -4
  307. package/dist/env/index.d.ts +7 -6
  308. package/dist/env/index.js +10 -9
  309. package/dist/errors/docs-url.d.ts +1 -1
  310. package/dist/errors/docs-url.js +22 -2
  311. package/dist/errors/transition-details.js +7 -7
  312. package/dist/errors/write-error-info.d.ts +6 -6
  313. package/dist/errors/write-error-info.js +6 -6
  314. package/dist/es-ops/context.d.ts +9 -9
  315. package/dist/es-ops/context.js +29 -29
  316. package/dist/es-ops/index.js +7 -9
  317. package/dist/es-ops/operations-schema.js +4 -10
  318. package/dist/es-ops/runner.d.ts +11 -11
  319. package/dist/es-ops/runner.js +49 -53
  320. package/dist/es-ops/types.d.ts +55 -55
  321. package/dist/es-ops/types.js +11 -11
  322. package/dist/event-store/admin-api.js +7 -8
  323. package/dist/event-store/errors.js +1 -1
  324. package/dist/event-store/event-store.js +12 -6
  325. package/dist/event-store/rebuild-dead-letter.js +1 -1
  326. package/dist/files/bind-pending-file-refs.js +3 -3
  327. package/dist/files/create-file-ref-from-bytes.js +4 -1
  328. package/dist/files/feature.js +9 -10
  329. package/dist/files/file-handle.js +7 -9
  330. package/dist/files/file-ref-entity.js +15 -15
  331. package/dist/files/file-ref-table.js +7 -8
  332. package/dist/files/file-routes.d.ts +7 -0
  333. package/dist/files/file-routes.js +22 -13
  334. package/dist/files/in-memory-provider.js +8 -9
  335. package/dist/files/local-provider.js +18 -25
  336. package/dist/files/provider-resolver.d.ts +7 -7
  337. package/dist/files/resolve-content-type.js +43 -0
  338. package/dist/files/storage-tracking.js +2 -2
  339. package/dist/files/upload-ledger.js +7 -0
  340. package/dist/files/zip-stream.d.ts +12 -19
  341. package/dist/files/zip-stream.js +44 -63
  342. package/dist/i18n/required-surface-keys.d.ts +1 -1
  343. package/dist/i18n/required-surface-keys.js +188 -212
  344. package/dist/internal/stack-support.d.ts +2 -1
  345. package/dist/internal/stack-support.js +1 -1
  346. package/dist/jobs/job-runner-context.d.ts +32 -0
  347. package/dist/jobs/job-runner-context.js +305 -0
  348. package/dist/jobs/job-runner-execution.d.ts +7 -0
  349. package/dist/jobs/job-runner-execution.js +160 -0
  350. package/dist/jobs/job-runner-gates.d.ts +19 -0
  351. package/dist/jobs/job-runner-gates.js +198 -0
  352. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  353. package/dist/jobs/job-runner-helpers.js +69 -4
  354. package/dist/jobs/job-runner-schedulers.d.ts +1 -1
  355. package/dist/jobs/job-runner-schedulers.js +3 -4
  356. package/dist/jobs/job-runner-scope.d.ts +44 -0
  357. package/dist/jobs/job-runner-scope.js +102 -0
  358. package/dist/jobs/job-runner-types.d.ts +12 -0
  359. package/dist/jobs/job-runner.d.ts +0 -1
  360. package/dist/jobs/job-runner.js +57 -669
  361. package/dist/lifecycle/lifecycle.js +3 -5
  362. package/dist/lifecycle/signal-handlers.js +5 -4
  363. package/dist/logging/utils.d.ts +2 -1
  364. package/dist/logging/utils.js +38 -10
  365. package/dist/migrations/index.js +1 -1
  366. package/dist/migrations/kumiko-drift.js +14 -13
  367. package/dist/migrations/pending-rebuilds.d.ts +21 -20
  368. package/dist/migrations/pending-rebuilds.js +27 -27
  369. package/dist/migrations/projection-table-index.d.ts +3 -3
  370. package/dist/migrations/projection-table-index.js +8 -8
  371. package/dist/observability/escape-hatch-report.d.ts +3 -0
  372. package/dist/observability/escape-hatch-report.js +12 -1
  373. package/dist/observability/metric-validator.js +1 -1
  374. package/dist/observability/metrics-handle.js +3 -3
  375. package/dist/pipeline/append-event-core.js +11 -2
  376. package/dist/pipeline/cascade-handler.d.ts +1 -1
  377. package/dist/pipeline/cascade-handler.js +165 -138
  378. package/dist/pipeline/dispatch-batch.js +2 -2
  379. package/dist/pipeline/dispatch-query.js +20 -13
  380. package/dist/pipeline/dispatch-shared.d.ts +14 -3
  381. package/dist/pipeline/dispatch-shared.js +67 -721
  382. package/dist/pipeline/dispatch-stream.js +1 -1
  383. package/dist/pipeline/dispatch-write.js +297 -190
  384. package/dist/pipeline/dispatcher-utils.d.ts +1 -1
  385. package/dist/pipeline/dispatcher-utils.js +1 -1
  386. package/dist/pipeline/event-consumer-state.js +2 -2
  387. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  388. package/dist/pipeline/event-dispatcher-delivery.js +4 -6
  389. package/dist/pipeline/event-dispatcher.js +17 -28
  390. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  391. package/dist/pipeline/handler-context-auth.js +54 -0
  392. package/dist/pipeline/handler-context-config.d.ts +14 -0
  393. package/dist/pipeline/handler-context-config.js +113 -0
  394. package/dist/pipeline/handler-context-db.d.ts +24 -0
  395. package/dist/pipeline/handler-context-db.js +149 -0
  396. package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
  397. package/dist/pipeline/handler-context-dispatch.js +89 -0
  398. package/dist/pipeline/handler-context-events.d.ts +43 -0
  399. package/dist/pipeline/handler-context-events.js +237 -0
  400. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  401. package/dist/pipeline/handler-context-notify.js +55 -0
  402. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  403. package/dist/pipeline/handler-context-observability.js +21 -0
  404. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  405. package/dist/pipeline/handler-context-scope.js +88 -0
  406. package/dist/pipeline/irreversible-operation-gate.js +1 -1
  407. package/dist/pipeline/lifecycle-pipeline.js +9 -9
  408. package/dist/pipeline/member-read-only-transaction.js +1 -3
  409. package/dist/pipeline/msp-rebuild.js +1 -1
  410. package/dist/pipeline/notify-scope.js +12 -1
  411. package/dist/pipeline/pre-handler-gates.js +20 -4
  412. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  413. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  414. package/dist/pipeline/projection-rebuild.js +15 -13
  415. package/dist/pipeline/system-hooks.js +39 -41
  416. package/dist/pipeline/system-identity-switch.js +1 -3
  417. package/dist/pipeline/tenant-timezone-cache.js +1 -1
  418. package/dist/random/generate.d.ts +29 -29
  419. package/dist/random/generate.js +30 -30
  420. package/dist/random/words.js +20 -20
  421. package/dist/rate-limit/middleware.js +9 -5
  422. package/dist/redis/client.js +2 -2
  423. package/dist/redis/pubsub-signal.js +14 -17
  424. package/dist/schema-cli/apply-command.d.ts +2 -0
  425. package/dist/schema-cli/apply-command.js +97 -0
  426. package/dist/schema-cli/baseline-command.d.ts +2 -0
  427. package/dist/schema-cli/baseline-command.js +33 -0
  428. package/dist/schema-cli/generate-command.d.ts +2 -0
  429. package/dist/schema-cli/generate-command.js +90 -0
  430. package/dist/schema-cli/shared.d.ts +39 -0
  431. package/dist/schema-cli/shared.js +95 -0
  432. package/dist/schema-cli/status-command.d.ts +2 -0
  433. package/dist/schema-cli/status-command.js +73 -0
  434. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  435. package/dist/schema-cli/validate-boot-input.js +130 -0
  436. package/dist/schema-cli/validate-command.d.ts +2 -0
  437. package/dist/schema-cli/validate-command.js +130 -0
  438. package/dist/schema-cli.d.ts +2 -18
  439. package/dist/schema-cli.js +47 -510
  440. package/dist/search/index.js +4 -4
  441. package/dist/search/meilisearch-adapter.js +2 -1
  442. package/dist/search/purge-subject.js +4 -5
  443. package/dist/search/reindex-entity.js +6 -9
  444. package/dist/time/boot-tz-warning.d.ts +2 -2
  445. package/dist/time/boot-tz-warning.js +8 -8
  446. package/dist/time/iana.js +10 -10
  447. package/dist/time/index.js +9 -9
  448. package/dist/time/legacy-date.d.ts +2 -2
  449. package/dist/time/legacy-date.js +6 -8
  450. package/dist/time/polyfill.js +1 -1
  451. package/dist/time/tz-context.d.ts +1 -1
  452. package/dist/time/tz-context.js +1 -1
  453. package/dist/ui-types/app-schema.js +7 -7
  454. package/dist/ui-types/list-row-meta.js +4 -4
  455. package/dist/upgrade-cli.js +159 -130
  456. package/dist/utils/compare.js +1 -1
  457. package/package.json +5 -5
  458. package/src/changes.json +358 -0
  459. package/src/scripts/codemod/crypto-shredding-testing-move.ts +2 -2
  460. package/src/scripts/codemod/feature-layout.ts +10 -2
  461. package/src/scripts/codemod/migrate-cross-tenant.ts +1 -1
  462. package/src/scripts/codemod/migrate-open-to-all.ts +1 -1
  463. package/src/scripts/codemod/object-form.ts +41 -4
  464. package/src/scripts/codemod/package-merge.ts +58 -8
  465. package/src/scripts/codemod/pii-personal-migration.ts +192 -153
  466. package/src/scripts/codemod/testing-injection.ts +122 -42
  467. package/src/scripts/codemod/testing-move.ts +29 -7
  468. package/src/scripts/codemod/unprocessable-error-details-reason.ts +2 -2
@@ -1,3 +1,4 @@
1
+ import { ErasedSubjectHandlings, } from "../crypto/pii-field-encryption.js";
1
2
  import { checkWriteFieldOwnership } from "../engine/field-access.js";
2
3
  import { instructionFieldNames } from "../engine/instruction-fields.js";
3
4
  import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
@@ -6,104 +7,19 @@ import { findUnavailableSelectOptions, isRecordLocked, isUnchangedValue, } from
6
7
  import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, isSubjectErasedConflict, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
7
8
  import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
8
9
  import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
9
- import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
10
10
  import { generateId } from "../utils/index.js";
11
11
  import { applyEntityEvent } from "./apply-entity-event.js";
12
12
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
13
13
  import { WritePurposes, } from "./event-store-executor.js";
14
14
  import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
15
+ import { optimisticLockFailure, ownershipDeniedFailure, projectEntityEvent, runPreSave, selectOptionNotAvailableFailure, withoutEventInJson, } from "./event-store-executor-write-steps.js";
15
16
  import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
16
- import { projectionRegistryOf } from "./projection-registry-binding.js";
17
17
  import { runInSavepointIfSupported } from "./query.js";
18
18
  import { assertPersonalDataWrite, tableNameOf, withExecutorManagedWrites } from "./tenant-db.js";
19
19
  import { tenantDbWriteRunner } from "./tenant-db-runner.js";
20
- // The echoed event carries entity PII and must never reach a handler's JSON response, while
21
- // hooks and projections still read it by property. Non-enumerable keeps it out of spread/stringify.
22
- function withoutEventInJson(result) {
23
- return Object.defineProperty(result, "event", { value: result.event, enumerable: false });
24
- }
25
- // Custom projections for a write that did not go through the dispatcher (hook or
26
- // job writing a second aggregate). Savepoint like applyEntityEvent: a throwing
27
- // projection fails the write and the caller's transaction rolls it back.
28
- async function projectEntityEvent(db, runner, event) {
29
- const registry = projectionRegistryOf(db);
30
- // skip: TenantDb not built by a dispatcher/job runner — nothing bound to project with
31
- if (!registry)
32
- return;
33
- // skip: no live projection listens — avoid a SAVEPOINT round trip on every plain write
34
- if (!hasLiveProjectionsForEvent(event, registry))
35
- return;
36
- await runInSavepointIfSupported(runner, (sp) => runProjectionsForEvent(event, registry, sp));
37
- }
38
20
  // The five write verbs (create/update/delete/forget/restore) of the event-
39
- // store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
40
- // behavior-preserving relocation, not a redesign: every closure below is
41
- // unchanged from the original, just relocated behind an explicit
21
+ // store-executor, split out of event-store-executor.ts. They read an explicit
42
22
  // ExecutorContext instead of capturing the factory's local scope directly.
43
- function selectOptionNotAvailableFailure(entries) {
44
- const [first] = entries;
45
- if (!first)
46
- return undefined;
47
- const i18nKey = "kumiko.validation.optionNotAvailable";
48
- return writeFailure(new UnprocessableError("select_option_not_available", {
49
- i18nKey,
50
- details: {
51
- field: first.field,
52
- value: first.value,
53
- allowed: first.allowed,
54
- fields: entries.map((entry) => ({
55
- path: entry.field,
56
- code: "select_option_not_available",
57
- i18nKey,
58
- params: { allowed: entry.allowed },
59
- })),
60
- },
61
- }));
62
- }
63
- // preSave runs before ownership checks: authorization must evaluate the row
64
- // as it will actually be persisted, including hook-derived fields
65
- // (kumiko-framework#1672). A throwing hook is an app-author bug (bad
66
- // business rule, not a framework fault) — map it to a clean writeFailure
67
- // instead of letting it propagate as an internal_error 500.
68
- async function runPreSave(preSave, changes, previous, isNew, entityName, action, outputSchema) {
69
- if (!preSave)
70
- return { data: changes };
71
- try {
72
- const hookResult = await preSave(changes, previous, isNew);
73
- // A hook that echoes `id`/`version` back (e.g. `{ ...changes, id: x }`)
74
- // must not leak them into the persisted row — aggregateId already comes
75
- // from generateId()/the loaded row, not from hook output (fw#1685).
76
- const { id: _hookId, version: _hookVersion, ...safe } = hookResult;
77
- // Hook output skips the handler's input validation yet goes straight into the projection.
78
- // Validated only, not replaced by the parse result: hooks may set fields in storage form.
79
- const parsed = outputSchema().safeParse(safe);
80
- if (!parsed.success) {
81
- return {
82
- failure: writeFailure(new UnprocessableError("presave_hook_invalid_output", {
83
- i18nKey: "errors.presaveHookInvalidOutput",
84
- details: {
85
- entityName,
86
- action,
87
- handler: `${entityName}:${action}`,
88
- issues: parsed.error.issues.map((issue) => ({
89
- path: issue.path.join("."),
90
- message: issue.message,
91
- })),
92
- },
93
- })),
94
- };
95
- }
96
- return { data: safe };
97
- }
98
- catch (e) {
99
- return {
100
- failure: writeFailure(new UnprocessableError("presave_hook_failed", {
101
- i18nKey: "errors.presaveHookFailed",
102
- details: { entityName, action, message: e instanceof Error ? e.message : String(e) },
103
- })),
104
- };
105
- }
106
- }
107
23
  export function createWriteVerbs(ctx) {
108
24
  const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, ownerTransitionRekeys, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
109
25
  const entityInstructionFieldNames = instructionFieldNames(entity);
@@ -121,7 +37,9 @@ export function createWriteVerbs(ctx) {
121
37
  // Erasure writes run after the subject key is gone; they store the erased
122
38
  // sentinel for that subject's fields instead of failing on re-encrypt.
123
39
  function erasureSentinelOption(options) {
124
- return options?.writePurpose === WritePurposes.erasure ? { onErasedSubject: "sentinel" } : {};
40
+ return options?.writePurpose === WritePurposes.erasure
41
+ ? { onErasedSubject: ErasedSubjectHandlings.sentinel }
42
+ : {};
125
43
  }
126
44
  const eventVersion = entity.eventVersion ?? 1;
127
45
  let insertSchema;
@@ -169,25 +87,17 @@ export function createWriteVerbs(ctx) {
169
87
  catch (e) {
170
88
  if (isSubjectErasedConflict(e))
171
89
  return { failure: writeFailure(e) };
172
- if (e instanceof EventStoreVersionConflict) {
173
- return {
174
- failure: writeFailure(new FrameworkVersionConflict({
175
- entityId: id,
176
- expectedVersion: currentVersion,
177
- currentVersion: await versionAfterConflict(runner, String(id), streamTenantFor(user, streamTenantId)),
178
- })),
179
- };
180
- }
181
- if (e instanceof EventStoreIdempotentAppendConflict) {
182
- return {
183
- failure: writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey })),
184
- };
185
- }
186
- throw e;
90
+ return {
91
+ failure: await appendConflictFailure(e, runner, {
92
+ entityId: id,
93
+ expectedVersion: currentVersion,
94
+ streamTenant: streamTenantFor(user, streamTenantId),
95
+ }),
96
+ };
187
97
  }
188
98
  }
189
- // After a lost append race the stream version read before the append is stale;
190
- // report the actual one. -1 when the lookup itself fails.
99
+ // Fallback for conflicts that carry no current version: the version read before the
100
+ // append is stale after a lost race. -1 when the lookup itself fails.
191
101
  async function versionAfterConflict(runner, aggregateId, tenantId) {
192
102
  try {
193
103
  return await getStreamVersion(runner, aggregateId, tenantId);
@@ -207,14 +117,304 @@ export function createWriteVerbs(ctx) {
207
117
  }
208
118
  return snapshot.streamVersion;
209
119
  }
120
+ async function appendConflictFailure(error, runner, conflict) {
121
+ if (error instanceof EventStoreVersionConflict) {
122
+ return writeFailure(new FrameworkVersionConflict({
123
+ entityId: conflict.entityId,
124
+ expectedVersion: conflict.expectedVersion,
125
+ currentVersion: error.currentVersion ??
126
+ (await versionAfterConflict(runner, String(conflict.entityId), conflict.streamTenant)),
127
+ }));
128
+ }
129
+ if (error instanceof EventStoreIdempotentAppendConflict) {
130
+ return writeFailure(new IdempotentReplayError({ idempotencyKey: error.idempotencyKey }));
131
+ }
132
+ throw error;
133
+ }
134
+ // Stream-version is authoritative, not row.version. `ctx.appendEvent`
135
+ // can bump the stream between CRUD writes (domain event on the same
136
+ // aggregate); a stale row.version here would make the next CRUD write
137
+ // trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
138
+ // with version_conflict.
139
+ //
140
+ // With `expect:` the version comes from loadExpectSnapshot's combined
141
+ // query instead; see its comment for why.
142
+ async function currentStreamVersion(db, runner, id, streamTenant, expect) {
143
+ return expect
144
+ ? await expectedStreamVersion(db, id, streamTenant, expect)
145
+ : await getStreamVersion(runner, String(id), streamTenant);
146
+ }
147
+ function assertFieldWritesAllowed(db, action, data) {
148
+ // After preSave so derived fields count, before the event append so nothing persists.
149
+ assertPersonalDataWrite(db, tableNameOf(table), Object.keys(data), entity);
150
+ assertInstructionFieldWriteAllowed(entityName, action, Object.keys(data), entityInstructionFieldNames);
151
+ }
152
+ function createOwnershipFailure(user, streamTenantId, payloadWithoutId, data) {
153
+ // H.2 — entity-level write-ownership on create. No oldRow exists, so
154
+ // only the new row is checked. No Straddle concern for creates.
155
+ if (!userCanCreateFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, data)) {
156
+ return ownershipDeniedFailure({
157
+ scope: "entity",
158
+ entityName,
159
+ action: "create",
160
+ userId: user.id,
161
+ });
162
+ }
163
+ // Field-level write-ownership on create — mirror of entity-level but
164
+ // per declared field. Role-level was already checked by the
165
+ // dispatcher; here we enforce ownership-rules against the new row.
166
+ //
167
+ // Which fields get checked is scoped to the pre-hook payload
168
+ // — a hook-derived field the user never submitted must not be
169
+ // field-ownership-checked against the user. The rule for a checked
170
+ // field is still evaluated against the full post-hook row (`data`) —
171
+ // an ownership rule can reference a column only a hook populates.
172
+ const fieldDeniedCreate = checkWriteFieldOwnership(entity, applyDefaults(payloadWithoutId), ownershipSubject(user, streamTenantId), undefined, data);
173
+ if (fieldDeniedCreate) {
174
+ return ownershipDeniedFailure({
175
+ scope: "field",
176
+ entityName,
177
+ action: "create",
178
+ field: fieldDeniedCreate,
179
+ userId: user.id,
180
+ });
181
+ }
182
+ return undefined;
183
+ }
184
+ function updateOwnershipFailure(user, streamTenantId, id, submittedChanges, previous, changes, mergedNew) {
185
+ // H.2 — entity-level write-ownership on update. Load old row (already
186
+ // done above), build post-change row via shallow merge. Straddle-safe
187
+ // multi-role check: at least one role must accept BOTH old and new —
188
+ // prevents the attack where role A passes old, role B passes new and
189
+ // aggregation would wrongly allow a row-grab.
190
+ if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, previous, mergedNew)) {
191
+ return ownershipDeniedFailure({
192
+ scope: "entity",
193
+ entityName,
194
+ action: "update",
195
+ userId: user.id,
196
+ entityId: id,
197
+ });
198
+ }
199
+ // Field-level write-ownership on update — this is the path the
200
+ // dispatcher could not evaluate (no oldRow). Now that we have
201
+ // `previous`, we can run the ownership rules per field against both
202
+ // sides and reject individual fields the user isn't entitled to
203
+ // touch on this specific row.
204
+ //
205
+ // Which fields get checked is scoped to `payload.changes` (the user's
206
+ // actual submission), NOT `changes` (post-preSave-hook data)
207
+ // — a hook-derived field the user never submitted (e.g. a system hook
208
+ // setting `assignedTo`) has no business being field-ownership-checked
209
+ // against the *user*, and doing so rejects writes the user is fully
210
+ // entitled to make. The dispatcher's role-gate (checkWriteFieldAccess)
211
+ // already runs on this same pre-hook payload for consistency. The rule
212
+ // for a checked field is still evaluated against the full post-hook
213
+ // row (`changes` merged onto `previous`) — an ownership rule can
214
+ // reference a column only a hook populates.
215
+ const fieldDeniedUpdate = checkWriteFieldOwnership(entity, submittedChanges, ownershipSubject(user, streamTenantId), previous, changes);
216
+ if (fieldDeniedUpdate) {
217
+ return ownershipDeniedFailure({
218
+ scope: "field",
219
+ entityName,
220
+ action: "update",
221
+ field: fieldDeniedUpdate,
222
+ userId: user.id,
223
+ entityId: id,
224
+ });
225
+ }
226
+ return undefined;
227
+ }
228
+ function updateSelectOptionFailure(user, mergedNew, previous, options) {
229
+ // Statutory retention/erasure writes may change a condition field (e.g. null a PII
230
+ // reference) and must not be blocked by the select rule — same exemption as the lock.
231
+ const isStatutorySystemWrite = options?.writePurpose !== undefined && isFrameworkSystemUser(user);
232
+ return isStatutorySystemWrite
233
+ ? undefined
234
+ : selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
235
+ }
236
+ async function appendCreatedEvent(runner, user, streamTenantId, aggregateId, flatData) {
237
+ // 1. Append the event in the same transaction as the projection write — both must
238
+ // succeed or both roll back; the dispatcher wraps them in one transaction.
239
+ // flatData is already table ciphertext for pii/encrypted fields, so the immutable
240
+ // log never sees plaintext and replay reproduces the row byte-for-byte.
241
+ //
242
+ // `expectedVersion: 0` means the stream does not yet exist. With deterministic
243
+ // aggregate-ID patterns (for example, uuidv5(tenantId|naturalKey)), a create can
244
+ // legitimately collide — the same ID has an existing stream — so return
245
+ // version_conflict instead of internal_error. Update uses the same catch.
246
+ try {
247
+ // Savepoint-scoped: postgres.js poisons the WHOLE surrounding
248
+ // begin() once any statement inside it errors, even if the JS error
249
+ // is caught — a losing concurrent create's unique-violation
250
+ // would otherwise abort the caller's outer transaction and surface as
251
+ // internal_error at commit time instead of the version_conflict this
252
+ // catch classifies. runInSavepointIfSupported confines the failed
253
+ // INSERT to a nested scope that rolls back on its own (same pattern as
254
+ // ctx.tryAppendEvent), and falls back to a plain call when the runner
255
+ // is a bare pool connection with no active transaction to poison
256
+ // (seeds/tests calling the executor directly).
257
+ const event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
258
+ aggregateId,
259
+ aggregateType: entityName,
260
+ tenantId: streamTenantFor(user, streamTenantId),
261
+ expectedVersion: 0,
262
+ type: entityEventName(entityName, "created"),
263
+ eventVersion,
264
+ payload: flatData,
265
+ metadata: buildEventMetadata(user),
266
+ }));
267
+ return { event };
268
+ }
269
+ catch (e) {
270
+ return {
271
+ failure: await appendConflictFailure(e, runner, {
272
+ entityId: aggregateId,
273
+ expectedVersion: 0,
274
+ streamTenant: streamTenantFor(user, streamTenantId),
275
+ }),
276
+ };
277
+ }
278
+ }
279
+ // 2. Update the projection through applyEntityEvent — the same code path used by
280
+ // rebuildProjection for replay, with the same StoredEvent, so live and rebuild
281
+ // produce the same result by construction.
282
+ //
283
+ // Application-level unique violations (for example, (tenantId, email) on a User
284
+ // entity or (tenantId, slug) on an Article) produce pg-23505 from the projection
285
+ // write. Without this catch, the error propagates as an unhandled exception and
286
+ // becomes a 500 internal_error. Map it to UniqueViolationError 409 so the
287
+ // designer/frontend can show a clear "duplicate" message instead of a cryptic
288
+ // "internal server error".
289
+ // Savepoint like the append: an unclassified DB error from the projection
290
+ // write would otherwise poison the enclosing transaction (postgres-js rejects the
291
+ // whole begin() once any statement in it fails). The savepoint rolls back the failed
292
+ // write while allowing the error to propagate and remain catchable.
293
+ async function applyProjection(runner, event, noRowMessage) {
294
+ let result;
295
+ try {
296
+ result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
297
+ }
298
+ catch (e) {
299
+ const mapped = tryMapUniqueViolation(e, entityName);
300
+ if (mapped)
301
+ return { failure: mapped };
302
+ throw e;
303
+ }
304
+ if (result.kind !== "applied" || result.row === null) {
305
+ return { failure: writeFailure(new InternalError({ message: noRowMessage })) };
306
+ }
307
+ return { row: result.row };
308
+ }
309
+ // Read-side conversion: DB form → combined API form for all compound types in one pass.
310
+ async function readProjectedRow(row) {
311
+ return await decryptForRead(rehydrateCompoundTypes(row, entity));
312
+ }
313
+ async function evictCachedEntity(user, streamTenantId, id) {
314
+ if (entityCache && entityName) {
315
+ await entityCache.del(streamTenantId ?? user.tenantId, entityName, id);
316
+ }
317
+ }
318
+ async function encryptUpdatePayload(user, streamTenantId, previous, changes, mergedNew, options) {
319
+ // Compound-type conversion in one pass.
320
+ // subjectSource: partial changes may carry a pii field without its
321
+ // ownerField — the merged row still identifies the subject.
322
+ // skipUnchanged is opt-in: an identical resubmitted key is dropped before
323
+ // encryption so pii/encrypted fields receive no fresh ciphertext when nothing
324
+ // actually changed.
325
+ // Direct callers (KEK rotation, backfill) rely on the default to force re-encryption.
326
+ const submittedChanges = options?.skipUnchanged
327
+ ? Object.fromEntries(Object.entries(changes).filter(([key, value]) => !isUnchangedValue(value, previous[key])))
328
+ : changes;
329
+ // Crypto-shredding follows the current owner, so personal
330
+ // fields not in this update are re-written under the new owner's key.
331
+ // Owner -> absent is not re-keyed; the value stays under the old owner.
332
+ const changesToPersist = {
333
+ ...submittedChanges,
334
+ ...ownerTransitionRekeys(previous, mergedNew, submittedChanges),
335
+ };
336
+ const flatChangesPlain = flattenCompoundTypes(changesToPersist, entity);
337
+ const flatChanges = await encryptForStorage(flatChangesPlain, user, {
338
+ onlyKeys: Object.keys(changesToPersist),
339
+ subjectSource: mergedNew,
340
+ keyTenantId: streamTenantId,
341
+ ...erasureSentinelOption(options),
342
+ });
343
+ // The event payload carries BOTH `changes` (what the user asked for) AND
344
+ // `previous` (the pre-update row). Cross-aggregate projections need the
345
+ // previous value to decrement/undo when a parent-FK moves — without it
346
+ // you'd have to snapshot-and-diff on every apply, and replays would
347
+ // break. Storage cost is acceptable (rows are bounded), correctness is
348
+ // not negotiable. `previous` came from loadById(), which decrypts —
349
+ // re-encrypt it before it's persisted so plaintext of pii/encrypted
350
+ // fields doesn't land in the immutable log (flatChanges is already
351
+ // ciphertext from encryptForStorage above).
352
+ // Owner chain for the ownerless-then-assigned row: previous owner, then
353
+ // the owner this event sets, then whenAbsent.
354
+ const encryptedPrevious = await encryptForStorage(previous, user, {
355
+ keyTenantId: streamTenantId,
356
+ ownerFallback: submittedChanges,
357
+ ...erasureSentinelOption(options),
358
+ });
359
+ return { flatChangesPlain, flatChanges, encryptedPrevious };
360
+ }
361
+ async function appendUpdatedEvent(runner, user, id, streamTenant, currentVersion, flatChanges, encryptedPrevious) {
362
+ // Savepoint-scoped — see appendCreatedEvent for why:
363
+ // confines a losing writer's unique-violation to a nested scope
364
+ // instead of poisoning the whole outer transaction.
365
+ return await runInSavepointIfSupported(runner, (sp) => append(sp, {
366
+ aggregateId: String(id),
367
+ aggregateType: entityName,
368
+ tenantId: streamTenant,
369
+ expectedVersion: currentVersion,
370
+ type: entityEventName(entityName, "updated"),
371
+ eventVersion,
372
+ payload: {
373
+ changes: flatChanges,
374
+ previous: encryptedPrevious,
375
+ },
376
+ metadata: buildEventMetadata(user),
377
+ }));
378
+ }
379
+ async function persistUpdate(input) {
380
+ const { db, runner, user, id, previous, changes, mergedNew, streamTenantId } = input;
381
+ const { flatChangesPlain, flatChanges, encryptedPrevious } = await encryptUpdatePayload(user, streamTenantId, previous, changes, mergedNew, input.options);
382
+ const event = await appendUpdatedEvent(runner, user, id, input.streamTenant, input.currentVersion, flatChanges, encryptedPrevious);
383
+ // Live and rebuild use applyEntityEvent with the same StoredEvent —
384
+ // apply reads only `changes`, which contain identical ciphertext in live writes
385
+ // and replays.
386
+ //
387
+ // An update that violates a unique index (for example, an email update to an
388
+ // already-existing value) is reported as 409 unique_violation instead of 500
389
+ // internal_error.
390
+ // Savepoint like the create path: a raw DB error from the projection UPDATE would
391
+ // otherwise poison the enclosing transaction, so a sibling write after a failed
392
+ // nested one would throw 500 instead of failing cleanly.
393
+ const projected = await applyProjection(runner, event, "projection update returned no row");
394
+ if ("failure" in projected)
395
+ return projected;
396
+ const data = await readProjectedRow(projected.row);
397
+ await evictCachedEntity(user, streamTenantId, id);
398
+ const echoEvent = {
399
+ ...event,
400
+ payload: {
401
+ changes: stripSensitive(flatChangesPlain),
402
+ previous: stripSensitive(previous),
403
+ },
404
+ };
405
+ await projectEntityEvent(db, runner, echoEvent);
406
+ return { data, echoEvent };
407
+ }
408
+ function assertTenantAllowedOnGlobalEntity(verb, payloadField, tenantId) {
409
+ if (isForeignTenantOnGlobalEntity(entity, tenantId)) {
410
+ throw new AccessDeniedError({
411
+ message: `${entityName}.${verb}: entity is tenancy: "global" — ${payloadField} must be SYSTEM_TENANT_ID or omitted.`,
412
+ });
413
+ }
414
+ }
210
415
  return {
211
416
  async create(payload, user, db, options) {
212
- if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
213
- throw new AccessDeniedError({
214
- message: `${entityName}.create: entity is tenancy: "global" — payload.tenantId must be ` +
215
- "SYSTEM_TENANT_ID or omitted.",
216
- });
217
- }
417
+ assertTenantAllowedOnGlobalEntity("create", "payload.tenantId", payload["tenantId"]);
218
418
  const streamTenantId = options?.streamTenantId;
219
419
  const streamTenantMismatch = streamTenantCreateFailure(db, payload, streamTenantId);
220
420
  if (streamTenantMismatch)
@@ -230,137 +430,32 @@ export function createWriteVerbs(ctx) {
230
430
  if ("failure" in preSaveResult)
231
431
  return preSaveResult.failure;
232
432
  const data = preSaveResult.data;
233
- // After preSave so derived fields count, before the event append so nothing persists.
234
- assertPersonalDataWrite(db, tableNameOf(table), Object.keys(data), entity);
235
- assertInstructionFieldWriteAllowed(entityName, "create", Object.keys(data), entityInstructionFieldNames);
236
- // H.2 — entity-level write-ownership on create. No oldRow exists, so
237
- // only the new row is checked. No Straddle concern for creates.
238
- if (!userCanCreateFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, data)) {
239
- return writeFailure(new UnprocessableError("ownership_denied", {
240
- i18nKey: "errors.ownershipDenied",
241
- details: { scope: "entity", entityName, action: "create", userId: user.id },
242
- }));
243
- }
244
- // Field-level write-ownership on create — mirror of entity-level but
245
- // per declared field. Role-level was already checked by the
246
- // dispatcher; here we enforce ownership-rules against the new row.
247
- //
248
- // Which fields get checked is scoped to the pre-hook payload (fw#1685)
249
- // — a hook-derived field the user never submitted must not be
250
- // field-ownership-checked against the user. The rule for a checked
251
- // field is still evaluated against the full post-hook row (`data`) —
252
- // an ownership rule can reference a column only a hook populates
253
- // (kumiko-framework#1672).
254
- const fieldDeniedCreate = checkWriteFieldOwnership(entity, applyDefaults(payloadWithoutId), ownershipSubject(user, streamTenantId), undefined, data);
255
- if (fieldDeniedCreate) {
256
- return writeFailure(new UnprocessableError("ownership_denied", {
257
- i18nKey: "errors.ownershipDenied",
258
- details: {
259
- scope: "field",
260
- entityName,
261
- action: "create",
262
- field: fieldDeniedCreate,
263
- userId: user.id,
264
- },
265
- }));
266
- }
433
+ assertFieldWritesAllowed(db, "create", data);
434
+ const ownershipDenied = createOwnershipFailure(user, streamTenantId, payloadWithoutId, data);
435
+ if (ownershipDenied)
436
+ return ownershipDenied;
267
437
  const unavailableOnCreate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, data));
268
438
  if (unavailableOnCreate)
269
439
  return unavailableOnCreate;
270
- // Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
271
- // dieselbe Pipeline. Caller schickt combined API-Form, Framework
272
- // speichert flat DB-Form. Siehe db/compound-types.ts.
273
- // subjectSource carries the freshly minted aggregateId: the create
274
- // payload has no id column, but a pii:true self-subject resolves from it.
440
+ // All compound types (locatedTimestamp, money, ...) use the same pipeline: the
441
+ // caller sends the combined API form, the framework stores the flat DB form (see
442
+ // db/compound-types.ts).
443
+ // subjectSource carries the freshly minted aggregateId: the create payload has no id
444
+ // column, but a pii:true self-subject resolves from it.
275
445
  const flatCreateData = flattenCompoundTypes(data, entity);
276
446
  const flatData = await encryptForStorage(flatCreateData, user, {
277
447
  subjectSource: { ...flatCreateData, id: aggregateId },
278
448
  keyTenantId: streamTenantId,
279
449
  });
280
- // 1. Append event (same TX as the projection write — both must succeed
281
- // or both roll back; the dispatcher wraps both in one transaction).
282
- // flatData is already table ciphertext for pii/encrypted fields, so
283
- // the immutable log never sees plaintext and replay reproduces the
284
- // row byte-identically (#967).
285
- //
286
- // `expectedVersion: 0` heißt: stream existiert noch nicht. Bei
287
- // deterministic-aggregate-id-Patterns (z.B. uuidv5(tenantId|naturalKey))
288
- // ist es legitim dass create kollidiert — selbe id, schon vorhandener
289
- // stream → version_conflict statt internal_error. Update hat den
290
- // selben catch (siehe line 493+).
291
- let event;
292
- try {
293
- // Savepoint-scoped: postgres.js poisons the WHOLE surrounding
294
- // begin() once any statement inside it errors, even if the JS error
295
- // is caught (kumiko-framework#1778) — a losing concurrent create's
296
- // unique-violation would otherwise abort the caller's outer
297
- // transaction and surface as internal_error at commit time instead
298
- // of the version_conflict this catch classifies. runInSavepointIfSupported
299
- // confines the failed INSERT to a nested scope that rolls back on
300
- // its own (same pattern as ctx.tryAppendEvent), and falls back to a
301
- // plain call when the runner is a bare pool connection with no active
302
- // transaction to poison (seeds/tests calling the executor directly).
303
- event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
304
- aggregateId,
305
- aggregateType: entityName,
306
- tenantId: streamTenantFor(user, streamTenantId),
307
- expectedVersion: 0,
308
- type: entityEventName(entityName, "created"),
309
- eventVersion,
310
- payload: flatData,
311
- metadata: buildEventMetadata(user),
312
- }));
313
- }
314
- catch (e) {
315
- if (e instanceof EventStoreVersionConflict) {
316
- const currentVersion = await versionAfterConflict(runner, aggregateId, streamTenantFor(user, streamTenantId));
317
- return writeFailure(new FrameworkVersionConflict({
318
- entityId: aggregateId,
319
- expectedVersion: 0,
320
- currentVersion,
321
- }));
322
- }
323
- if (e instanceof EventStoreIdempotentAppendConflict) {
324
- return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
325
- }
326
- throw e;
327
- }
328
- // 2. Update projection via applyEntityEvent — derselbe Code-Pfad den
329
- // rebuildProjection für Replay nutzt, mit demselben StoredEvent →
330
- // Live==Rebuild by-construction (#967).
331
- //
332
- // F8-Patch: app-level unique-violations (z.B. (tenantId, email)
333
- // auf User-Entity, (tenantId, slug) auf Article) werfen pg-23505
334
- // aus der projection-INSERT. Ohne den catch propagiert das als
335
- // unhandled exception → 500 internal_error. Map auf
336
- // UniqueViolationError 409 damit Designer/Frontend einen sauberen
337
- // "duplicate" zeigen können statt cryptic "internal server error".
338
- // Savepoint like the append above: an unclassified DB error out of the
339
- // projection INSERT would otherwise poison the enclosing tx (postgres-js
340
- // rejects the whole begin() once any statement in it failed), so a later
341
- // write sharing that tx — a sibling write after a failed nested one —
342
- // throws raw as a 500 instead of failing cleanly. The savepoint rolls the
343
- // failed INSERT back; the error still propagates and stays catchable.
344
- let result;
345
- try {
346
- result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
347
- }
348
- catch (e) {
349
- const mapped = tryMapUniqueViolation(e, entityName);
350
- if (mapped)
351
- return mapped;
352
- throw e;
353
- }
354
- if (result.kind !== "applied" || result.row === null) {
355
- return writeFailure(new InternalError({ message: "projection insert returned no row" }));
356
- }
357
- const row = result.row;
358
- // Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
359
- // Compound-Types in einem Pass.
360
- const projection = await decryptForRead(rehydrateCompoundTypes(row, entity));
361
- if (entityCache && entityName) {
362
- await entityCache.del(streamTenantId ?? user.tenantId, entityName, aggregateId);
363
- }
450
+ const appended = await appendCreatedEvent(runner, user, streamTenantId, aggregateId, flatData);
451
+ if ("failure" in appended)
452
+ return appended.failure;
453
+ const event = appended.event;
454
+ const projected = await applyProjection(runner, event, "projection insert returned no row");
455
+ if ("failure" in projected)
456
+ return projected.failure;
457
+ const projection = await readProjectedRow(projected.row);
458
+ await evictCachedEntity(user, streamTenantId, aggregateId);
364
459
  // The echoed event is the one projections see (and the dispatcher later
365
460
  // skips), so build it before projecting.
366
461
  const echoEvent = { ...event, payload: stripSensitive(flatCreateData) };
@@ -376,19 +471,14 @@ export function createWriteVerbs(ctx) {
376
471
  isNew: true,
377
472
  entityName,
378
473
  // Persisted event carries ciphertext by design — the caller-facing
379
- // echo must be plaintext like every other response field (#820).
474
+ // echo must be plaintext like every other response field.
380
475
  event: echoEvent,
381
476
  }),
382
477
  };
383
478
  },
384
479
  async update(payload, user, db, updateOptions) {
385
480
  const runner = tenantDbWriteRunner(db, "ctx.db executor update");
386
- if (isForeignTenantOnGlobalEntity(entity, payload.changes["tenantId"])) {
387
- throw new AccessDeniedError({
388
- message: `${entityName}.update: entity is tenancy: "global" — payload.changes.tenantId must ` +
389
- "be SYSTEM_TENANT_ID or omitted.",
390
- });
391
- }
481
+ assertTenantAllowedOnGlobalEntity("update", "payload.changes.tenantId", payload.changes["tenantId"]);
392
482
  const previous = await loadById(payload.id, db, {
393
483
  includeDeleted: updateOptions?.includeDeleted === true,
394
484
  });
@@ -402,193 +492,38 @@ export function createWriteVerbs(ctx) {
402
492
  if ("failure" in preSaveResult)
403
493
  return preSaveResult.failure;
404
494
  const changes = preSaveResult.data;
405
- // After preSave so derived fields count, before the event append so nothing persists.
406
- assertPersonalDataWrite(db, tableNameOf(table), Object.keys(changes), entity);
407
- assertInstructionFieldWriteAllowed(entityName, "update", Object.keys(changes), entityInstructionFieldNames);
408
- // H.2 — entity-level write-ownership on update. Load old row (already
409
- // done above), build post-change row via shallow merge. Straddle-safe
410
- // multi-role check: at least one role must accept BOTH old and new —
411
- // prevents the attack where role A passes old, role B passes new and
412
- // aggregation would wrongly allow a row-grab.
495
+ assertFieldWritesAllowed(db, "update", changes);
413
496
  const mergedNew = { ...previous, ...changes };
414
- if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, previous, mergedNew)) {
415
- return writeFailure(new UnprocessableError("ownership_denied", {
416
- i18nKey: "errors.ownershipDenied",
417
- details: {
418
- scope: "entity",
419
- entityName,
420
- action: "update",
421
- userId: user.id,
422
- entityId: payload.id,
423
- },
424
- }));
425
- }
426
- // Field-level write-ownership on update — this is the path the
427
- // dispatcher could not evaluate (no oldRow). Now that we have
428
- // `previous`, we can run the ownership rules per field against both
429
- // sides and reject individual fields the user isn't entitled to
430
- // touch on this specific row.
431
- //
432
- // Which fields get checked is scoped to `payload.changes` (the user's
433
- // actual submission), NOT `changes` (post-preSave-hook data, fw#1685)
434
- // — a hook-derived field the user never submitted (e.g. a system hook
435
- // setting `assignedTo`) has no business being field-ownership-checked
436
- // against the *user*, and doing so rejects writes the user is fully
437
- // entitled to make. The dispatcher's role-gate (checkWriteFieldAccess)
438
- // already runs on this same pre-hook payload for consistency. The rule
439
- // for a checked field is still evaluated against the full post-hook
440
- // row (`changes` merged onto `previous`) — an ownership rule can
441
- // reference a column only a hook populates (kumiko-framework#1672).
442
- const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, ownershipSubject(user, streamTenantId), previous, changes);
443
- if (fieldDeniedUpdate) {
444
- return writeFailure(new UnprocessableError("ownership_denied", {
445
- i18nKey: "errors.ownershipDenied",
446
- details: {
447
- scope: "field",
448
- entityName,
449
- action: "update",
450
- field: fieldDeniedUpdate,
451
- userId: user.id,
452
- entityId: payload.id,
453
- },
454
- }));
455
- }
456
- const lockedUpdate = recordLockedFailure(previous, payload.id, user, updateOptions);
457
- if (lockedUpdate)
458
- return lockedUpdate;
459
- // Statutory retention/erasure writes may change a condition field (e.g. null a PII
460
- // reference) and must not be blocked by the select rule — same exemption as the lock.
461
- const isStatutorySystemWrite = updateOptions?.writePurpose !== undefined && isFrameworkSystemUser(user);
462
- const unavailableOnUpdate = isStatutorySystemWrite
463
- ? undefined
464
- : selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
465
- if (unavailableOnUpdate)
466
- return unavailableOnUpdate;
497
+ const guardFailure = updateOwnershipFailure(user, streamTenantId, payload.id, payload.changes, previous, changes, mergedNew) ??
498
+ recordLockedFailure(previous, payload.id, user, updateOptions) ??
499
+ updateSelectOptionFailure(user, mergedNew, previous, updateOptions);
500
+ if (guardFailure)
501
+ return guardFailure;
467
502
  const streamTenant = streamTenantFor(user, streamTenantId);
468
503
  await assertStreamWritable(db, payload.id, streamTenant);
469
- // Stream-version is authoritative, not row.version. `ctx.appendEvent`
470
- // can bump the stream between CRUD writes (domain event on the same
471
- // aggregate); a stale row.version here would make the next CRUD write
472
- // trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
473
- // with version_conflict.
474
- //
475
- // With `expect:` the version comes from loadExpectSnapshot's combined
476
- // query instead; see its comment for why.
477
- const expect = updateOptions?.expect;
478
- const currentVersion = expect
479
- ? await expectedStreamVersion(db, payload.id, streamTenant, expect)
480
- : await getStreamVersion(runner, String(payload.id), streamTenant);
504
+ const currentVersion = await currentStreamVersion(db, runner, payload.id, streamTenant, updateOptions?.expect);
481
505
  if (typeof currentVersion !== "number")
482
506
  return currentVersion;
483
- if (!updateOptions?.skipOptimisticLock) {
484
- if (payload.version === undefined) {
485
- return writeFailure(new FrameworkVersionConflict({
486
- entityId: payload.id,
487
- expectedVersion: 0,
488
- currentVersion,
489
- }));
490
- }
491
- if (currentVersion !== payload.version) {
492
- return writeFailure(new FrameworkVersionConflict({
493
- entityId: payload.id,
494
- expectedVersion: payload.version,
495
- currentVersion,
496
- }));
497
- }
498
- }
507
+ const versionConflict = optimisticLockFailure(payload.id, payload.version, currentVersion, updateOptions?.skipOptimisticLock);
508
+ if (versionConflict)
509
+ return versionConflict;
499
510
  try {
500
- // Compound-Types Auto-Convert (alle in einem Pass).
501
- // subjectSource: partial changes may carry a pii field without its
502
- // ownerField — the merged row still names the subject.
503
- // skipUnchanged (#464) is opt-in: an identical resubmitted key is dropped before
504
- // encryption so pii/encrypted fields get no fresh ciphertext for no real change.
505
- // Direct callers (KEK rotation, #494 backfill) rely on the default to force a re-encrypt.
506
- const submittedChanges = updateOptions?.skipUnchanged
507
- ? Object.fromEntries(Object.entries(changes).filter(([key, value]) => !isUnchangedValue(value, previous[key])))
508
- : changes;
509
- // fw#3670: crypto-shredding follows the current owner, so personal
510
- // fields not in this update are re-written under the new owner's key.
511
- // Owner -> absent is not re-keyed; the value stays under the old owner.
512
- const changesToPersist = {
513
- ...submittedChanges,
514
- ...ownerTransitionRekeys(previous, mergedNew, submittedChanges),
515
- };
516
- const flatChangesPlain = flattenCompoundTypes(changesToPersist, entity);
517
- const flatChanges = await encryptForStorage(flatChangesPlain, user, {
518
- onlyKeys: Object.keys(changesToPersist),
519
- subjectSource: mergedNew,
520
- keyTenantId: streamTenantId,
521
- ...erasureSentinelOption(updateOptions),
522
- });
523
- // The event payload carries BOTH `changes` (what the user asked for) AND
524
- // `previous` (the pre-update row). Cross-aggregate projections need the
525
- // previous value to decrement/undo when a parent-FK moves — without it
526
- // you'd have to snapshot-and-diff on every apply, and replays would
527
- // break. Storage cost is acceptable (rows are bounded), correctness is
528
- // not negotiable. `previous` came from loadById(), which decrypts —
529
- // re-encrypt it before it's persisted so plaintext of pii/encrypted
530
- // fields doesn't land in the immutable log (flatChanges is already
531
- // ciphertext from encryptForStorage above).
532
- // Owner chain for the ownerless-then-assigned row: previous owner, then the
533
- // owner this event sets, then whenAbsent (fw#3654).
534
- const encryptedPrevious = await encryptForStorage(previous, user, {
535
- keyTenantId: streamTenantId,
536
- ownerFallback: submittedChanges,
537
- ...erasureSentinelOption(updateOptions),
511
+ const persisted = await persistUpdate({
512
+ db,
513
+ runner,
514
+ user,
515
+ id: payload.id,
516
+ previous,
517
+ changes,
518
+ mergedNew,
519
+ streamTenantId,
520
+ streamTenant,
521
+ currentVersion,
522
+ options: updateOptions,
538
523
  });
539
- // Savepoint-scoped — see the create() append() above for why:
540
- // confines a losing writer's unique-violation to a nested scope
541
- // instead of poisoning the whole outer transaction.
542
- const event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
543
- aggregateId: String(payload.id),
544
- aggregateType: entityName,
545
- tenantId: streamTenant,
546
- expectedVersion: currentVersion,
547
- type: entityEventName(entityName, "updated"),
548
- eventVersion,
549
- payload: {
550
- changes: flatChanges,
551
- previous: encryptedPrevious,
552
- },
553
- metadata: buildEventMetadata(user),
554
- }));
555
- // Live==Rebuild via applyEntityEvent mit demselben StoredEvent —
556
- // apply liest nur `changes`, und die sind live wie im Replay
557
- // identischer Ciphertext (#967).
558
- //
559
- // F8-Patch: dasselbe unique-violation-handling wie im create-Pfad
560
- // — ein update das einen unique-Index verletzt (z.B. email-update
561
- // auf einen schon-existierenden Wert) wird mit 409 unique_violation
562
- // statt 500 internal_error rückgemeldet.
563
- // Savepoint like the create path: a raw DB error out of the projection
564
- // UPDATE poisons the enclosing tx otherwise, so a sibling write after a
565
- // failed nested one throws 500 instead of failing cleanly.
566
- let result;
567
- try {
568
- result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
569
- }
570
- catch (e) {
571
- const mapped = tryMapUniqueViolation(e, entityName);
572
- if (mapped)
573
- return mapped;
574
- throw e;
575
- }
576
- if (result.kind !== "applied" || result.row === null) {
577
- return writeFailure(new InternalError({ message: "projection update returned no row" }));
578
- }
579
- const row = result.row;
580
- const data = await decryptForRead(rehydrateCompoundTypes(row, entity));
581
- if (entityCache && entityName) {
582
- await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
583
- }
584
- const echoEvent = {
585
- ...event,
586
- payload: {
587
- changes: stripSensitive(flatChangesPlain),
588
- previous: stripSensitive(previous),
589
- },
590
- };
591
- await projectEntityEvent(db, runner, echoEvent);
524
+ if ("failure" in persisted)
525
+ return persisted.failure;
526
+ const { data, echoEvent } = persisted;
592
527
  return {
593
528
  isSuccess: true,
594
529
  data: withoutEventInJson({
@@ -608,17 +543,11 @@ export function createWriteVerbs(ctx) {
608
543
  // branch catches the narrow race where two writers both read version=N
609
544
  // and both pass the local check — the unique index on (aggregate_id,
610
545
  // version) serializes them, one wins, the other lands here.
611
- if (e instanceof EventStoreVersionConflict) {
612
- return writeFailure(new FrameworkVersionConflict({
613
- entityId: payload.id,
614
- expectedVersion: payload.version ?? 0,
615
- currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenant),
616
- }));
617
- }
618
- if (e instanceof EventStoreIdempotentAppendConflict) {
619
- return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
620
- }
621
- throw e;
546
+ return await appendConflictFailure(e, runner, {
547
+ entityId: payload.id,
548
+ expectedVersion: currentVersion,
549
+ streamTenant,
550
+ });
622
551
  }
623
552
  },
624
553
  async delete(payload, user, db, options) {
@@ -638,16 +567,13 @@ export function createWriteVerbs(ctx) {
638
567
  // twice to userCanWriteFieldRow makes the Straddle check trivial
639
568
  // (same row on both sides) while keeping the multi-role-atomic shape.
640
569
  if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
641
- return writeFailure(new UnprocessableError("ownership_denied", {
642
- i18nKey: "errors.ownershipDenied",
643
- details: {
644
- scope: "entity",
645
- entityName,
646
- action: "delete",
647
- userId: user.id,
648
- entityId: payload.id,
649
- },
650
- }));
570
+ return ownershipDeniedFailure({
571
+ scope: "entity",
572
+ entityName,
573
+ action: "delete",
574
+ userId: user.id,
575
+ entityId: payload.id,
576
+ });
651
577
  }
652
578
  const lockedDelete = recordLockedFailure(existing, payload.id, user, options);
653
579
  if (lockedDelete)
@@ -680,26 +606,18 @@ export function createWriteVerbs(ctx) {
680
606
  }));
681
607
  }
682
608
  catch (e) {
683
- if (e instanceof EventStoreVersionConflict) {
684
- return writeFailure(new FrameworkVersionConflict({
685
- entityId: payload.id,
686
- expectedVersion: currentVersion,
687
- currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenantFor(user, streamTenantId)),
688
- }));
689
- }
690
- if (e instanceof EventStoreIdempotentAppendConflict) {
691
- return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
692
- }
693
- throw e;
609
+ return await appendConflictFailure(e, runner, {
610
+ entityId: payload.id,
611
+ expectedVersion: currentVersion,
612
+ streamTenant: streamTenantFor(user, streamTenantId),
613
+ });
694
614
  }
695
615
  // Live==Rebuild via applyEntityEvent. Savepoint like create/update.
696
616
  const deleteResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
697
617
  if (deleteResult.kind !== "applied") {
698
618
  return writeFailure(new InternalError({ message: "projection delete: applyEntityEvent skipped" }));
699
619
  }
700
- if (entityCache && entityName) {
701
- await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
702
- }
620
+ await evictCachedEntity(user, streamTenantId, payload.id);
703
621
  const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
704
622
  await projectEntityEvent(db, runner, echoEvent);
705
623
  return {
@@ -728,19 +646,16 @@ export function createWriteVerbs(ctx) {
728
646
  const streamTenantMismatch = streamTenantOverrideFailure(db, raw, streamTenantId);
729
647
  if (streamTenantMismatch)
730
648
  return streamTenantMismatch;
731
- const existing = await decryptForRead(rehydrateCompoundTypes(raw, entity));
649
+ const existing = await readProjectedRow(raw);
732
650
  if (!isFrameworkSystemUser(user) &&
733
651
  !userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
734
- return writeFailure(new UnprocessableError("ownership_denied", {
735
- i18nKey: "errors.ownershipDenied",
736
- details: {
737
- scope: "entity",
738
- entityName,
739
- action: "delete",
740
- userId: user.id,
741
- entityId: payload.id,
742
- },
743
- }));
652
+ return ownershipDeniedFailure({
653
+ scope: "entity",
654
+ entityName,
655
+ action: "delete",
656
+ userId: user.id,
657
+ entityId: payload.id,
658
+ });
744
659
  }
745
660
  await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
746
661
  const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
@@ -758,32 +673,24 @@ export function createWriteVerbs(ctx) {
758
673
  payload: {
759
674
  previous: await encryptForStorage(existing, user, {
760
675
  keyTenantId: streamTenantId,
761
- onErasedSubject: "sentinel",
676
+ onErasedSubject: ErasedSubjectHandlings.sentinel,
762
677
  }),
763
678
  },
764
679
  metadata: buildEventMetadata(user),
765
680
  }));
766
681
  }
767
682
  catch (e) {
768
- if (e instanceof EventStoreVersionConflict) {
769
- return writeFailure(new FrameworkVersionConflict({
770
- entityId: payload.id,
771
- expectedVersion: currentVersion,
772
- currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenantFor(user, streamTenantId)),
773
- }));
774
- }
775
- if (e instanceof EventStoreIdempotentAppendConflict) {
776
- return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
777
- }
778
- throw e;
683
+ return await appendConflictFailure(e, runner, {
684
+ entityId: payload.id,
685
+ expectedVersion: currentVersion,
686
+ streamTenant: streamTenantFor(user, streamTenantId),
687
+ });
779
688
  }
780
689
  const forgetResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
781
690
  if (forgetResult.kind !== "applied") {
782
691
  return writeFailure(new InternalError({ message: "projection forget: applyEntityEvent skipped" }));
783
692
  }
784
- if (entityCache && entityName) {
785
- await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
786
- }
693
+ await evictCachedEntity(user, streamTenantId, payload.id);
787
694
  const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
788
695
  await projectEntityEvent(db, runner, echoEvent);
789
696
  return {
@@ -814,16 +721,13 @@ export function createWriteVerbs(ctx) {
814
721
  // only the stored row matters. Stored row carries pre-soft-delete
815
722
  // teamId/... fields, so the ownership predicate still applies cleanly.
816
723
  if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, data, data)) {
817
- return writeFailure(new UnprocessableError("ownership_denied", {
818
- i18nKey: "errors.ownershipDenied",
819
- details: {
820
- scope: "entity",
821
- entityName,
822
- action: "restore",
823
- userId: user.id,
824
- entityId: payload.id,
825
- },
826
- }));
724
+ return ownershipDeniedFailure({
725
+ scope: "entity",
726
+ entityName,
727
+ action: "restore",
728
+ userId: user.id,
729
+ entityId: payload.id,
730
+ });
827
731
  }
828
732
  await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
829
733
  // Stream-version authoritative (see update() for rationale).
@@ -843,29 +747,16 @@ export function createWriteVerbs(ctx) {
843
747
  // plus the version bump, so there is no sensitive-field drift and no
844
748
  // payload override is needed.
845
749
  // Soft-delete unique indexes are partial (WHERE is_deleted = false), so a live row may have re-taken the value this restore brings back.
846
- let restoreResult;
847
- try {
848
- restoreResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
849
- }
850
- catch (e) {
851
- const mapped = tryMapUniqueViolation(e, entityName);
852
- if (mapped)
853
- return mapped;
854
- throw e;
855
- }
856
- if (restoreResult.kind !== "applied" || restoreResult.row === null) {
857
- return writeFailure(new InternalError({ message: "projection restore returned no row" }));
858
- }
859
- const restored = restoreResult.row;
860
- if (entityCache && entityName) {
861
- await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
862
- }
750
+ const projected = await applyProjection(runner, event, "projection restore returned no row");
751
+ if ("failure" in projected)
752
+ return projected.failure;
753
+ await evictCachedEntity(user, streamTenantId, payload.id);
863
754
  // Read-side auto-convert for compound types, same as update/list.
864
755
  // decryptForRead matches create/update/list/detail: the caller-facing
865
756
  // row and `previous` snapshot must be plaintext for `encrypted` fields,
866
757
  // same as every other executor method — `data`/`restored` are raw rows
867
758
  // (db.fetchOne / applyEntityEvent), never decrypted before this point.
868
- const restoredHydrated = await decryptForRead(rehydrateCompoundTypes(restored, entity));
759
+ const restoredHydrated = await readProjectedRow(projected.row);
869
760
  const echoEvent = { ...event, payload: { previous: stripSensitive(previousPlain) } };
870
761
  await projectEntityEvent(db, runner, echoEvent);
871
762
  return {