@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.
- package/AGENTS.md +6 -1
- package/agents/examples/anonymous-access.md +1 -1
- package/agents/examples/apex-landing.md +204 -0
- package/agents/examples/api-worker-split.md +64 -0
- package/agents/examples/app-config.md +14 -0
- package/agents/examples/auth-mfa.md +115 -0
- package/agents/examples/env-composition.md +28 -1
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/prod-boot-options.md +16 -0
- package/agents/examples/rate-limiting.md +48 -0
- package/agents/examples/record-detail-layout.md +692 -0
- package/agents/examples/seed-migration.md +212 -0
- package/agents/examples/wizard-form.md +941 -0
- package/agents/examples/writeform-section.md +548 -0
- package/agents/features.md +19 -2
- package/agents/widgets.md +1 -0
- package/dist/api/client-error-route.js +8 -2
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +3 -1
- package/dist/api/request-context.d.ts +1 -1
- package/dist/api/request-context.js +1 -1
- package/dist/api/server-boot-checks.d.ts +10 -0
- package/dist/api/server-boot-checks.js +157 -0
- package/dist/api/server-event-dispatcher.d.ts +26 -0
- package/dist/api/server-event-dispatcher.js +208 -0
- package/dist/api/server-extra-routes.d.ts +11 -0
- package/dist/api/server-extra-routes.js +203 -0
- package/dist/api/server-middleware.d.ts +20 -0
- package/dist/api/server-middleware.js +211 -0
- package/dist/api/server-routes.d.ts +19 -0
- package/dist/api/server-routes.js +293 -0
- package/dist/api/server-websocket-routes.d.ts +4 -0
- package/dist/api/server-websocket-routes.js +108 -0
- package/dist/api/server-wiring.d.ts +32 -0
- package/dist/api/server-wiring.js +236 -0
- package/dist/api/server.d.ts +10 -15
- package/dist/api/server.js +48 -1251
- package/dist/api/session-cookies.js +4 -4
- package/dist/api/sse-route.js +16 -12
- package/dist/crypto/owner-transition-rekey.d.ts +5 -1
- package/dist/crypto/owner-transition-rekey.js +1 -1
- package/dist/crypto/pii-field-encryption.d.ts +5 -1
- package/dist/crypto/pii-field-encryption.js +3 -1
- package/dist/db/aggregate-sql.d.ts +21 -0
- package/dist/db/aggregate-sql.js +155 -0
- package/dist/db/closed-connection.d.ts +9 -0
- package/dist/db/closed-connection.js +95 -0
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +1 -1
- package/dist/db/driver-row-coercion.d.ts +2 -0
- package/dist/db/driver-row-coercion.js +186 -0
- package/dist/db/driver-value-preparation.d.ts +16 -0
- package/dist/db/driver-value-preparation.js +99 -0
- package/dist/db/entity-field-encryption.d.ts +1 -0
- package/dist/db/entity-field-encryption.js +1 -1
- package/dist/db/entity-table-meta.js +103 -110
- package/dist/db/event-store-executor-context.d.ts +2 -1
- package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
- package/dist/db/event-store-executor-read-aggregate.js +112 -0
- package/dist/db/event-store-executor-read-filter.d.ts +31 -0
- package/dist/db/event-store-executor-read-filter.js +203 -0
- package/dist/db/event-store-executor-read-paging.d.ts +14 -0
- package/dist/db/event-store-executor-read-paging.js +99 -0
- package/dist/db/event-store-executor-read-rows.d.ts +6 -0
- package/dist/db/event-store-executor-read-rows.js +32 -0
- package/dist/db/event-store-executor-read-search.d.ts +28 -0
- package/dist/db/event-store-executor-read-search.js +221 -0
- package/dist/db/event-store-executor-read-sort.d.ts +41 -0
- package/dist/db/event-store-executor-read-sort.js +130 -0
- package/dist/db/event-store-executor-read-where.d.ts +24 -0
- package/dist/db/event-store-executor-read-where.js +81 -0
- package/dist/db/event-store-executor-read.d.ts +2 -9
- package/dist/db/event-store-executor-read.js +48 -701
- package/dist/db/event-store-executor-write-steps.d.ts +20 -0
- package/dist/db/event-store-executor-write-steps.js +100 -0
- package/dist/db/event-store-executor-write.js +395 -499
- package/dist/db/index.d.ts +6 -2
- package/dist/db/index.js +5 -1
- package/dist/db/list-aggregate-labels.d.ts +14 -0
- package/dist/db/list-aggregate-labels.js +26 -0
- package/dist/db/migrate-generator.js +71 -86
- package/dist/db/migrate-runner.js +4 -1
- package/dist/db/query.d.ts +2 -15
- package/dist/db/query.js +8 -761
- package/dist/db/reference-data.js +6 -1
- package/dist/db/table-builder.js +113 -166
- package/dist/db/table-info.d.ts +10 -0
- package/dist/db/table-info.js +45 -0
- package/dist/db/temporal-value-guards.d.ts +6 -0
- package/dist/db/temporal-value-guards.js +15 -0
- package/dist/db/tenant-db-runner.d.ts +1 -0
- package/dist/db/tenant-db-runner.js +3 -0
- package/dist/db/tenant-db.js +39 -9
- package/dist/db/where-clause.d.ts +6 -0
- package/dist/db/where-clause.js +236 -0
- package/dist/derivatives/delete-stored-file.js +2 -1
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/detail-screens.js +2 -2
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +17 -0
- package/dist/engine/boot-validator/form-derivations.js +35 -1
- package/dist/engine/boot-validator/i18n-keys.js +5 -3
- package/dist/engine/boot-validator/index.js +2 -0
- package/dist/engine/boot-validator/pii-retention.js +233 -155
- package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
- package/dist/engine/boot-validator/screens-actions.js +8 -1
- package/dist/engine/boot-validator/screens-dashboard.js +2 -5
- package/dist/engine/boot-validator/screens-forms.js +2 -6
- package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
- package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
- package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
- package/dist/engine/boot-validator/screens-lists.js +12 -9
- package/dist/engine/boot-validator/screens.d.ts +0 -2
- package/dist/engine/boot-validator/screens.js +99 -860
- package/dist/engine/build-app-schema.js +56 -96
- package/dist/engine/define-feature.js +9 -2
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/entity-handlers.d.ts +13 -0
- package/dist/engine/entity-handlers.js +10 -21
- package/dist/engine/entity-list-runtime-options.d.ts +8 -0
- package/dist/engine/entity-list-runtime-options.js +18 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-ast/extractors/round2.js +25 -6
- package/dist/engine/feature-ast/patch.js +4 -182
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
- package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
- package/dist/engine/feature-ast/patterns.d.ts +9 -0
- package/dist/engine/feature-ast/patterns.js +43 -51
- package/dist/engine/feature-ast/render.js +48 -87
- package/dist/engine/feature-entity-handlers.js +3 -0
- package/dist/engine/form-derivations.js +18 -7
- package/dist/engine/handler-field-references.js +1 -1
- package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
- package/dist/engine/project-app-schema-for-roles.js +6 -4
- package/dist/engine/registry-ingest.js +1 -1
- package/dist/engine/schema-builder.js +228 -212
- package/dist/engine/screen-helpers.js +1 -1
- package/dist/engine/screen-kind.d.ts +7 -0
- package/dist/engine/screen-kind.js +1 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
- package/dist/engine/validate-projection-allowlist.js +2 -1
- package/dist/entrypoint/index.d.ts +4 -1
- package/dist/entrypoint/index.js +7 -1
- package/dist/errors/docs-url.d.ts +1 -1
- package/dist/errors/docs-url.js +22 -2
- package/dist/es-ops/context.js +1 -1
- package/dist/event-store/admin-api.js +1 -1
- package/dist/event-store/event-store.js +10 -4
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +4 -1
- package/dist/files/file-handle.js +2 -3
- package/dist/files/file-routes.js +11 -8
- package/dist/files/resolve-content-type.js +43 -0
- package/dist/files/upload-ledger.js +7 -0
- package/dist/i18n/required-surface-keys.js +182 -206
- package/dist/internal/stack-support.d.ts +2 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-context.d.ts +32 -0
- package/dist/jobs/job-runner-context.js +305 -0
- package/dist/jobs/job-runner-execution.d.ts +7 -0
- package/dist/jobs/job-runner-execution.js +160 -0
- package/dist/jobs/job-runner-gates.d.ts +19 -0
- package/dist/jobs/job-runner-gates.js +198 -0
- package/dist/jobs/job-runner-helpers.d.ts +17 -3
- package/dist/jobs/job-runner-helpers.js +61 -2
- package/dist/jobs/job-runner-scope.d.ts +44 -0
- package/dist/jobs/job-runner-scope.js +102 -0
- package/dist/jobs/job-runner-types.d.ts +12 -0
- package/dist/jobs/job-runner.d.ts +0 -1
- package/dist/jobs/job-runner.js +47 -657
- package/dist/observability/escape-hatch-report.d.ts +3 -0
- package/dist/observability/escape-hatch-report.js +12 -1
- package/dist/pipeline/cascade-handler.js +165 -138
- package/dist/pipeline/dispatch-query.js +10 -3
- package/dist/pipeline/dispatch-shared.d.ts +13 -3
- package/dist/pipeline/dispatch-shared.js +58 -710
- package/dist/pipeline/dispatch-write.js +296 -189
- package/dist/pipeline/event-dispatcher-admin.js +2 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -5
- package/dist/pipeline/handler-context-auth.d.ts +9 -0
- package/dist/pipeline/handler-context-auth.js +54 -0
- package/dist/pipeline/handler-context-config.d.ts +14 -0
- package/dist/pipeline/handler-context-config.js +113 -0
- package/dist/pipeline/handler-context-db.d.ts +24 -0
- package/dist/pipeline/handler-context-db.js +149 -0
- package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
- package/dist/pipeline/handler-context-dispatch.js +89 -0
- package/dist/pipeline/handler-context-events.d.ts +43 -0
- package/dist/pipeline/handler-context-events.js +237 -0
- package/dist/pipeline/handler-context-notify.d.ts +12 -0
- package/dist/pipeline/handler-context-notify.js +55 -0
- package/dist/pipeline/handler-context-observability.d.ts +7 -0
- package/dist/pipeline/handler-context-observability.js +21 -0
- package/dist/pipeline/handler-context-scope.d.ts +48 -0
- package/dist/pipeline/handler-context-scope.js +88 -0
- package/dist/pipeline/notify-scope.js +12 -1
- package/dist/pipeline/pre-handler-gates.js +20 -4
- package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
- package/dist/pipeline/pre-transaction-reservations.js +9 -3
- package/dist/pipeline/projection-rebuild.js +2 -1
- package/dist/schema-cli/apply-command.d.ts +2 -0
- package/dist/schema-cli/apply-command.js +97 -0
- package/dist/schema-cli/baseline-command.d.ts +2 -0
- package/dist/schema-cli/baseline-command.js +33 -0
- package/dist/schema-cli/generate-command.d.ts +2 -0
- package/dist/schema-cli/generate-command.js +90 -0
- package/dist/schema-cli/shared.d.ts +39 -0
- package/dist/schema-cli/shared.js +95 -0
- package/dist/schema-cli/status-command.d.ts +2 -0
- package/dist/schema-cli/status-command.js +73 -0
- package/dist/schema-cli/validate-boot-input.d.ts +5 -0
- package/dist/schema-cli/validate-boot-input.js +130 -0
- package/dist/schema-cli/validate-command.d.ts +2 -0
- package/dist/schema-cli/validate-command.js +130 -0
- package/dist/schema-cli.d.ts +2 -18
- package/dist/schema-cli.js +47 -604
- package/dist/search/meilisearch-adapter.js +2 -1
- package/dist/upgrade-cli.js +149 -121
- package/package.json +4 -4
- package/src/changes.json +310 -0
- package/src/scripts/codemod/feature-layout.ts +10 -2
- package/src/scripts/codemod/object-form.ts +41 -4
- package/src/scripts/codemod/package-merge.ts +58 -8
- package/src/scripts/codemod/pii-personal-migration.ts +175 -145
- package/src/scripts/codemod/testing-injection.ts +122 -42
- 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
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
//
|
|
190
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
|
649
|
+
const existing = await readProjectedRow(raw);
|
|
727
650
|
if (!isFrameworkSystemUser(user) &&
|
|
728
651
|
!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
|
|
729
|
-
return
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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:
|
|
676
|
+
onErasedSubject: ErasedSubjectHandlings.sentinel,
|
|
757
677
|
}),
|
|
758
678
|
},
|
|
759
679
|
metadata: buildEventMetadata(user),
|
|
760
680
|
}));
|
|
761
681
|
}
|
|
762
682
|
catch (e) {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
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
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
|
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 {
|