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