@cosmicdrift/kumiko-framework 0.387.0 → 0.389.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 +9 -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/golden-path.md +13 -1
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/lifecycle-hooks.md +55 -2
- package/agents/examples/list-table-options.md +466 -0
- package/agents/examples/pdf-audit-export.md +4 -2
- 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/state-machine.md +3 -2
- package/agents/examples/tenant-isolation.md +73 -1
- package/agents/examples/wizard-form.md +941 -0
- package/agents/examples/writeform-section.md +548 -0
- package/agents/examples.md +1 -0
- package/agents/features.md +19 -2
- package/agents/widgets.md +2 -0
- package/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +2 -0
- package/dist/api/auth-routes-shared.d.ts +2 -0
- package/dist/api/auth-routes-shared.js +5 -0
- package/dist/api/client-error-route.js +10 -5
- package/dist/api/entity-read-access.d.ts +2 -1
- package/dist/api/entity-read-access.js +14 -0
- package/dist/api/export/export-cells-reference.d.ts +9 -0
- package/dist/api/export/export-cells-reference.js +21 -0
- package/dist/api/export/export-cells.d.ts +11 -0
- package/dist/api/export/export-cells.js +136 -0
- package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
- package/dist/api/export/export-ciphertext-guard.js +27 -0
- package/dist/api/export/export-columns.d.ts +29 -0
- package/dist/api/export/export-columns.js +85 -0
- package/dist/api/export/export-errors.d.ts +4 -0
- package/dist/api/export/export-errors.js +14 -0
- package/dist/api/export/export-route.d.ts +4 -0
- package/dist/api/export/export-route.js +74 -0
- package/dist/api/export/export-rows.d.ts +13 -0
- package/dist/api/export/export-rows.js +62 -0
- package/dist/api/export/export-screen.d.ts +12 -0
- package/dist/api/export/export-screen.js +47 -0
- package/dist/api/export/export-xlsx-writer.d.ts +4 -0
- package/dist/api/export/export-xlsx-writer.js +21 -0
- package/dist/api/extra-route.d.ts +4 -0
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +3 -1
- package/dist/api/pii-leak-guard.d.ts +3 -0
- package/dist/api/pii-leak-guard.js +3 -2
- package/dist/api/request-context.d.ts +1 -1
- package/dist/api/request-context.js +1 -1
- package/dist/api/routes.d.ts +16 -0
- 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 +206 -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 +299 -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 +238 -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.d.ts +2 -1
- package/dist/api/sse-route.js +27 -13
- package/dist/config/index.d.ts +3 -1
- 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.d.ts +1 -0
- package/dist/crypto/pg-kms-adapter.js +12 -18
- 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 +23 -0
- package/dist/db/aggregate-sql.js +165 -0
- package/dist/db/closed-connection.d.ts +9 -0
- package/dist/db/closed-connection.js +88 -0
- package/dist/db/connection.d.ts +4 -5
- package/dist/db/connection.js +29 -20
- 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 +101 -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-currencies.d.ts +17 -0
- package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
- package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
- package/dist/db/event-store-executor-read-aggregate.js +143 -0
- package/dist/db/event-store-executor-read-filter.d.ts +31 -0
- package/dist/db/event-store-executor-read-filter.js +206 -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-atomic.d.ts +4 -0
- package/dist/db/event-store-executor-write-atomic.js +41 -0
- package/dist/db/event-store-executor-write-steps.d.ts +17 -0
- package/dist/db/event-store-executor-write-steps.js +95 -0
- package/dist/db/event-store-executor-write.js +405 -509
- package/dist/db/event-store-executor.js +2 -1
- package/dist/db/executor-managed-tables.js +1 -1
- package/dist/db/index.d.ts +7 -3
- package/dist/db/index.js +6 -2
- 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 +7 -4
- package/dist/db/pg-error.js +26 -21
- package/dist/db/queries/backfill-pii.js +3 -6
- package/dist/db/queries/database-now.js +2 -1
- package/dist/db/queries/shadow-swap.js +2 -3
- package/dist/db/query.d.ts +16 -15
- package/dist/db/query.js +76 -804
- package/dist/db/reference-data.js +19 -2
- package/dist/db/statement-timeout.js +2 -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-upsert.d.ts +5 -0
- package/dist/db/tenant-db-upsert.js +91 -0
- package/dist/db/tenant-db.js +50 -10
- 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/bundled-lists-table.d.ts +2 -0
- package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
- 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/entity-list-aggregate-options.d.ts +3 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -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/list-bulk-actions.d.ts +3 -0
- package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
- package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
- package/dist/engine/boot-validator/list-column-layout.js +41 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
- package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-export-options.js +27 -0
- package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
- package/dist/engine/boot-validator/list-table-options.js +77 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
- package/dist/engine/boot-validator/pii-retention.js +233 -155
- package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
- package/dist/engine/boot-validator/projection-list-screens.js +3 -13
- package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
- package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
- 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 +235 -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 +56 -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 +24 -11
- 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.d.ts +2 -1
- package/dist/engine/build-app-schema.js +65 -97
- package/dist/engine/build-config-feature-schema.js +6 -1
- package/dist/engine/define-feature.js +13 -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/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/round1.js +6 -0
- package/dist/engine/feature-ast/extractors/round2.js +25 -6
- package/dist/engine/feature-ast/index.d.ts +1 -1
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-ast/patch.d.ts +2 -0
- package/dist/engine/feature-ast/patch.js +5 -182
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -245
- package/dist/engine/feature-ast/pattern-change-schema.js +10 -163
- package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
- package/dist/engine/feature-ast/pattern-id-matchers.js +211 -0
- package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
- package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
- package/dist/engine/feature-ast/patterns.d.ts +14 -1
- package/dist/engine/feature-ast/patterns.js +44 -51
- package/dist/engine/feature-ast/render.js +52 -87
- package/dist/engine/feature-builder-state.d.ts +1 -0
- package/dist/engine/feature-builder-state.js +1 -0
- package/dist/engine/feature-entity-handlers.js +3 -0
- package/dist/engine/feature-manifest.d.ts +1 -0
- package/dist/engine/feature-manifest.js +1 -0
- package/dist/engine/field-access.d.ts +1 -0
- package/dist/engine/field-access.js +12 -0
- package/dist/engine/form-derivations.js +18 -7
- package/dist/engine/handler-field-references.js +1 -1
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/pattern-library/library.js +2 -1
- package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
- package/dist/engine/pattern-library/static-schemas.js +9 -0
- 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-filter-ops.js +2 -0
- 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 +58 -17
- package/dist/engine/types/feature.d.ts +1 -0
- package/dist/engine/types/index.d.ts +4 -3
- package/dist/engine/types/screen.d.ts +1 -1
- package/dist/engine/types/screen.js +1 -1
- package/dist/engine/validate-projection-allowlist.js +2 -1
- package/dist/entrypoint/index.d.ts +6 -1
- package/dist/entrypoint/index.js +10 -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.d.ts +1 -0
- package/dist/event-store/event-store.js +17 -8
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +7 -1
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +11 -2
- package/dist/files/file-handle.js +2 -3
- package/dist/files/file-routes.d.ts +2 -0
- package/dist/files/file-routes.js +40 -10
- package/dist/files/provider-resolver.d.ts +1 -0
- package/dist/files/provider-resolver.js +1 -1
- package/dist/files/record-write-check.d.ts +6 -0
- package/dist/files/record-write-check.js +13 -0
- package/dist/files/resolve-content-type.js +43 -0
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/files/upload-ledger.js +11 -4
- package/dist/i18n/required-surface-keys.js +189 -208
- package/dist/internal/stack-support.d.ts +2 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/internal/testing.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 +50 -657
- package/dist/observability/escape-hatch-report.d.ts +3 -0
- package/dist/observability/escape-hatch-report.js +21 -3
- package/dist/pipeline/after-rollback-sink.d.ts +6 -0
- package/dist/pipeline/after-rollback-sink.js +32 -0
- package/dist/pipeline/cascade-handler.js +165 -138
- package/dist/pipeline/dispatch-batch.js +8 -1
- package/dist/pipeline/dispatch-query.js +10 -3
- package/dist/pipeline/dispatch-shared.d.ts +13 -3
- package/dist/pipeline/dispatch-shared.js +62 -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 +6 -6
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +6 -9
- 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 +23 -0
- package/dist/pipeline/handler-context-dispatch.js +101 -0
- package/dist/pipeline/handler-context-events.d.ts +41 -0
- package/dist/pipeline/handler-context-events.js +239 -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/pipeline/system-hooks.d.ts +3 -1
- package/dist/pipeline/system-hooks.js +55 -6
- package/dist/pipeline/write-result-masking.js +13 -10
- 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/search/reindex-entity.js +15 -5
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-changelog.d.ts +2 -0
- package/dist/upgrade-changelog.js +14 -0
- package/dist/upgrade-cli.d.ts +3 -12
- package/dist/upgrade-cli.js +171 -197
- package/dist/upgrade-marker-parse.d.ts +32 -0
- package/dist/upgrade-marker-parse.js +73 -0
- package/package.json +9 -6
- package/src/changes.json +555 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/db-factory.ts +1 -1
- 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
- package/dist/engine/provider-plugin-context.d.ts +0 -3
- package/dist/engine/provider-plugin-context.js +0 -28
package/dist/db/tenant-db.js
CHANGED
|
@@ -10,7 +10,8 @@ import { bindProjectionRegistry, projectionRegistryOf } from "./projection-regis
|
|
|
10
10
|
import { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
11
11
|
import { queryDatabaseNow } from "./queries/database-now.js";
|
|
12
12
|
import { hasFieldProperty, tableNameOf } from "./table-introspection.js";
|
|
13
|
-
import { bindTenantDbRunner, denyTenantDbWrites, tenantDbRunner, tenantDbWritesDeniedError, } from "./tenant-db-runner.js";
|
|
13
|
+
import { bindTenantDbRunner, denyTenantDbWrites, tenantDbRunner, tenantDbWritesDeniedError, tenantDbWritesDeniedReason, } from "./tenant-db-runner.js";
|
|
14
|
+
import { upsertTableDenied, upsertTenantRow } from "./tenant-db-upsert.js";
|
|
14
15
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
15
16
|
const declaredUnsafeRawRunners = new WeakMap();
|
|
16
17
|
// The CRUD executor writes through tenantDbRunner, not insertOne, so it asks the
|
|
@@ -36,7 +37,7 @@ function boundRunner(runner, gate, projectionRegistry, executorManagedWrites) {
|
|
|
36
37
|
if (typeof value !== "function")
|
|
37
38
|
return value;
|
|
38
39
|
if (prop === "reserve") {
|
|
39
|
-
//
|
|
40
|
+
// The driver hands back a raw connection; re-wrap it so the gate survives.
|
|
40
41
|
return async (...args) => {
|
|
41
42
|
const reserved = await Reflect.apply(value, target, args);
|
|
42
43
|
return boundRunner(reserved, gate, projectionRegistry, executorManagedWrites);
|
|
@@ -105,9 +106,7 @@ export function assertPersonalDataWrite(db, tableName, keys, entity) {
|
|
|
105
106
|
}
|
|
106
107
|
// Framework-private (not re-exported from db/index.ts): same grant check + audit as unsafeRaw, for engine forwarding.
|
|
107
108
|
export function unsafeRawForDeclaredStep(holder, reason) {
|
|
108
|
-
|
|
109
|
-
throw new Error("unsafeRawForDeclaredStep requires a non-empty reason");
|
|
110
|
-
}
|
|
109
|
+
assertReason("unsafeRawForDeclaredStep", reason);
|
|
111
110
|
const runner = declaredUnsafeRawRunners.get(holder);
|
|
112
111
|
if (!runner) {
|
|
113
112
|
throw new InternalError({
|
|
@@ -257,7 +256,13 @@ export function createSystemDbView(db, dbOutsideTransaction, report = fallbackEs
|
|
|
257
256
|
// same connection/transaction. For code that must run caller-supplied callbacks (e.g. config
|
|
258
257
|
// validators) without handing them a cross-tenant view.
|
|
259
258
|
export function scopeTenantDbToTenant(db, tenantId) {
|
|
260
|
-
|
|
259
|
+
const writesDeniedReason = tenantDbWritesDeniedReason(db);
|
|
260
|
+
return createTenantDb({
|
|
261
|
+
db: tenantDbRunner(db),
|
|
262
|
+
tenantId,
|
|
263
|
+
mode: "tenant",
|
|
264
|
+
...(writesDeniedReason !== undefined && { grants: { writesDeniedReason } }),
|
|
265
|
+
});
|
|
261
266
|
}
|
|
262
267
|
// @cast-boundary tenant-db-row
|
|
263
268
|
export function castTenantRows(rows) {
|
|
@@ -268,6 +273,18 @@ export { tableNameOf };
|
|
|
268
273
|
function hasGrant(decl) {
|
|
269
274
|
return decl !== undefined && decl.reason.trim().length > 0;
|
|
270
275
|
}
|
|
276
|
+
// Tenant ids left over when the caller excludes some via ne/notIn; undefined for other operator forms.
|
|
277
|
+
function tenantIdsAfterExclusion(requested, allowed) {
|
|
278
|
+
if (!isPlainObject(requested))
|
|
279
|
+
return undefined;
|
|
280
|
+
if ("ne" in requested)
|
|
281
|
+
return allowed.filter((t) => t !== requested["ne"]);
|
|
282
|
+
if (Array.isArray(requested["notIn"])) {
|
|
283
|
+
const excluded = requested["notIn"];
|
|
284
|
+
return allowed.filter((t) => !excluded.includes(t));
|
|
285
|
+
}
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
271
288
|
// Tenant ids a where.tenantId filter asks for; undefined for operator forms
|
|
272
289
|
// that cannot narrow to a tenant list (ne, like, ...).
|
|
273
290
|
export function requestedTenantIds(requested) {
|
|
@@ -429,7 +446,13 @@ function buildTenantDb(options) {
|
|
|
429
446
|
const allowed = [tenantId, SYSTEM_TENANT_ID];
|
|
430
447
|
const requested = where?.["tenantId"];
|
|
431
448
|
if (requested !== undefined) {
|
|
432
|
-
const requestedList = requestedTenantIds(requested)
|
|
449
|
+
const requestedList = requestedTenantIds(requested);
|
|
450
|
+
// An explicit exclusion or empty list must keep excluding, never widen to the full scope.
|
|
451
|
+
if (requestedList === undefined) {
|
|
452
|
+
return { ...where, tenantId: tenantIdsAfterExclusion(requested, allowed) ?? allowed };
|
|
453
|
+
}
|
|
454
|
+
if (requestedList.length === 0)
|
|
455
|
+
return { ...where, tenantId: [] };
|
|
433
456
|
const narrowed = requestedList.filter((t) => typeof t === "string" && allowed.includes(t));
|
|
434
457
|
return { ...where, tenantId: narrowed.length > 0 ? narrowed : allowed };
|
|
435
458
|
}
|
|
@@ -464,6 +487,14 @@ function buildTenantDb(options) {
|
|
|
464
487
|
function globalWriteReason() {
|
|
465
488
|
return grants?.globalWrites?.reason ?? "";
|
|
466
489
|
}
|
|
490
|
+
// The table is the audit target: process-level dedup would otherwise collapse a cron's
|
|
491
|
+
// writes to several global tables into one audit entry.
|
|
492
|
+
function reportGlobalWrite(table) {
|
|
493
|
+
report("global-write", globalWriteReason(), {
|
|
494
|
+
id: tableNameOf(table),
|
|
495
|
+
tenantId: tenantId ?? SYSTEM_TENANT_ID,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
467
498
|
function personalDataDenied(table, keys) {
|
|
468
499
|
if (!personalDataGate)
|
|
469
500
|
return undefined;
|
|
@@ -535,7 +566,7 @@ function buildTenantDb(options) {
|
|
|
535
566
|
personalDataDenied(table, Object.keys(values));
|
|
536
567
|
if (denied)
|
|
537
568
|
return Promise.reject(denied);
|
|
538
|
-
|
|
569
|
+
reportGlobalWrite(table);
|
|
539
570
|
return withDbSpan("insert", table, async () => insertRow(table, values));
|
|
540
571
|
},
|
|
541
572
|
updateMany(set, where) {
|
|
@@ -549,7 +580,7 @@ function buildTenantDb(options) {
|
|
|
549
580
|
if (!where || Object.keys(where).length === 0) {
|
|
550
581
|
return Promise.reject(new Error("db.global().updateMany without where would mass-update every tenant's rows. Pass at least one where condition."));
|
|
551
582
|
}
|
|
552
|
-
|
|
583
|
+
reportGlobalWrite(table);
|
|
553
584
|
return withDbSpan("update", table, async () => updateRows(table, set, where));
|
|
554
585
|
},
|
|
555
586
|
deleteMany(where) {
|
|
@@ -561,7 +592,7 @@ function buildTenantDb(options) {
|
|
|
561
592
|
if (!where || Object.keys(where).length === 0) {
|
|
562
593
|
return Promise.reject(new Error("db.global().deleteMany without where would mass-delete every tenant's rows. Pass at least one where condition."));
|
|
563
594
|
}
|
|
564
|
-
|
|
595
|
+
reportGlobalWrite(table);
|
|
565
596
|
return withDbSpan("delete", table, async () => {
|
|
566
597
|
await deleteRows(table, where);
|
|
567
598
|
});
|
|
@@ -632,6 +663,15 @@ function buildTenantDb(options) {
|
|
|
632
663
|
const data = insertValues(table, values);
|
|
633
664
|
return withDbSpan("insert", table, async () => insertRow(table, data));
|
|
634
665
|
},
|
|
666
|
+
upsert(table, values, options) {
|
|
667
|
+
const denied = writesDenied("ctx.db.upsert") ??
|
|
668
|
+
upsertTableDenied(table, mode) ??
|
|
669
|
+
personalDataDenied(table, [...Object.keys(values), ...Object.keys(options.update ?? {})]);
|
|
670
|
+
if (denied)
|
|
671
|
+
return Promise.reject(denied);
|
|
672
|
+
const data = insertValues(table, values);
|
|
673
|
+
return withDbSpan("insert", table, () => upsertTenantRow(db, table, data, options));
|
|
674
|
+
},
|
|
635
675
|
updateMany(table, set, where) {
|
|
636
676
|
const writeDenied = writesDenied("ctx.db.updateMany");
|
|
637
677
|
if (writeDenied)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { WhereObject } from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
2
|
+
import type { TableInfo } from "./table-info.ts";
|
|
3
|
+
export declare function buildWhereClause(info: TableInfo, where: WhereObject, startIndex: number, qualifier?: string): {
|
|
4
|
+
sqlText: string;
|
|
5
|
+
values: unknown[];
|
|
6
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index.js";
|
|
3
|
+
import { quoteIdent } from "../crypto/ciphertext-pattern.js";
|
|
4
|
+
import { prepareJsonbValue, prepareValue } from "./driver-value-preparation.js";
|
|
5
|
+
function isJsonTextMatchList(value) {
|
|
6
|
+
return Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
function isWhereOperator(v) {
|
|
9
|
+
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
10
|
+
return false;
|
|
11
|
+
// Don't false-positive on Date / Temporal / other domain-objects.
|
|
12
|
+
// WhereOperator is plain object literal with at most these keys.
|
|
13
|
+
const keys = Object.keys(v);
|
|
14
|
+
if (keys.length === 0)
|
|
15
|
+
return false;
|
|
16
|
+
return keys.every((key) => Object.hasOwn(OPERATOR_EMITTERS, key));
|
|
17
|
+
}
|
|
18
|
+
function isJsonbScalar(value) {
|
|
19
|
+
return value !== null && typeof value !== "object";
|
|
20
|
+
}
|
|
21
|
+
function valueList(target, values) {
|
|
22
|
+
return values
|
|
23
|
+
.map((v) => {
|
|
24
|
+
const p = prepareValue(v, target.pgType);
|
|
25
|
+
return p.kind === "literal" ? p.literal : `${target.bind(p.bound)}${p.sql}`;
|
|
26
|
+
})
|
|
27
|
+
.join(", ");
|
|
28
|
+
}
|
|
29
|
+
// multiSelect (and any other jsonb-array-of-scalars column) stores an
|
|
30
|
+
// array — a filter value is one option, not the whole array, so eq/ne/in
|
|
31
|
+
// must check array containment (`@>`) instead of scalar `=`/`<>`/`IN`
|
|
32
|
+
// against the jsonb column, which Postgres rejects outright.
|
|
33
|
+
// Mirrors applyMultiSelectFilter in event-store-executor-read-filter.ts — keep in lock-step.
|
|
34
|
+
function jsonbContainsAny(target, candidates) {
|
|
35
|
+
const { column, ref, bind } = target;
|
|
36
|
+
if (candidates.length === 0)
|
|
37
|
+
return "FALSE";
|
|
38
|
+
const parts = [];
|
|
39
|
+
for (const v of candidates) {
|
|
40
|
+
const p = prepareJsonbValue([v]);
|
|
41
|
+
// prepareJsonbValue never returns kind:"literal" (only prepareValue's
|
|
42
|
+
// isSqlExpression branch does) — narrow anyway, PreparedValue is a union.
|
|
43
|
+
if (p && p.kind === "param") {
|
|
44
|
+
parts.push(`${ref(column)} @> ${bind(p.bound)}${p.sql}`);
|
|
45
|
+
}
|
|
46
|
+
// A jsonb column holding the scalar itself is never `@>` an array of it.
|
|
47
|
+
if (isJsonbScalar(v)) {
|
|
48
|
+
parts.push(`${ref(column)} = ${bind(JSON.stringify(v))}::text::jsonb`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return parts.length > 0 ? `(${parts.join(" OR ")})` : "FALSE";
|
|
52
|
+
}
|
|
53
|
+
function isValidJsonTextMatch(match) {
|
|
54
|
+
if (match === null || typeof match !== "object")
|
|
55
|
+
return false;
|
|
56
|
+
const keys = propertyOf(match, "keys");
|
|
57
|
+
return (Array.isArray(keys) &&
|
|
58
|
+
keys.length > 0 &&
|
|
59
|
+
keys.every((k) => typeof k === "string") &&
|
|
60
|
+
typeof propertyOf(match, "eq") === "string");
|
|
61
|
+
}
|
|
62
|
+
function jsonTextMatchCondition(target, match) {
|
|
63
|
+
const { column, ref, bind } = target;
|
|
64
|
+
if (!isValidJsonTextMatch(match)) {
|
|
65
|
+
throw new Error(`where: jsonText on column "${column}" needs non-empty string keys and a string eq`);
|
|
66
|
+
}
|
|
67
|
+
const lookups = match.keys.map((key) => `${ref(column)}->>${bind(key)}::text`);
|
|
68
|
+
const lhs = lookups.length === 1 ? lookups[0] : `COALESCE(${lookups.join(", ")})`;
|
|
69
|
+
return `(${lhs} = ${bind(match.eq)})`;
|
|
70
|
+
}
|
|
71
|
+
function emitJsonText(target, operator) {
|
|
72
|
+
const { column, pgType } = target;
|
|
73
|
+
const operand = operator.jsonText;
|
|
74
|
+
if (operand === undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
if (Object.keys(operator).length > 1) {
|
|
77
|
+
throw new Error(`where: jsonText on column "${column}" cannot be combined with other operators`);
|
|
78
|
+
}
|
|
79
|
+
if (pgType !== "jsonb") {
|
|
80
|
+
throw new Error(`where: jsonText requires a jsonb column, "${column}" is ${pgType}`);
|
|
81
|
+
}
|
|
82
|
+
const matches = isJsonTextMatchList(operand) ? operand : [operand];
|
|
83
|
+
if (matches.length === 0)
|
|
84
|
+
return "FALSE";
|
|
85
|
+
return matches.map((match) => jsonTextMatchCondition(target, match)).join(" AND ");
|
|
86
|
+
}
|
|
87
|
+
function comparisonCondition(target, symbol, operand) {
|
|
88
|
+
const { column, pgType, ref, bind } = target;
|
|
89
|
+
const p = prepareValue(operand, pgType);
|
|
90
|
+
return p.kind === "literal"
|
|
91
|
+
? `${ref(column)} ${symbol} ${p.literal}`
|
|
92
|
+
: `${ref(column)} ${symbol} ${bind(p.bound)}${p.sql}`;
|
|
93
|
+
}
|
|
94
|
+
function comparisonEmitter(key, symbol) {
|
|
95
|
+
return (target, operator) => {
|
|
96
|
+
const operand = operator[key];
|
|
97
|
+
if (operand === undefined)
|
|
98
|
+
return undefined;
|
|
99
|
+
// Mirrors applyMultiSelectFilter: ordering/pattern ops have no meaning on a jsonb
|
|
100
|
+
// option array (`like` would crash as `jsonb ~~ jsonb`), so match nothing.
|
|
101
|
+
if (target.pgType === "jsonb")
|
|
102
|
+
return "FALSE";
|
|
103
|
+
return comparisonCondition(target, symbol, operand);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Mirrors applyMultiSelectFilter: an array `ne` means "does not contain all of these".
|
|
107
|
+
function jsonbNotEqualCondition(target, operand) {
|
|
108
|
+
const { column, ref, bind } = target;
|
|
109
|
+
if (Array.isArray(operand)) {
|
|
110
|
+
if (operand.length === 0)
|
|
111
|
+
return "FALSE";
|
|
112
|
+
const p = prepareJsonbValue(operand);
|
|
113
|
+
if (p && p.kind === "param") {
|
|
114
|
+
return `(${ref(column)} IS NULL OR NOT (${ref(column)} @> ${bind(p.bound)}${p.sql}))`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (isJsonbScalar(operand)) {
|
|
118
|
+
const p = prepareJsonbValue([operand]);
|
|
119
|
+
if (p && p.kind === "param") {
|
|
120
|
+
return `(${ref(column)} IS NULL OR NOT (${ref(column)} @> ${bind(p.bound)}${p.sql} OR ${ref(column)} = ${bind(JSON.stringify(operand))}::text::jsonb))`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
function emitNotEqual(target, operator) {
|
|
126
|
+
const operand = operator.ne;
|
|
127
|
+
if (operand === undefined)
|
|
128
|
+
return undefined;
|
|
129
|
+
if (operand === null && Object.keys(operator).length === 1) {
|
|
130
|
+
return `${target.ref(target.column)} IS NOT NULL`;
|
|
131
|
+
}
|
|
132
|
+
if (target.pgType === "jsonb") {
|
|
133
|
+
const jsonbCondition = jsonbNotEqualCondition(target, operand);
|
|
134
|
+
if (jsonbCondition !== undefined)
|
|
135
|
+
return jsonbCondition;
|
|
136
|
+
}
|
|
137
|
+
return comparisonCondition(target, "<>", operand);
|
|
138
|
+
}
|
|
139
|
+
function emitIn(target, operator) {
|
|
140
|
+
const operand = operator.in;
|
|
141
|
+
if (!Array.isArray(operand))
|
|
142
|
+
return undefined;
|
|
143
|
+
if (target.pgType === "jsonb")
|
|
144
|
+
return jsonbContainsAny(target, operand);
|
|
145
|
+
if (operand.length === 0)
|
|
146
|
+
return "FALSE";
|
|
147
|
+
return `${target.ref(target.column)} IN (${valueList(target, operand)})`;
|
|
148
|
+
}
|
|
149
|
+
function emitNotIn(target, operator) {
|
|
150
|
+
const operand = operator.notIn;
|
|
151
|
+
if (!Array.isArray(operand))
|
|
152
|
+
return undefined;
|
|
153
|
+
if (target.pgType === "jsonb") {
|
|
154
|
+
throw new Error(`where: notIn is not supported on jsonb column "${target.column}"`);
|
|
155
|
+
}
|
|
156
|
+
if (operand.length === 0)
|
|
157
|
+
return "TRUE";
|
|
158
|
+
return `${target.ref(target.column)} NOT IN (${valueList(target, operand)})`;
|
|
159
|
+
}
|
|
160
|
+
// Insertion order is the SQL emission order. jsonText comes first so its "cannot be combined" error wins over any other
|
|
161
|
+
// operator's error; the rest keep the historical order gt, gte, lt, lte, ne, like, in, notIn.
|
|
162
|
+
const OPERATOR_EMITTERS = {
|
|
163
|
+
jsonText: emitJsonText,
|
|
164
|
+
gt: comparisonEmitter("gt", ">"),
|
|
165
|
+
gte: comparisonEmitter("gte", ">="),
|
|
166
|
+
lt: comparisonEmitter("lt", "<"),
|
|
167
|
+
lte: comparisonEmitter("lte", "<="),
|
|
168
|
+
ne: emitNotEqual,
|
|
169
|
+
like: comparisonEmitter("like", "LIKE"),
|
|
170
|
+
in: emitIn,
|
|
171
|
+
notIn: emitNotIn,
|
|
172
|
+
};
|
|
173
|
+
function operatorConditions(target, operator) {
|
|
174
|
+
const conditions = [];
|
|
175
|
+
for (const emit of Object.values(OPERATOR_EMITTERS)) {
|
|
176
|
+
const condition = emit(target, operator);
|
|
177
|
+
if (condition !== undefined)
|
|
178
|
+
conditions.push(condition);
|
|
179
|
+
}
|
|
180
|
+
return conditions;
|
|
181
|
+
}
|
|
182
|
+
// Blind-index OR rewrite: if the column has a bidx
|
|
183
|
+
// counterpart (suffix convention `<col>_bidx`, framework-reserved)
|
|
184
|
+
// and a key is configured, equality matches both arms —
|
|
185
|
+
// plaintext legacy rows via the plaintext arm, encrypted rows
|
|
186
|
+
// via the HMAC. The `kumiko-pii:` prefix on ciphertexts makes
|
|
187
|
+
// a random match on the plaintext arm practically impossible.
|
|
188
|
+
function equalityCondition(target, value) {
|
|
189
|
+
const { info, column, pgType, ref, bind } = target;
|
|
190
|
+
const p = prepareValue(value, pgType);
|
|
191
|
+
if (p.kind === "literal")
|
|
192
|
+
return `${ref(column)} = ${p.literal}`;
|
|
193
|
+
const bidxKey = configuredBlindIndexKey();
|
|
194
|
+
const bidxCol = `${column}_bidx`;
|
|
195
|
+
if (bidxKey !== undefined &&
|
|
196
|
+
typeof value === "string" &&
|
|
197
|
+
!column.endsWith("_bidx") &&
|
|
198
|
+
info.hasColumn(bidxCol)) {
|
|
199
|
+
return `(${ref(column)} = ${bind(p.bound)} OR ${ref(bidxCol)} = ${bind(computeBlindIndex(bidxKey, value))})`;
|
|
200
|
+
}
|
|
201
|
+
return `${ref(column)} = ${bind(p.bound)}${p.sql}`;
|
|
202
|
+
}
|
|
203
|
+
function arrayCondition(target, values) {
|
|
204
|
+
if (values.length === 0)
|
|
205
|
+
return "FALSE";
|
|
206
|
+
if (target.pgType === "jsonb")
|
|
207
|
+
return jsonbContainsAny(target, values);
|
|
208
|
+
return `${target.ref(target.column)} IN (${valueList(target, values)})`;
|
|
209
|
+
}
|
|
210
|
+
function conditionsForValue(target, value) {
|
|
211
|
+
if (value === null)
|
|
212
|
+
return [`${target.ref(target.column)} IS NULL`];
|
|
213
|
+
if (Array.isArray(value))
|
|
214
|
+
return [arrayCondition(target, value)];
|
|
215
|
+
if (isWhereOperator(value))
|
|
216
|
+
return operatorConditions(target, value);
|
|
217
|
+
if (target.pgType === "jsonb" && isJsonbScalar(value)) {
|
|
218
|
+
return [jsonbContainsAny(target, [value])];
|
|
219
|
+
}
|
|
220
|
+
return [equalityCondition(target, value)];
|
|
221
|
+
}
|
|
222
|
+
export function buildWhereClause(info, where, startIndex, qualifier) {
|
|
223
|
+
const values = [];
|
|
224
|
+
// Single place that renders a column reference, so a join qualifier reaches
|
|
225
|
+
// every arm (blind index, jsonb containment, IS NULL, jsonText) alike.
|
|
226
|
+
const ref = (column) => qualifier === undefined ? quoteIdent(column) : `${quoteIdent(qualifier)}.${quoteIdent(column)}`;
|
|
227
|
+
const bind = (value) => {
|
|
228
|
+
values.push(value);
|
|
229
|
+
return `$${startIndex + values.length - 1}`;
|
|
230
|
+
};
|
|
231
|
+
const conditions = Object.entries(where).flatMap(([field, value]) => {
|
|
232
|
+
const column = info.columnOf(field);
|
|
233
|
+
return conditionsForValue({ info, column, pgType: info.pgTypeOf(column), ref, bind }, value);
|
|
234
|
+
});
|
|
235
|
+
return { sqlText: conditions.join(" AND "), values };
|
|
236
|
+
}
|
|
@@ -56,6 +56,7 @@ export async function deleteStoredFileAndDerivatives(originalKey, store, errorCo
|
|
|
56
56
|
...(await listDerivativeKeys(originalKey, store, errorContext)),
|
|
57
57
|
];
|
|
58
58
|
const failedKeys = [];
|
|
59
|
+
const log = createFallbackLogger(errorContext);
|
|
59
60
|
for (const key of keysToDelete) {
|
|
60
61
|
try {
|
|
61
62
|
assertSafeStorageKey(key);
|
|
@@ -63,7 +64,7 @@ export async function deleteStoredFileAndDerivatives(originalKey, store, errorCo
|
|
|
63
64
|
}
|
|
64
65
|
catch (err) {
|
|
65
66
|
failedKeys.push(key);
|
|
66
|
-
|
|
67
|
+
log.warn("storage delete failed", {
|
|
67
68
|
key,
|
|
68
69
|
error: err instanceof Error ? err.message : String(err),
|
|
69
70
|
});
|
|
@@ -13,7 +13,7 @@ export function validateFeatureBootChecks(features) {
|
|
|
13
13
|
}
|
|
14
14
|
catch (err) {
|
|
15
15
|
const message = err instanceof Error ? err.message : String(err);
|
|
16
|
-
throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}
|
|
16
|
+
throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}`);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { LIST_TABLE_OPTION_KEYS } from "../types/screen.js";
|
|
3
|
+
import { validateListTableOptions } from "./list-table-options.js";
|
|
4
|
+
export const BUNDLED_LISTS_OPTION_KEYS = ["table", "pinFirstColumn"];
|
|
5
|
+
// grouping and totals name fields of one list, so they cannot be shared across lists
|
|
6
|
+
const SCREEN_ONLY_TABLE_KEYS = ["grouping", "totals"];
|
|
7
|
+
export function validateBundledListsOptions(options) {
|
|
8
|
+
if (!isPlainObject(options)) {
|
|
9
|
+
throw new Error("bundledLists must be an object like { table: { columnPicker: true } }.");
|
|
10
|
+
}
|
|
11
|
+
const unknownKeys = Object.keys(options).filter((key) => !BUNDLED_LISTS_OPTION_KEYS.some((allowed) => allowed === key));
|
|
12
|
+
if (unknownKeys.length > 0) {
|
|
13
|
+
throw new Error(`bundledLists has unknown key(s) ${unknownKeys.map((k) => `"${k}"`).join(", ")}. ` +
|
|
14
|
+
`Allowed keys: ${BUNDLED_LISTS_OPTION_KEYS.join(", ")}.`);
|
|
15
|
+
}
|
|
16
|
+
const table = options["table"];
|
|
17
|
+
if (!isPlainObject(table)) {
|
|
18
|
+
const allowed = LIST_TABLE_OPTION_KEYS.filter((key) => !SCREEN_ONLY_TABLE_KEYS.some((screenOnly) => screenOnly === key));
|
|
19
|
+
throw new Error(`bundledLists.table must be an object like { columnPicker: true }. ` +
|
|
20
|
+
`Allowed keys: ${allowed.join(", ")}.`);
|
|
21
|
+
}
|
|
22
|
+
for (const key of SCREEN_ONLY_TABLE_KEYS) {
|
|
23
|
+
if (table[key] !== undefined) {
|
|
24
|
+
throw new Error(`bundledLists.table.${key} is not allowed: ${key} depends on the fields of one list. ` +
|
|
25
|
+
`Set it in the table block of that screen.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
validateListTableOptions("bundledLists", table, [], true);
|
|
29
|
+
const pinFirstColumn = options["pinFirstColumn"];
|
|
30
|
+
if (pinFirstColumn !== undefined && typeof pinFirstColumn !== "boolean") {
|
|
31
|
+
throw new Error("bundledLists.pinFirstColumn must be a boolean.");
|
|
32
|
+
}
|
|
33
|
+
if (pinFirstColumn === true && table["pinning"] !== true) {
|
|
34
|
+
throw new Error("bundledLists.pinFirstColumn needs bundledLists.table.pinning: true.");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { qualifyEntityName } from "../qualified-name.js";
|
|
2
|
-
import { findEntityFeature } from "./screens.js";
|
|
2
|
+
import { findEntityFeature } from "./screens-kind-shared.js";
|
|
3
3
|
// Entity name → the one screen that declares detailFor: "<entity>". Built
|
|
4
4
|
// once, up front (before the per-feature validateScreens loop) so both this
|
|
5
|
-
// module's own uniqueness check AND rowAction entity-targets (in screens.ts)
|
|
5
|
+
// module's own uniqueness check AND rowAction entity-targets (in screens-actions.ts)
|
|
6
6
|
// resolve against the same map instead of re-walking every feature's
|
|
7
7
|
// screens twice.
|
|
8
8
|
export function collectDetailForScreens(features, featureMap) {
|
|
@@ -9,6 +9,7 @@ export declare function validateHandlerAccess(feature: FeatureDefinition): void;
|
|
|
9
9
|
export declare function validateAdditionalRateLimits(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
10
10
|
export declare function validatePerCommandTransaction(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
11
11
|
export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
12
|
+
export declare function validateRateLimitCost(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
12
13
|
export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
13
14
|
export declare function validateRateLimitBucketList(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
14
15
|
export declare function validateNoAllRoleInHandlerAccess(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>): void;
|
|
@@ -139,6 +139,7 @@ export function validateHandlerAccess(feature) {
|
|
|
139
139
|
for (const [name, handler] of Object.entries(feature.writeHandlers)) {
|
|
140
140
|
validateTenantlessAnonymous(feature.name, name, handler);
|
|
141
141
|
validateAdditionalRateLimits(feature.name, name, handler);
|
|
142
|
+
validateRateLimitCost(feature.name, name, handler);
|
|
142
143
|
validatePerCommandTransaction(feature.name, name, handler);
|
|
143
144
|
}
|
|
144
145
|
}
|
|
@@ -235,6 +236,22 @@ export function validateTenantlessAnonymous(featureName, handlerName, handler) {
|
|
|
235
236
|
`tenant/user bucket, so one caller could exhaust it for all. Use ${IP_SCOPED_RATE_LIMIT_PER.map((allowed) => `"${allowed}"`).join(" or ")}.`);
|
|
236
237
|
}
|
|
237
238
|
}
|
|
239
|
+
export function validateRateLimitCost(featureName, handlerName, handler) {
|
|
240
|
+
// skip: no payload-derived cost declared
|
|
241
|
+
if (!handler.rateLimitCost)
|
|
242
|
+
return;
|
|
243
|
+
if (handler.rateLimit === undefined || isRateLimitDisabled(handler.rateLimit)) {
|
|
244
|
+
throw new Error(`write handler "${featureName}:write:${handlerName}" declares rateLimitCost without an active rateLimit — ` +
|
|
245
|
+
`the cost is booked on the rateLimit buckets, so declare rateLimit or drop rateLimitCost.`);
|
|
246
|
+
}
|
|
247
|
+
const isAnonymous = "roles" in handler.access && handler.access.roles.includes("anonymous");
|
|
248
|
+
const hasIpBucket = declaredRateLimitOptions(handler.rateLimit).some(({ per }) => IP_SCOPED_RATE_LIMIT_PER.includes(per));
|
|
249
|
+
if (isAnonymous || hasIpBucket) {
|
|
250
|
+
throw new Error(`write handler "${featureName}:write:${handlerName}" declares rateLimitCost on an anonymous or ip-limited handler — ` +
|
|
251
|
+
`rateLimitCost books after the parse, so such a handler would lose its pre-parse protection against junk floods. ` +
|
|
252
|
+
`Bulk handlers are authenticated: use a user/tenant bucket and drop "anonymous", or drop rateLimitCost.`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
238
255
|
export function validateRateLimitDisabledReason(featureName, kind, handlerName, rateLimit) {
|
|
239
256
|
// skip: rate limit isn't disabled, so no reason is required
|
|
240
257
|
if (!isRateLimitDisabled(rateLimit))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isListFilterAllowed } from "../../db/entity-field-encryption.js";
|
|
2
|
+
import { isFieldFilterable } from "../screen-filter-ops.js";
|
|
3
|
+
import { entityAggregateFieldLookup, validateListTableAggregateFieldTypes, } from "./list-table-aggregate-options.js";
|
|
4
|
+
const RANGE_FACET_TYPES = ["number", "decimal", "money", "bigInt"];
|
|
5
|
+
function validateRangeFacets(where, facets, entity) {
|
|
6
|
+
for (const [field, config] of Object.entries(facets ?? {})) {
|
|
7
|
+
if (config === false || config.display !== "range")
|
|
8
|
+
continue;
|
|
9
|
+
const def = Object.hasOwn(entity.fields, field) ? entity.fields[field] : undefined;
|
|
10
|
+
if (def === undefined || !RANGE_FACET_TYPES.includes(def.type)) {
|
|
11
|
+
throw new Error(`${where} facets.${field}: display "range" needs a number, decimal, money or bigInt field.`);
|
|
12
|
+
}
|
|
13
|
+
if (!isFieldFilterable(def) || !isListFilterAllowed(entity, field, { op: "gte", value: 0 })) {
|
|
14
|
+
throw new Error(`${where} facets.${field}: display "range" needs filterable: true on a field that is not encrypted, personal or secret.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function validateEntityListAggregateOptions(where, screen, entity) {
|
|
19
|
+
if (screen.table !== undefined) {
|
|
20
|
+
validateListTableAggregateFieldTypes(where, screen.table, entityAggregateFieldLookup(entity), true);
|
|
21
|
+
}
|
|
22
|
+
validateRangeFacets(where, screen.facets, entity);
|
|
23
|
+
}
|
|
@@ -3,15 +3,46 @@ import { parseQn } from "../qualified-name.js";
|
|
|
3
3
|
import { formDerivationsOfSchema } from "../with-form-derivations.js";
|
|
4
4
|
const NUMERIC_FORM_FIELD_TYPES = ["money", "number", "decimal"];
|
|
5
5
|
const NUMERIC_SUB_FIELD_TYPES = ["money", "number", "decimal"];
|
|
6
|
+
function isMoneyType(type) {
|
|
7
|
+
return type === "money";
|
|
8
|
+
}
|
|
9
|
+
// Money operands are computed in minor units, number/decimal in major units: mixing them
|
|
10
|
+
// silently scales the result by 100 or 10000.
|
|
11
|
+
function validateUnitCompatibility({ prefix, screen, target }, derivation) {
|
|
12
|
+
const targetIsMoney = isMoneyType(screen.fields[target]?.type);
|
|
13
|
+
const moneyFlags = derivation.from.map((source) => isMoneyType(screen.fields[source]?.type));
|
|
14
|
+
const moneyCount = moneyFlags.filter(Boolean).length;
|
|
15
|
+
if (derivation.op === "sum" || derivation.op === "subtract") {
|
|
16
|
+
if (moneyCount !== 0 && moneyCount !== moneyFlags.length) {
|
|
17
|
+
throw new Error(`${prefix}.${target}: "${derivation.op}" sources must be all money or all number/decimal.`);
|
|
18
|
+
}
|
|
19
|
+
if (moneyCount > 0 !== targetIsMoney) {
|
|
20
|
+
throw new Error(`${prefix}.${target}: a "${derivation.op}" over ${moneyCount > 0 ? "money" : "number/decimal"} sources needs a ${moneyCount > 0 ? "money" : "number/decimal"} target.`);
|
|
21
|
+
}
|
|
22
|
+
// skip: sum/subtract fully validated above.
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (moneyCount > 1 || (derivation.op === "percent" && moneyFlags[1] === true)) {
|
|
26
|
+
throw new Error(`${prefix}.${target}: "${derivation.op}" takes at most one money source${derivation.op === "percent" ? " (the base, not the rate)" : ""}.`);
|
|
27
|
+
}
|
|
28
|
+
if ((moneyCount === 1) !== targetIsMoney) {
|
|
29
|
+
throw new Error(`${prefix}.${target}: the target must be money exactly when a "${derivation.op}" source is money.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
6
32
|
function validateFromList({ prefix, screen, target }, derivation) {
|
|
7
33
|
if (derivation.op === "percent" && derivation.from.length !== 2) {
|
|
8
34
|
throw new Error(`${prefix}.${target}: "percent" needs exactly two sources (base, rate).`);
|
|
9
35
|
}
|
|
10
36
|
for (const source of derivation.from) {
|
|
11
|
-
|
|
37
|
+
const sourceField = screen.fields[source];
|
|
38
|
+
if (sourceField === undefined) {
|
|
12
39
|
throw new Error(`${prefix}.${target}: unknown source field "${source}". Name a field of this screen.`);
|
|
13
40
|
}
|
|
41
|
+
if (!NUMERIC_FORM_FIELD_TYPES.includes(sourceField.type)) {
|
|
42
|
+
throw new Error(`${prefix}.${target}: source "${source}" must be money, number or decimal, not "${sourceField.type}".`);
|
|
43
|
+
}
|
|
14
44
|
}
|
|
45
|
+
validateUnitCompatibility({ prefix, screen, target }, derivation);
|
|
15
46
|
}
|
|
16
47
|
function validateListSum(check, derivation) {
|
|
17
48
|
const { prefix, screen, target } = check;
|
|
@@ -26,6 +57,9 @@ function validateListSum(check, derivation) {
|
|
|
26
57
|
if (cellType === undefined || !NUMERIC_SUB_FIELD_TYPES.includes(cellType)) {
|
|
27
58
|
throw new Error(`${prefix}.${target}: "${derivation.cell}" must be a money, number or decimal sub-field of "${derivation.list}".`);
|
|
28
59
|
}
|
|
60
|
+
if (isMoneyType(cellType) !== isMoneyType(screen.fields[target]?.type)) {
|
|
61
|
+
throw new Error(`${prefix}.${target}: a listSum of a ${isMoneyType(cellType) ? "money" : "number/decimal"} cell needs a ${isMoneyType(cellType) ? "money" : "number/decimal"} target.`);
|
|
62
|
+
}
|
|
29
63
|
const { whenEmpty } = derivation;
|
|
30
64
|
// skip: whenEmpty is optional
|
|
31
65
|
if (whenEmpty === undefined)
|
|
@@ -94,9 +94,11 @@ export function validateTranslationLocaleConsistency(features) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
export function validateRequiredLocales(features, requiredLocales) {
|
|
97
|
-
const gaps =
|
|
98
|
-
|
|
97
|
+
const gaps = requiredLocales === undefined || requiredLocales.length === 0
|
|
98
|
+
? []
|
|
99
|
+
: findRequiredLocaleGaps(features, requiredLocales);
|
|
100
|
+
if (requiredLocales !== undefined && gaps.length > 0) {
|
|
99
101
|
const lines = gaps.map((gap) => `[i18n] Feature "${gap.featureName}": translation key "${gap.key}" is missing required locale(s) ${gap.missingLocales.join(", ")}.`);
|
|
100
|
-
throw new Error(`${lines.join("\n")}\nThe app requires locales [${
|
|
102
|
+
throw new Error(`${lines.join("\n")}\nThe app requires locales [${requiredLocales.join(", ")}] for every translation key; load the matching locale package or add the missing translations.`);
|
|
101
103
|
}
|
|
102
104
|
}
|
|
@@ -2,6 +2,7 @@ import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-en
|
|
|
2
2
|
import { dedupeFeatures } from "../dedupe-features.js";
|
|
3
3
|
import { FILE_STORAGE_PROVIDER_ENV } from "../extension-names.js";
|
|
4
4
|
import { QnTypes, qualifyEntityName } from "../qualified-name.js";
|
|
5
|
+
import { validateProjectionAllowlist } from "../validate-projection-allowlist.js";
|
|
5
6
|
import { validateAccessDeclarations } from "./access-declarations.js";
|
|
6
7
|
import { warnOnUniqueAccessRoles } from "./access-roles.js";
|
|
7
8
|
import { validateActionWiring, validateFieldWiring } from "./action-wiring.js";
|
|
@@ -238,6 +239,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
238
239
|
validateGdprStoragePersistence(features);
|
|
239
240
|
validateGlobalTenancyEntities(features);
|
|
240
241
|
validateFeatureBootChecks(features);
|
|
242
|
+
validateProjectionAllowlist(features);
|
|
241
243
|
if (hasEncryptedFields) {
|
|
242
244
|
// Availability check, not env-presence: eagerly building the keyring
|
|
243
245
|
// catches malformed keys (wrong length, bad base64) at boot instead of
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type RowAction } from "../types/screen.ts";
|
|
2
|
+
/** `where` is the full message prefix, e.g. `[Feature x] Screen "y" (entityList)`. */
|
|
3
|
+
export declare function validateListBulkActions(where: string, bulkActions: unknown, rowActions: readonly RowAction[] | undefined): void;
|