@cosmicdrift/kumiko-framework 0.387.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 (240) hide show
  1. package/AGENTS.md +6 -1
  2. package/agents/examples/anonymous-access.md +1 -1
  3. package/agents/examples/apex-landing.md +204 -0
  4. package/agents/examples/api-worker-split.md +64 -0
  5. package/agents/examples/app-config.md +14 -0
  6. package/agents/examples/auth-mfa.md +115 -0
  7. package/agents/examples/env-composition.md +28 -1
  8. package/agents/examples/legal-pages.md +1 -1
  9. package/agents/examples/prod-boot-options.md +16 -0
  10. package/agents/examples/rate-limiting.md +48 -0
  11. package/agents/examples/record-detail-layout.md +692 -0
  12. package/agents/examples/seed-migration.md +212 -0
  13. package/agents/examples/wizard-form.md +941 -0
  14. package/agents/examples/writeform-section.md +548 -0
  15. package/agents/features.md +19 -2
  16. package/agents/widgets.md +1 -0
  17. package/dist/api/client-error-route.js +8 -2
  18. package/dist/api/index.d.ts +3 -1
  19. package/dist/api/index.js +3 -1
  20. package/dist/api/request-context.d.ts +1 -1
  21. package/dist/api/request-context.js +1 -1
  22. package/dist/api/server-boot-checks.d.ts +10 -0
  23. package/dist/api/server-boot-checks.js +157 -0
  24. package/dist/api/server-event-dispatcher.d.ts +26 -0
  25. package/dist/api/server-event-dispatcher.js +208 -0
  26. package/dist/api/server-extra-routes.d.ts +11 -0
  27. package/dist/api/server-extra-routes.js +203 -0
  28. package/dist/api/server-middleware.d.ts +20 -0
  29. package/dist/api/server-middleware.js +211 -0
  30. package/dist/api/server-routes.d.ts +19 -0
  31. package/dist/api/server-routes.js +293 -0
  32. package/dist/api/server-websocket-routes.d.ts +4 -0
  33. package/dist/api/server-websocket-routes.js +108 -0
  34. package/dist/api/server-wiring.d.ts +32 -0
  35. package/dist/api/server-wiring.js +236 -0
  36. package/dist/api/server.d.ts +10 -15
  37. package/dist/api/server.js +48 -1251
  38. package/dist/api/session-cookies.js +4 -4
  39. package/dist/api/sse-route.js +16 -12
  40. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  41. package/dist/crypto/owner-transition-rekey.js +1 -1
  42. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  43. package/dist/crypto/pii-field-encryption.js +3 -1
  44. package/dist/db/aggregate-sql.d.ts +21 -0
  45. package/dist/db/aggregate-sql.js +155 -0
  46. package/dist/db/closed-connection.d.ts +9 -0
  47. package/dist/db/closed-connection.js +95 -0
  48. package/dist/db/dialect.js +1 -1
  49. package/dist/db/distinct-on.d.ts +1 -1
  50. package/dist/db/driver-row-coercion.d.ts +2 -0
  51. package/dist/db/driver-row-coercion.js +186 -0
  52. package/dist/db/driver-value-preparation.d.ts +16 -0
  53. package/dist/db/driver-value-preparation.js +99 -0
  54. package/dist/db/entity-field-encryption.d.ts +1 -0
  55. package/dist/db/entity-field-encryption.js +1 -1
  56. package/dist/db/entity-table-meta.js +103 -110
  57. package/dist/db/event-store-executor-context.d.ts +2 -1
  58. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  59. package/dist/db/event-store-executor-read-aggregate.js +112 -0
  60. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  61. package/dist/db/event-store-executor-read-filter.js +203 -0
  62. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  63. package/dist/db/event-store-executor-read-paging.js +99 -0
  64. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  65. package/dist/db/event-store-executor-read-rows.js +32 -0
  66. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  67. package/dist/db/event-store-executor-read-search.js +221 -0
  68. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  69. package/dist/db/event-store-executor-read-sort.js +130 -0
  70. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  71. package/dist/db/event-store-executor-read-where.js +81 -0
  72. package/dist/db/event-store-executor-read.d.ts +2 -9
  73. package/dist/db/event-store-executor-read.js +48 -701
  74. package/dist/db/event-store-executor-write-steps.d.ts +20 -0
  75. package/dist/db/event-store-executor-write-steps.js +100 -0
  76. package/dist/db/event-store-executor-write.js +395 -499
  77. package/dist/db/index.d.ts +6 -2
  78. package/dist/db/index.js +5 -1
  79. package/dist/db/list-aggregate-labels.d.ts +14 -0
  80. package/dist/db/list-aggregate-labels.js +26 -0
  81. package/dist/db/migrate-generator.js +71 -86
  82. package/dist/db/migrate-runner.js +4 -1
  83. package/dist/db/query.d.ts +2 -15
  84. package/dist/db/query.js +8 -761
  85. package/dist/db/reference-data.js +6 -1
  86. package/dist/db/table-builder.js +113 -166
  87. package/dist/db/table-info.d.ts +10 -0
  88. package/dist/db/table-info.js +45 -0
  89. package/dist/db/temporal-value-guards.d.ts +6 -0
  90. package/dist/db/temporal-value-guards.js +15 -0
  91. package/dist/db/tenant-db-runner.d.ts +1 -0
  92. package/dist/db/tenant-db-runner.js +3 -0
  93. package/dist/db/tenant-db.js +39 -9
  94. package/dist/db/where-clause.d.ts +6 -0
  95. package/dist/db/where-clause.js +236 -0
  96. package/dist/derivatives/delete-stored-file.js +2 -1
  97. package/dist/engine/boot-validator/boot-check.js +1 -1
  98. package/dist/engine/boot-validator/detail-screens.js +2 -2
  99. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  100. package/dist/engine/boot-validator/entity-handler.js +17 -0
  101. package/dist/engine/boot-validator/form-derivations.js +35 -1
  102. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  103. package/dist/engine/boot-validator/index.js +2 -0
  104. package/dist/engine/boot-validator/pii-retention.js +233 -155
  105. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  106. package/dist/engine/boot-validator/screens-actions.js +8 -1
  107. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  108. package/dist/engine/boot-validator/screens-forms.js +2 -6
  109. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  110. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  111. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  112. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  113. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  114. package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
  115. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  116. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  117. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  118. package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
  119. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  120. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  121. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  122. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  123. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens-lists.js +12 -9
  125. package/dist/engine/boot-validator/screens.d.ts +0 -2
  126. package/dist/engine/boot-validator/screens.js +99 -860
  127. package/dist/engine/build-app-schema.js +56 -96
  128. package/dist/engine/define-feature.js +9 -2
  129. package/dist/engine/define-handler.js +1 -0
  130. package/dist/engine/entity-handlers.d.ts +13 -0
  131. package/dist/engine/entity-handlers.js +10 -21
  132. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  133. package/dist/engine/entity-list-runtime-options.js +18 -0
  134. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  135. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  136. package/dist/engine/feature-ast/patch.js +4 -182
  137. package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
  138. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  139. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  140. package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
  141. package/dist/engine/feature-ast/patterns.d.ts +9 -0
  142. package/dist/engine/feature-ast/patterns.js +43 -51
  143. package/dist/engine/feature-ast/render.js +48 -87
  144. package/dist/engine/feature-entity-handlers.js +3 -0
  145. package/dist/engine/form-derivations.js +18 -7
  146. package/dist/engine/handler-field-references.js +1 -1
  147. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  148. package/dist/engine/project-app-schema-for-roles.js +6 -4
  149. package/dist/engine/registry-ingest.js +1 -1
  150. package/dist/engine/schema-builder.js +228 -212
  151. package/dist/engine/screen-helpers.js +1 -1
  152. package/dist/engine/screen-kind.d.ts +7 -0
  153. package/dist/engine/screen-kind.js +1 -0
  154. package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
  155. package/dist/engine/validate-projection-allowlist.js +2 -1
  156. package/dist/entrypoint/index.d.ts +4 -1
  157. package/dist/entrypoint/index.js +7 -1
  158. package/dist/errors/docs-url.d.ts +1 -1
  159. package/dist/errors/docs-url.js +22 -2
  160. package/dist/es-ops/context.js +1 -1
  161. package/dist/event-store/admin-api.js +1 -1
  162. package/dist/event-store/event-store.js +10 -4
  163. package/dist/files/bind-pending-file-refs.js +3 -3
  164. package/dist/files/create-file-ref-from-bytes.js +4 -1
  165. package/dist/files/file-handle.js +2 -3
  166. package/dist/files/file-routes.js +11 -8
  167. package/dist/files/resolve-content-type.js +43 -0
  168. package/dist/files/upload-ledger.js +7 -0
  169. package/dist/i18n/required-surface-keys.js +182 -206
  170. package/dist/internal/stack-support.d.ts +2 -1
  171. package/dist/internal/stack-support.js +1 -1
  172. package/dist/jobs/job-runner-context.d.ts +32 -0
  173. package/dist/jobs/job-runner-context.js +305 -0
  174. package/dist/jobs/job-runner-execution.d.ts +7 -0
  175. package/dist/jobs/job-runner-execution.js +160 -0
  176. package/dist/jobs/job-runner-gates.d.ts +19 -0
  177. package/dist/jobs/job-runner-gates.js +198 -0
  178. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  179. package/dist/jobs/job-runner-helpers.js +61 -2
  180. package/dist/jobs/job-runner-scope.d.ts +44 -0
  181. package/dist/jobs/job-runner-scope.js +102 -0
  182. package/dist/jobs/job-runner-types.d.ts +12 -0
  183. package/dist/jobs/job-runner.d.ts +0 -1
  184. package/dist/jobs/job-runner.js +47 -657
  185. package/dist/observability/escape-hatch-report.d.ts +3 -0
  186. package/dist/observability/escape-hatch-report.js +12 -1
  187. package/dist/pipeline/cascade-handler.js +165 -138
  188. package/dist/pipeline/dispatch-query.js +10 -3
  189. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  190. package/dist/pipeline/dispatch-shared.js +58 -710
  191. package/dist/pipeline/dispatch-write.js +296 -189
  192. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  193. package/dist/pipeline/event-dispatcher-delivery.js +3 -5
  194. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  195. package/dist/pipeline/handler-context-auth.js +54 -0
  196. package/dist/pipeline/handler-context-config.d.ts +14 -0
  197. package/dist/pipeline/handler-context-config.js +113 -0
  198. package/dist/pipeline/handler-context-db.d.ts +24 -0
  199. package/dist/pipeline/handler-context-db.js +149 -0
  200. package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
  201. package/dist/pipeline/handler-context-dispatch.js +89 -0
  202. package/dist/pipeline/handler-context-events.d.ts +43 -0
  203. package/dist/pipeline/handler-context-events.js +237 -0
  204. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  205. package/dist/pipeline/handler-context-notify.js +55 -0
  206. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  207. package/dist/pipeline/handler-context-observability.js +21 -0
  208. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  209. package/dist/pipeline/handler-context-scope.js +88 -0
  210. package/dist/pipeline/notify-scope.js +12 -1
  211. package/dist/pipeline/pre-handler-gates.js +20 -4
  212. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  213. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  214. package/dist/pipeline/projection-rebuild.js +2 -1
  215. package/dist/schema-cli/apply-command.d.ts +2 -0
  216. package/dist/schema-cli/apply-command.js +97 -0
  217. package/dist/schema-cli/baseline-command.d.ts +2 -0
  218. package/dist/schema-cli/baseline-command.js +33 -0
  219. package/dist/schema-cli/generate-command.d.ts +2 -0
  220. package/dist/schema-cli/generate-command.js +90 -0
  221. package/dist/schema-cli/shared.d.ts +39 -0
  222. package/dist/schema-cli/shared.js +95 -0
  223. package/dist/schema-cli/status-command.d.ts +2 -0
  224. package/dist/schema-cli/status-command.js +73 -0
  225. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  226. package/dist/schema-cli/validate-boot-input.js +130 -0
  227. package/dist/schema-cli/validate-command.d.ts +2 -0
  228. package/dist/schema-cli/validate-command.js +130 -0
  229. package/dist/schema-cli.d.ts +2 -18
  230. package/dist/schema-cli.js +47 -604
  231. package/dist/search/meilisearch-adapter.js +2 -1
  232. package/dist/upgrade-cli.js +149 -121
  233. package/package.json +4 -4
  234. package/src/changes.json +310 -0
  235. package/src/scripts/codemod/feature-layout.ts +10 -2
  236. package/src/scripts/codemod/object-form.ts +41 -4
  237. package/src/scripts/codemod/package-merge.ts +58 -8
  238. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  239. package/src/scripts/codemod/testing-injection.ts +122 -42
  240. package/src/scripts/codemod/testing-move.ts +29 -7
@@ -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 —
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. A throwing
65
- // hook is an app-author bug (bad business rule, not a framework fault) — map it
66
- // to a clean writeFailure instead of letting it propagate as an internal_error
67
- // 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.
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,39 +430,10 @@ 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
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
- const fieldDeniedCreate = checkWriteFieldOwnership(entity, applyDefaults(payloadWithoutId), ownershipSubject(user, streamTenantId), undefined, data);
254
- if (fieldDeniedCreate) {
255
- return writeFailure(new UnprocessableError("ownership_denied", {
256
- i18nKey: "errors.ownershipDenied",
257
- details: {
258
- scope: "field",
259
- entityName,
260
- action: "create",
261
- field: fieldDeniedCreate,
262
- userId: user.id,
263
- },
264
- }));
265
- }
433
+ assertFieldWritesAllowed(db, "create", data);
434
+ const ownershipDenied = createOwnershipFailure(user, streamTenantId, payloadWithoutId, data);
435
+ if (ownershipDenied)
436
+ return ownershipDenied;
266
437
  const unavailableOnCreate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, data));
267
438
  if (unavailableOnCreate)
268
439
  return unavailableOnCreate;
@@ -276,86 +447,15 @@ export function createWriteVerbs(ctx) {
276
447
  subjectSource: { ...flatCreateData, id: aggregateId },
277
448
  keyTenantId: streamTenantId,
278
449
  });
279
- // 1. Append the event in the same transaction as the projection write — both must
280
- // succeed or both roll back; the dispatcher wraps them in one transaction.
281
- // flatData is already table ciphertext for pii/encrypted fields, so the immutable
282
- // log never sees plaintext and replay reproduces the row byte-for-byte.
283
- //
284
- // `expectedVersion: 0` means the stream does not yet exist. With deterministic
285
- // aggregate-ID patterns (for example, uuidv5(tenantId|naturalKey)), a create can
286
- // legitimately collide — the same ID has an existing stream — so return
287
- // version_conflict instead of internal_error. Update uses the same catch.
288
- let event;
289
- try {
290
- // Savepoint-scoped: postgres.js poisons the WHOLE surrounding
291
- // begin() once any statement inside it errors, even if the JS error
292
- // is caught — a losing concurrent create's unique-violation
293
- // would otherwise abort the caller's outer transaction and surface as
294
- // internal_error at commit time instead of the version_conflict this
295
- // catch classifies. runInSavepointIfSupported confines the failed
296
- // INSERT to a nested scope that rolls back on its own (same pattern as
297
- // ctx.tryAppendEvent), and falls back to a plain call when the runner
298
- // is a bare pool connection with no active transaction to poison
299
- // (seeds/tests calling the executor directly).
300
- event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
301
- aggregateId,
302
- aggregateType: entityName,
303
- tenantId: streamTenantFor(user, streamTenantId),
304
- expectedVersion: 0,
305
- type: entityEventName(entityName, "created"),
306
- eventVersion,
307
- payload: flatData,
308
- metadata: buildEventMetadata(user),
309
- }));
310
- }
311
- catch (e) {
312
- if (e instanceof EventStoreVersionConflict) {
313
- const currentVersion = await versionAfterConflict(runner, aggregateId, streamTenantFor(user, streamTenantId));
314
- return writeFailure(new FrameworkVersionConflict({
315
- entityId: aggregateId,
316
- expectedVersion: 0,
317
- currentVersion,
318
- }));
319
- }
320
- if (e instanceof EventStoreIdempotentAppendConflict) {
321
- return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
322
- }
323
- throw e;
324
- }
325
- // 2. Update the projection through applyEntityEvent — the same code path used by
326
- // rebuildProjection for replay, with the same StoredEvent, so live and rebuild
327
- // produce the same result by construction.
328
- //
329
- // Application-level unique violations (for example, (tenantId, email) on a User
330
- // entity or (tenantId, slug) on an Article) produce pg-23505 from the projection
331
- // INSERT. Without this catch, the error propagates as an unhandled exception and
332
- // becomes a 500 internal_error. Map it to UniqueViolationError 409 so the
333
- // designer/frontend can show a clear "duplicate" message instead of a cryptic
334
- // "internal server error".
335
- // Savepoint like the append above: an unclassified DB error from the projection
336
- // INSERT would otherwise poison the enclosing transaction (postgres-js rejects the
337
- // whole begin() once any statement in it fails). The savepoint rolls back the failed
338
- // INSERT while allowing the error to propagate and remain catchable.
339
- let result;
340
- try {
341
- result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
342
- }
343
- catch (e) {
344
- const mapped = tryMapUniqueViolation(e, entityName);
345
- if (mapped)
346
- return mapped;
347
- throw e;
348
- }
349
- if (result.kind !== "applied" || result.row === null) {
350
- return writeFailure(new InternalError({ message: "projection insert returned no row" }));
351
- }
352
- const row = result.row;
353
- // Read-side conversion: DB form → combined API form for all
354
- // compound types in one pass.
355
- const projection = await decryptForRead(rehydrateCompoundTypes(row, entity));
356
- if (entityCache && entityName) {
357
- await entityCache.del(streamTenantId ?? user.tenantId, entityName, aggregateId);
358
- }
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);
359
459
  // The echoed event is the one projections see (and the dispatcher later
360
460
  // skips), so build it before projecting.
361
461
  const echoEvent = { ...event, payload: stripSensitive(flatCreateData) };
@@ -378,12 +478,7 @@ export function createWriteVerbs(ctx) {
378
478
  },
379
479
  async update(payload, user, db, updateOptions) {
380
480
  const runner = tenantDbWriteRunner(db, "ctx.db executor update");
381
- if (isForeignTenantOnGlobalEntity(entity, payload.changes["tenantId"])) {
382
- throw new AccessDeniedError({
383
- message: `${entityName}.update: entity is tenancy: "global" — payload.changes.tenantId must ` +
384
- "be SYSTEM_TENANT_ID or omitted.",
385
- });
386
- }
481
+ assertTenantAllowedOnGlobalEntity("update", "payload.changes.tenantId", payload.changes["tenantId"]);
387
482
  const previous = await loadById(payload.id, db, {
388
483
  includeDeleted: updateOptions?.includeDeleted === true,
389
484
  });
@@ -397,193 +492,38 @@ export function createWriteVerbs(ctx) {
397
492
  if ("failure" in preSaveResult)
398
493
  return preSaveResult.failure;
399
494
  const changes = preSaveResult.data;
400
- // After preSave so derived fields count, before the event append so nothing persists.
401
- assertPersonalDataWrite(db, tableNameOf(table), Object.keys(changes), entity);
402
- assertInstructionFieldWriteAllowed(entityName, "update", Object.keys(changes), entityInstructionFieldNames);
403
- // H.2 — entity-level write-ownership on update. Load old row (already
404
- // done above), build post-change row via shallow merge. Straddle-safe
405
- // multi-role check: at least one role must accept BOTH old and new —
406
- // prevents the attack where role A passes old, role B passes new and
407
- // aggregation would wrongly allow a row-grab.
495
+ assertFieldWritesAllowed(db, "update", changes);
408
496
  const mergedNew = { ...previous, ...changes };
409
- if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, previous, mergedNew)) {
410
- return writeFailure(new UnprocessableError("ownership_denied", {
411
- i18nKey: "errors.ownershipDenied",
412
- details: {
413
- scope: "entity",
414
- entityName,
415
- action: "update",
416
- userId: user.id,
417
- entityId: payload.id,
418
- },
419
- }));
420
- }
421
- // Field-level write-ownership on update — this is the path the
422
- // dispatcher could not evaluate (no oldRow). Now that we have
423
- // `previous`, we can run the ownership rules per field against both
424
- // sides and reject individual fields the user isn't entitled to
425
- // touch on this specific row.
426
- //
427
- // Which fields get checked is scoped to `payload.changes` (the user's
428
- // actual submission), NOT `changes` (post-preSave-hook data)
429
- // — a hook-derived field the user never submitted (e.g. a system hook
430
- // setting `assignedTo`) has no business being field-ownership-checked
431
- // against the *user*, and doing so rejects writes the user is fully
432
- // entitled to make. The dispatcher's role-gate (checkWriteFieldAccess)
433
- // already runs on this same pre-hook payload for consistency. The rule
434
- // for a checked field is still evaluated against the full post-hook
435
- // row (`changes` merged onto `previous`) — an ownership rule can
436
- // reference a column only a hook populates.
437
- const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, ownershipSubject(user, streamTenantId), previous, changes);
438
- if (fieldDeniedUpdate) {
439
- return writeFailure(new UnprocessableError("ownership_denied", {
440
- i18nKey: "errors.ownershipDenied",
441
- details: {
442
- scope: "field",
443
- entityName,
444
- action: "update",
445
- field: fieldDeniedUpdate,
446
- userId: user.id,
447
- entityId: payload.id,
448
- },
449
- }));
450
- }
451
- const lockedUpdate = recordLockedFailure(previous, payload.id, user, updateOptions);
452
- if (lockedUpdate)
453
- return lockedUpdate;
454
- // Statutory retention/erasure writes may change a condition field (e.g. null a PII
455
- // reference) and must not be blocked by the select rule — same exemption as the lock.
456
- const isStatutorySystemWrite = updateOptions?.writePurpose !== undefined && isFrameworkSystemUser(user);
457
- const unavailableOnUpdate = isStatutorySystemWrite
458
- ? undefined
459
- : selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
460
- if (unavailableOnUpdate)
461
- 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;
462
502
  const streamTenant = streamTenantFor(user, streamTenantId);
463
503
  await assertStreamWritable(db, payload.id, streamTenant);
464
- // Stream-version is authoritative, not row.version. `ctx.appendEvent`
465
- // can bump the stream between CRUD writes (domain event on the same
466
- // aggregate); a stale row.version here would make the next CRUD write
467
- // trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
468
- // with version_conflict.
469
- //
470
- // With `expect:` the version comes from loadExpectSnapshot's combined
471
- // query instead; see its comment for why.
472
- const expect = updateOptions?.expect;
473
- const currentVersion = expect
474
- ? await expectedStreamVersion(db, payload.id, streamTenant, expect)
475
- : await getStreamVersion(runner, String(payload.id), streamTenant);
504
+ const currentVersion = await currentStreamVersion(db, runner, payload.id, streamTenant, updateOptions?.expect);
476
505
  if (typeof currentVersion !== "number")
477
506
  return currentVersion;
478
- if (!updateOptions?.skipOptimisticLock) {
479
- if (payload.version === undefined) {
480
- return writeFailure(new FrameworkVersionConflict({
481
- entityId: payload.id,
482
- expectedVersion: 0,
483
- currentVersion,
484
- }));
485
- }
486
- if (currentVersion !== payload.version) {
487
- return writeFailure(new FrameworkVersionConflict({
488
- entityId: payload.id,
489
- expectedVersion: payload.version,
490
- currentVersion,
491
- }));
492
- }
493
- }
507
+ const versionConflict = optimisticLockFailure(payload.id, payload.version, currentVersion, updateOptions?.skipOptimisticLock);
508
+ if (versionConflict)
509
+ return versionConflict;
494
510
  try {
495
- // Compound-type conversion in one pass.
496
- // subjectSource: partial changes may carry a pii field without its
497
- // ownerField — the merged row still identifies the subject.
498
- // skipUnchanged is opt-in: an identical resubmitted key is dropped before
499
- // encryption so pii/encrypted fields receive no fresh ciphertext when nothing
500
- // actually changed.
501
- // Direct callers (KEK rotation, backfill) rely on the default to force re-encryption.
502
- const submittedChanges = updateOptions?.skipUnchanged
503
- ? Object.fromEntries(Object.entries(changes).filter(([key, value]) => !isUnchangedValue(value, previous[key])))
504
- : changes;
505
- // Crypto-shredding follows the current owner, so personal
506
- // fields not in this update are re-written under the new owner's key.
507
- // Owner -> absent is not re-keyed; the value stays under the old owner.
508
- const changesToPersist = {
509
- ...submittedChanges,
510
- ...ownerTransitionRekeys(previous, mergedNew, submittedChanges),
511
- };
512
- const flatChangesPlain = flattenCompoundTypes(changesToPersist, entity);
513
- const flatChanges = await encryptForStorage(flatChangesPlain, user, {
514
- onlyKeys: Object.keys(changesToPersist),
515
- subjectSource: mergedNew,
516
- keyTenantId: streamTenantId,
517
- ...erasureSentinelOption(updateOptions),
518
- });
519
- // The event payload carries BOTH `changes` (what the user asked for) AND
520
- // `previous` (the pre-update row). Cross-aggregate projections need the
521
- // previous value to decrement/undo when a parent-FK moves — without it
522
- // you'd have to snapshot-and-diff on every apply, and replays would
523
- // break. Storage cost is acceptable (rows are bounded), correctness is
524
- // not negotiable. `previous` came from loadById(), which decrypts —
525
- // re-encrypt it before it's persisted so plaintext of pii/encrypted
526
- // fields doesn't land in the immutable log (flatChanges is already
527
- // ciphertext from encryptForStorage above).
528
- // Owner chain for the ownerless-then-assigned row: previous owner, then
529
- // the owner this event sets, then whenAbsent.
530
- const encryptedPrevious = await encryptForStorage(previous, user, {
531
- keyTenantId: streamTenantId,
532
- ownerFallback: submittedChanges,
533
- ...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,
534
523
  });
535
- // Savepoint-scoped — see the create() append() above for why:
536
- // confines a losing writer's unique-violation to a nested scope
537
- // instead of poisoning the whole outer transaction.
538
- const event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
539
- aggregateId: String(payload.id),
540
- aggregateType: entityName,
541
- tenantId: streamTenant,
542
- expectedVersion: currentVersion,
543
- type: entityEventName(entityName, "updated"),
544
- eventVersion,
545
- payload: {
546
- changes: flatChanges,
547
- previous: encryptedPrevious,
548
- },
549
- metadata: buildEventMetadata(user),
550
- }));
551
- // Live and rebuild use applyEntityEvent with the same StoredEvent —
552
- // apply reads only `changes`, which contain identical ciphertext in live writes
553
- // and replays.
554
- //
555
- // An update that violates a unique index (for example, an email update to an
556
- // already-existing value) is reported as 409 unique_violation instead of 500
557
- // internal_error.
558
- // Savepoint like the create path: a raw DB error from the projection UPDATE would
559
- // otherwise poison the enclosing transaction, so a sibling write after a failed
560
- // nested one would throw 500 instead of failing cleanly.
561
- let result;
562
- try {
563
- result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
564
- }
565
- catch (e) {
566
- const mapped = tryMapUniqueViolation(e, entityName);
567
- if (mapped)
568
- return mapped;
569
- throw e;
570
- }
571
- if (result.kind !== "applied" || result.row === null) {
572
- return writeFailure(new InternalError({ message: "projection update returned no row" }));
573
- }
574
- const row = result.row;
575
- const data = await decryptForRead(rehydrateCompoundTypes(row, entity));
576
- if (entityCache && entityName) {
577
- await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
578
- }
579
- const echoEvent = {
580
- ...event,
581
- payload: {
582
- changes: stripSensitive(flatChangesPlain),
583
- previous: stripSensitive(previous),
584
- },
585
- };
586
- await projectEntityEvent(db, runner, echoEvent);
524
+ if ("failure" in persisted)
525
+ return persisted.failure;
526
+ const { data, echoEvent } = persisted;
587
527
  return {
588
528
  isSuccess: true,
589
529
  data: withoutEventInJson({
@@ -603,17 +543,11 @@ export function createWriteVerbs(ctx) {
603
543
  // branch catches the narrow race where two writers both read version=N
604
544
  // and both pass the local check — the unique index on (aggregate_id,
605
545
  // version) serializes them, one wins, the other lands here.
606
- if (e instanceof EventStoreVersionConflict) {
607
- return writeFailure(new FrameworkVersionConflict({
608
- entityId: payload.id,
609
- expectedVersion: payload.version ?? 0,
610
- currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenant),
611
- }));
612
- }
613
- if (e instanceof EventStoreIdempotentAppendConflict) {
614
- return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
615
- }
616
- throw e;
546
+ return await appendConflictFailure(e, runner, {
547
+ entityId: payload.id,
548
+ expectedVersion: currentVersion,
549
+ streamTenant,
550
+ });
617
551
  }
618
552
  },
619
553
  async delete(payload, user, db, options) {
@@ -633,16 +567,13 @@ export function createWriteVerbs(ctx) {
633
567
  // twice to userCanWriteFieldRow makes the Straddle check trivial
634
568
  // (same row on both sides) while keeping the multi-role-atomic shape.
635
569
  if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
636
- return writeFailure(new UnprocessableError("ownership_denied", {
637
- i18nKey: "errors.ownershipDenied",
638
- details: {
639
- scope: "entity",
640
- entityName,
641
- action: "delete",
642
- userId: user.id,
643
- entityId: payload.id,
644
- },
645
- }));
570
+ return ownershipDeniedFailure({
571
+ scope: "entity",
572
+ entityName,
573
+ action: "delete",
574
+ userId: user.id,
575
+ entityId: payload.id,
576
+ });
646
577
  }
647
578
  const lockedDelete = recordLockedFailure(existing, payload.id, user, options);
648
579
  if (lockedDelete)
@@ -675,26 +606,18 @@ export function createWriteVerbs(ctx) {
675
606
  }));
676
607
  }
677
608
  catch (e) {
678
- if (e instanceof EventStoreVersionConflict) {
679
- return writeFailure(new FrameworkVersionConflict({
680
- entityId: payload.id,
681
- expectedVersion: currentVersion,
682
- currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenantFor(user, streamTenantId)),
683
- }));
684
- }
685
- if (e instanceof EventStoreIdempotentAppendConflict) {
686
- return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
687
- }
688
- throw e;
609
+ return await appendConflictFailure(e, runner, {
610
+ entityId: payload.id,
611
+ expectedVersion: currentVersion,
612
+ streamTenant: streamTenantFor(user, streamTenantId),
613
+ });
689
614
  }
690
615
  // Live==Rebuild via applyEntityEvent. Savepoint like create/update.
691
616
  const deleteResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
692
617
  if (deleteResult.kind !== "applied") {
693
618
  return writeFailure(new InternalError({ message: "projection delete: applyEntityEvent skipped" }));
694
619
  }
695
- if (entityCache && entityName) {
696
- await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
697
- }
620
+ await evictCachedEntity(user, streamTenantId, payload.id);
698
621
  const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
699
622
  await projectEntityEvent(db, runner, echoEvent);
700
623
  return {
@@ -723,19 +646,16 @@ export function createWriteVerbs(ctx) {
723
646
  const streamTenantMismatch = streamTenantOverrideFailure(db, raw, streamTenantId);
724
647
  if (streamTenantMismatch)
725
648
  return streamTenantMismatch;
726
- const existing = await decryptForRead(rehydrateCompoundTypes(raw, entity));
649
+ const existing = await readProjectedRow(raw);
727
650
  if (!isFrameworkSystemUser(user) &&
728
651
  !userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
729
- return writeFailure(new UnprocessableError("ownership_denied", {
730
- i18nKey: "errors.ownershipDenied",
731
- details: {
732
- scope: "entity",
733
- entityName,
734
- action: "delete",
735
- userId: user.id,
736
- entityId: payload.id,
737
- },
738
- }));
652
+ return ownershipDeniedFailure({
653
+ scope: "entity",
654
+ entityName,
655
+ action: "delete",
656
+ userId: user.id,
657
+ entityId: payload.id,
658
+ });
739
659
  }
740
660
  await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
741
661
  const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
@@ -753,32 +673,24 @@ export function createWriteVerbs(ctx) {
753
673
  payload: {
754
674
  previous: await encryptForStorage(existing, user, {
755
675
  keyTenantId: streamTenantId,
756
- onErasedSubject: "sentinel",
676
+ onErasedSubject: ErasedSubjectHandlings.sentinel,
757
677
  }),
758
678
  },
759
679
  metadata: buildEventMetadata(user),
760
680
  }));
761
681
  }
762
682
  catch (e) {
763
- if (e instanceof EventStoreVersionConflict) {
764
- return writeFailure(new FrameworkVersionConflict({
765
- entityId: payload.id,
766
- expectedVersion: currentVersion,
767
- currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenantFor(user, streamTenantId)),
768
- }));
769
- }
770
- if (e instanceof EventStoreIdempotentAppendConflict) {
771
- return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
772
- }
773
- throw e;
683
+ return await appendConflictFailure(e, runner, {
684
+ entityId: payload.id,
685
+ expectedVersion: currentVersion,
686
+ streamTenant: streamTenantFor(user, streamTenantId),
687
+ });
774
688
  }
775
689
  const forgetResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
776
690
  if (forgetResult.kind !== "applied") {
777
691
  return writeFailure(new InternalError({ message: "projection forget: applyEntityEvent skipped" }));
778
692
  }
779
- if (entityCache && entityName) {
780
- await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
781
- }
693
+ await evictCachedEntity(user, streamTenantId, payload.id);
782
694
  const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
783
695
  await projectEntityEvent(db, runner, echoEvent);
784
696
  return {
@@ -809,16 +721,13 @@ export function createWriteVerbs(ctx) {
809
721
  // only the stored row matters. Stored row carries pre-soft-delete
810
722
  // teamId/... fields, so the ownership predicate still applies cleanly.
811
723
  if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, data, data)) {
812
- return writeFailure(new UnprocessableError("ownership_denied", {
813
- i18nKey: "errors.ownershipDenied",
814
- details: {
815
- scope: "entity",
816
- entityName,
817
- action: "restore",
818
- userId: user.id,
819
- entityId: payload.id,
820
- },
821
- }));
724
+ return ownershipDeniedFailure({
725
+ scope: "entity",
726
+ entityName,
727
+ action: "restore",
728
+ userId: user.id,
729
+ entityId: payload.id,
730
+ });
822
731
  }
823
732
  await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
824
733
  // Stream-version authoritative (see update() for rationale).
@@ -838,29 +747,16 @@ export function createWriteVerbs(ctx) {
838
747
  // plus the version bump, so there is no sensitive-field drift and no
839
748
  // payload override is needed.
840
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.
841
- let restoreResult;
842
- try {
843
- restoreResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
844
- }
845
- catch (e) {
846
- const mapped = tryMapUniqueViolation(e, entityName);
847
- if (mapped)
848
- return mapped;
849
- throw e;
850
- }
851
- if (restoreResult.kind !== "applied" || restoreResult.row === null) {
852
- return writeFailure(new InternalError({ message: "projection restore returned no row" }));
853
- }
854
- const restored = restoreResult.row;
855
- if (entityCache && entityName) {
856
- await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
857
- }
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);
858
754
  // Read-side auto-convert for compound types, same as update/list.
859
755
  // decryptForRead matches create/update/list/detail: the caller-facing
860
756
  // row and `previous` snapshot must be plaintext for `encrypted` fields,
861
757
  // same as every other executor method — `data`/`restored` are raw rows
862
758
  // (db.fetchOne / applyEntityEvent), never decrypted before this point.
863
- const restoredHydrated = await decryptForRead(rehydrateCompoundTypes(restored, entity));
759
+ const restoredHydrated = await readProjectedRow(projected.row);
864
760
  const echoEvent = { ...event, payload: { previous: stripSensitive(previousPlain) } };
865
761
  await projectEntityEvent(db, runner, echoEvent);
866
762
  return {