@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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/index.js";
|
|
3
|
+
import { shiftParams } from "../engine/ownership.js";
|
|
4
|
+
import { ValidationError } from "../errors/index.js";
|
|
5
|
+
import { isListFilterAllowed } from "./entity-field-encryption.js";
|
|
6
|
+
import { buildFilterWhere } from "./event-store-executor-context.js";
|
|
7
|
+
// Rebases a clause's local $1..$n onto `params` and returns the shifted text.
|
|
8
|
+
export function bindShiftedClause(params, clause) {
|
|
9
|
+
const shifted = shiftParams({ sqlText: clause.sqlText, params: clause.params }, params.length);
|
|
10
|
+
for (const p of shifted.params)
|
|
11
|
+
params.push(p);
|
|
12
|
+
return shifted.sqlText;
|
|
13
|
+
}
|
|
14
|
+
export function appendShiftedClause(where, clause) {
|
|
15
|
+
where.whereSql.push(bindShiftedClause(where.params, clause));
|
|
16
|
+
}
|
|
17
|
+
export function bindPlaceholders(params, values) {
|
|
18
|
+
return values
|
|
19
|
+
.map((value) => {
|
|
20
|
+
params.push(value);
|
|
21
|
+
return `$${params.length}`;
|
|
22
|
+
})
|
|
23
|
+
.join(", ");
|
|
24
|
+
}
|
|
25
|
+
// multiSelect stores its options as a jsonb array — a filter value is one
|
|
26
|
+
// option, not the whole array, so eq/ne/in must check array containment
|
|
27
|
+
// (`@>`) instead of scalar `=`/`<>`/`IN` against the jsonb column.
|
|
28
|
+
// lt/gt/lte/gte have no containment analogue; the boot-validator already
|
|
29
|
+
// blocks them for screen-declared filters (screen-filter-ops.ts
|
|
30
|
+
// EQUALITY_ONLY), but a client-supplied facet filter reaches here
|
|
31
|
+
// unvalidated, so treat it as unsatisfiable rather than emitting SQL
|
|
32
|
+
// Postgres would reject.
|
|
33
|
+
function applyMultiSelectFilter(colSql, whereSql, params, f) {
|
|
34
|
+
if (f.op === "lt" || f.op === "gt" || f.op === "lte" || f.op === "gte") {
|
|
35
|
+
whereSql.push("FALSE");
|
|
36
|
+
// skip: lt/gt/lte/gte is unsatisfiable on a multiSelect column
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (f.op === "in") {
|
|
40
|
+
if (!Array.isArray(f.value) || f.value.length === 0) {
|
|
41
|
+
whereSql.push("FALSE");
|
|
42
|
+
// skip: empty/non-array `in` is unsatisfiable, mirrors buildFilterWhere's "in" short-circuit
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const parts = f.value.map((v) => {
|
|
46
|
+
params.push(JSON.stringify([v]));
|
|
47
|
+
return `${colSql(f.field)} @> $${params.length}::text::jsonb`;
|
|
48
|
+
});
|
|
49
|
+
whereSql.push(`(${parts.join(" OR ")})`);
|
|
50
|
+
// skip: containment condition already pushed — don't fall through to the eq/ne branch below
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const candidates = Array.isArray(f.value) ? f.value : [f.value];
|
|
54
|
+
// `@> '[]'` is true for every non-null row, so an empty array would turn
|
|
55
|
+
// the filter into a silent match-all.
|
|
56
|
+
if (candidates.length === 0) {
|
|
57
|
+
whereSql.push("FALSE");
|
|
58
|
+
// skip: empty candidate list is unsatisfiable
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Bound as text then cast: a JS array makes postgres.js infer boolean[]/int4[]
|
|
62
|
+
// for client-supplied non-string values, and `boolean[]::jsonb` is rejected
|
|
63
|
+
// (500 on client input). An array value means "contains all of these".
|
|
64
|
+
params.push(JSON.stringify(candidates));
|
|
65
|
+
const containment = `${colSql(f.field)} @> $${params.length}::text::jsonb`;
|
|
66
|
+
// A NULL column must count as "does not contain" — plain NOT(...) yields
|
|
67
|
+
// NULL and would drop those rows.
|
|
68
|
+
whereSql.push(f.op === "ne" ? `(${colSql(f.field)} IS NULL OR NOT (${containment}))` : containment);
|
|
69
|
+
}
|
|
70
|
+
// Falls through to the screen-filter WHERE builder shared with
|
|
71
|
+
// screen-declared filters — buildFilterWhere flattens the op into a
|
|
72
|
+
// WhereObject, which this then lowers to raw SQL fragments.
|
|
73
|
+
function applyScreenFilter(table, colSql, whereSql, params, f) {
|
|
74
|
+
const screen = buildFilterWhere(f.field, f.op, f.value);
|
|
75
|
+
if (screen === null) {
|
|
76
|
+
whereSql.push("FALSE");
|
|
77
|
+
// skip: filter is unsatisfiable → emit FALSE, no params to bind
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const [field, value] of Object.entries(screen)) {
|
|
81
|
+
// `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in db/where-clause.ts.
|
|
82
|
+
if (value === null) {
|
|
83
|
+
whereSql.push(`${colSql(field)} IS NULL`);
|
|
84
|
+
}
|
|
85
|
+
else if (Array.isArray(value)) {
|
|
86
|
+
const placeholders = value.map((v) => {
|
|
87
|
+
params.push(v);
|
|
88
|
+
return `$${params.length}`;
|
|
89
|
+
});
|
|
90
|
+
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
91
|
+
}
|
|
92
|
+
else if (isPlainObject(value)) {
|
|
93
|
+
if (value["ne"] === null && Object.keys(value).length === 1) {
|
|
94
|
+
whereSql.push(`${colSql(field)} IS NOT NULL`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const opMap = {
|
|
98
|
+
gt: ">",
|
|
99
|
+
gte: ">=",
|
|
100
|
+
lt: "<",
|
|
101
|
+
lte: "<=",
|
|
102
|
+
ne: "<>",
|
|
103
|
+
};
|
|
104
|
+
for (const [opKey, opSym] of Object.entries(opMap)) {
|
|
105
|
+
if (!(opKey in value))
|
|
106
|
+
continue;
|
|
107
|
+
params.push(value[opKey]);
|
|
108
|
+
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// Blind-index OR rewrite, lock-step with buildWhereClause
|
|
113
|
+
// in db/where-clause.ts — equality on lookupable fields matches the
|
|
114
|
+
// plaintext arm OR the HMAC arm.
|
|
115
|
+
const bidxKey = configuredBlindIndexKey();
|
|
116
|
+
if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
|
|
117
|
+
params.push(value, computeBlindIndex(bidxKey, value));
|
|
118
|
+
whereSql.push(`(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
params.push(value);
|
|
122
|
+
whereSql.push(`${colSql(field)} = $${params.length}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// A `filter`/`filters` entry that already pins the parentRef's entityType
|
|
128
|
+
// field is a real subset of the parent-ref clause's own OR-branches — the
|
|
129
|
+
// client-supplied filter can't widen the result, only narrow which branches
|
|
130
|
+
// the SQL even needs to consider. Skipped for multiSelect fields, where `eq`
|
|
131
|
+
// means containment (see applyMultiSelectFilter) rather than equality.
|
|
132
|
+
export function deriveParentNarrowTypes(entity, payload) {
|
|
133
|
+
const parentRef = entity.parentRef;
|
|
134
|
+
if (parentRef === undefined)
|
|
135
|
+
return undefined;
|
|
136
|
+
if (entity.fields[parentRef.entityTypeField]?.type === "multiSelect")
|
|
137
|
+
return undefined;
|
|
138
|
+
const typeFilters = [
|
|
139
|
+
...(payload.filter ? [payload.filter] : []),
|
|
140
|
+
...(payload.filters ?? []),
|
|
141
|
+
].filter((f) => f.field === parentRef.entityTypeField);
|
|
142
|
+
let narrowed;
|
|
143
|
+
for (const f of typeFilters) {
|
|
144
|
+
let values;
|
|
145
|
+
if (f.op === "eq" && typeof f.value === "string") {
|
|
146
|
+
values = [f.value];
|
|
147
|
+
}
|
|
148
|
+
else if (f.op === "in" &&
|
|
149
|
+
Array.isArray(f.value) &&
|
|
150
|
+
f.value.every((v) => typeof v === "string")) {
|
|
151
|
+
values = f.value;
|
|
152
|
+
}
|
|
153
|
+
if (values === undefined)
|
|
154
|
+
continue;
|
|
155
|
+
narrowed =
|
|
156
|
+
narrowed === undefined
|
|
157
|
+
? new Set(values)
|
|
158
|
+
: new Set([...narrowed].filter((v) => values.includes(v)));
|
|
159
|
+
}
|
|
160
|
+
return narrowed;
|
|
161
|
+
}
|
|
162
|
+
export function collectListFilters(payload) {
|
|
163
|
+
const single = payload.filter !== undefined ? [payload.filter] : [];
|
|
164
|
+
return payload.filters !== undefined ? [...single, ...payload.filters] : single;
|
|
165
|
+
}
|
|
166
|
+
export function applyListFilter(where, f, table, entity, fieldReadClause) {
|
|
167
|
+
if (!Object.hasOwn(table, f.field)) {
|
|
168
|
+
// skip: unknown field — not a real column, drop the filter (injection guard)
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const readClause = fieldReadClause(f.field);
|
|
172
|
+
if (readClause.kind === "empty") {
|
|
173
|
+
where.whereSql.push("FALSE");
|
|
174
|
+
// skip: no role grants read on this field, so no row may be probed
|
|
175
|
+
// through it — an unsatisfiable filter beats an error, which would
|
|
176
|
+
// itself confirm the field exists (field-access.ts strips silently)
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!isListFilterAllowed(entity, f.field, f)) {
|
|
180
|
+
throw new ValidationError({
|
|
181
|
+
fields: [
|
|
182
|
+
{
|
|
183
|
+
path: `filter.${f.field}`,
|
|
184
|
+
code: "field_not_filterable",
|
|
185
|
+
i18nKey: "errors.validation.invalid_value",
|
|
186
|
+
params: {
|
|
187
|
+
field: f.field,
|
|
188
|
+
op: f.op,
|
|
189
|
+
hint: "Encrypted, personal and secret fields cannot be filtered; only equality on a field with find: exact or fuzzy.",
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (readClause.kind === "sql")
|
|
196
|
+
appendShiftedClause(where, readClause);
|
|
197
|
+
if (entity.fields[f.field]?.type === "multiSelect") {
|
|
198
|
+
applyMultiSelectFilter(where.colSql, where.whereSql, where.params, f);
|
|
199
|
+
// skip: multiSelect already handled — don't fall through to applyScreenFilter
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
applyScreenFilter(table, where.colSql, where.whereSql, where.params, f);
|
|
203
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DbRunner } from "./connection.ts";
|
|
2
|
+
import type { Table } from "./event-store-executor-context.ts";
|
|
3
|
+
import type { ListWhere } from "./event-store-executor-read-filter.ts";
|
|
4
|
+
import { type ListSort } from "./event-store-executor-read-sort.ts";
|
|
5
|
+
export declare function resolveListPagination(payload: {
|
|
6
|
+
readonly limit?: unknown;
|
|
7
|
+
readonly offset?: unknown;
|
|
8
|
+
}): {
|
|
9
|
+
readonly limit: number;
|
|
10
|
+
readonly offset: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function appendCursorBoundary(where: ListWhere, encodedCursor: string, sort: ListSort): void;
|
|
13
|
+
export declare function encodeNextCursor(cursorId: string, sort: ListSort, lastRaw: Record<string, unknown>, lastSortLabel: unknown, table: Table): string;
|
|
14
|
+
export declare function countListRows(runner: DbRunner, tableName: string, whereClauseSqlText: string, whereParams: readonly unknown[]): Promise<number>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { UnprocessableError } from "../errors/index.js";
|
|
2
|
+
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
|
|
3
|
+
import { renderSortSql } from "./event-store-executor-read-sort.js";
|
|
4
|
+
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
5
|
+
import { physicalColumnName } from "./table-builder.js";
|
|
6
|
+
// Defense-in-depth pagination guard. The handler boundary (entityListSchema)
|
|
7
|
+
// already validates limit/offset, but the executor is public API for custom
|
|
8
|
+
// handlers that pass their payload straight through — a non-integer limit
|
|
9
|
+
// would otherwise be interpolated raw into `LIMIT ${limit}` SQL text.
|
|
10
|
+
const MAX_LIST_LIMIT = 200; // keep in sync with engine/entity-handlers.ts MAX_LIST_LIMIT
|
|
11
|
+
export function resolveListPagination(payload) {
|
|
12
|
+
const limit = payload.limit ?? 50;
|
|
13
|
+
const offset = payload.offset ?? 0;
|
|
14
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 0) {
|
|
15
|
+
throw new UnprocessableError("invalid_list_limit", {
|
|
16
|
+
details: { hint: "limit must be a non-negative integer" },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
if (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0) {
|
|
20
|
+
throw new UnprocessableError("invalid_list_offset", {
|
|
21
|
+
details: { hint: "offset must be a non-negative integer" },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return { limit: Math.min(limit, MAX_LIST_LIMIT), offset };
|
|
25
|
+
}
|
|
26
|
+
// Cursor sort values travel as text and bind as plain string params — Postgres
|
|
27
|
+
// infers the parameter type from the column they are compared against, the same
|
|
28
|
+
// way prepareValue binds a timestamptz as an ISO string. undefined means the
|
|
29
|
+
// driver value has no faithful text form, which downgrades the page to the
|
|
30
|
+
// legacy id-only boundary instead of emitting a wrong one.
|
|
31
|
+
function toCursorSortText(value) {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (value === null)
|
|
35
|
+
return null;
|
|
36
|
+
if (typeof value === "string")
|
|
37
|
+
return value;
|
|
38
|
+
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
|
|
39
|
+
return String(value);
|
|
40
|
+
}
|
|
41
|
+
if (value instanceof Date)
|
|
42
|
+
return value.toISOString();
|
|
43
|
+
if (typeof value === "object") {
|
|
44
|
+
const tag = Reflect.get(value, Symbol.toStringTag);
|
|
45
|
+
if (typeof tag === "string" && tag.startsWith("Temporal."))
|
|
46
|
+
return String(value);
|
|
47
|
+
return JSON.stringify(value);
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
// Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
|
|
52
|
+
// null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
|
|
53
|
+
// would change the visible order and cost the sort its index.
|
|
54
|
+
function keysetBoundarySql(sortCol, idCol, cursor, descending, params) {
|
|
55
|
+
params.push(cursor.id);
|
|
56
|
+
const afterId = `${idCol} > $${params.length}`;
|
|
57
|
+
const nullsComeFirst = descending;
|
|
58
|
+
if (cursor.sortValue === null) {
|
|
59
|
+
return nullsComeFirst
|
|
60
|
+
? `(${sortCol} IS NOT NULL OR ${afterId})`
|
|
61
|
+
: `(${sortCol} IS NULL AND ${afterId})`;
|
|
62
|
+
}
|
|
63
|
+
params.push(cursor.sortValue);
|
|
64
|
+
const sortParam = `$${params.length}`;
|
|
65
|
+
const beyond = `${sortCol} ${descending ? "<" : ">"} ${sortParam}`;
|
|
66
|
+
const tie = `(${sortCol} = ${sortParam} AND ${afterId})`;
|
|
67
|
+
return nullsComeFirst
|
|
68
|
+
? `(${sortCol} IS NOT NULL AND (${beyond} OR ${tie}))`
|
|
69
|
+
: `(${sortCol} IS NULL OR ${beyond} OR ${tie})`;
|
|
70
|
+
}
|
|
71
|
+
export function appendCursorBoundary(where, encodedCursor, sort) {
|
|
72
|
+
const cursor = decodeKeysetCursor(encodedCursor);
|
|
73
|
+
if (sort.kind === "unsorted" || cursor.sortValue === undefined) {
|
|
74
|
+
where.params.push(cursor.id);
|
|
75
|
+
where.whereSql.push(`${where.colSql("id")} > $${where.params.length}`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
where.whereSql.push(keysetBoundarySql(renderSortSql(sort, where), where.colSql("id"), { id: cursor.id, sortValue: cursor.sortValue }, sort.descending, where.params));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function encodeNextCursor(cursorId, sort, lastRaw, lastSortLabel, table) {
|
|
82
|
+
const sortText = sort.kind === "unsorted"
|
|
83
|
+
? undefined
|
|
84
|
+
: toCursorSortText(sort.kind === "referenceLabel"
|
|
85
|
+
? lastSortLabel
|
|
86
|
+
: lastRaw[physicalColumnName(table, sort.field)]);
|
|
87
|
+
return sortText === undefined ? encodeCursor(cursorId) : encodeKeysetCursor(sortText, cursorId);
|
|
88
|
+
}
|
|
89
|
+
// total: extra COUNT(*) — only when explicitly requested (pager UI).
|
|
90
|
+
// Postgres cost is O(table-scan) without a filter, with a filter as
|
|
91
|
+
// expensive as the corresponding WHERE — cheap enough on indexed columns.
|
|
92
|
+
// Always counted over the full WHERE: search ids, reference matches, tenant,
|
|
93
|
+
// soft-delete, cursor, ownership and filters all narrow the result, so the
|
|
94
|
+
// search hit count alone is never the total.
|
|
95
|
+
export async function countListRows(runner, tableName, whereClauseSqlText, whereParams) {
|
|
96
|
+
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
97
|
+
const countRows = await executeRawQueryRead(runner, countSql, whereParams);
|
|
98
|
+
return countRows[0]?.count ?? 0;
|
|
99
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { EntityDefinition, TenantId } from "../engine/types/index.ts";
|
|
2
|
+
import type { TableInfo } from "./table-info.ts";
|
|
3
|
+
export declare function withStreamVersion(row: Record<string, unknown>, streamVersion: number): Record<string, unknown>;
|
|
4
|
+
export declare function coerceAndRehydrateRow(raw: Record<string, unknown>, tableInfo: TableInfo, entity: EntityDefinition): Record<string, unknown>;
|
|
5
|
+
export declare function appendStreamVersionProjection(params: unknown[], tableName: string, streamTenantId: TenantId): string;
|
|
6
|
+
export declare function takeStreamVersions(rawRows: readonly Record<string, unknown>[]): number[];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
2
|
+
import { coerceRow } from "./query.js";
|
|
3
|
+
const STREAM_VERSION_ALIAS = "__streamVersion";
|
|
4
|
+
export function withStreamVersion(row, streamVersion) {
|
|
5
|
+
return streamVersion > 0 ? { ...row, version: streamVersion } : row;
|
|
6
|
+
}
|
|
7
|
+
// Coerce BEFORE rehydrate/decrypt: raw SQL rows carry snake_case column names,
|
|
8
|
+
// while compound-type lookups (rehydrateMoney et al.) and the encrypted/pii
|
|
9
|
+
// field lists are all camelCase — running either first on a still-snake_case
|
|
10
|
+
// row silently no-ops (money) or skips every multi-word field (ciphertext
|
|
11
|
+
// leaked to the caller).
|
|
12
|
+
export function coerceAndRehydrateRow(raw, tableInfo, entity) {
|
|
13
|
+
return rehydrateCompoundTypes(coerceRow(raw, tableInfo), entity);
|
|
14
|
+
}
|
|
15
|
+
// Stream version in the same statement as the rows: a separate read could pair a
|
|
16
|
+
// post-write version with a pre-write row, and update({version}) would then pass
|
|
17
|
+
// the optimistic lock against a state the client never saw (see loadDetailSnapshot).
|
|
18
|
+
export function appendStreamVersionProjection(params, tableName, streamTenantId) {
|
|
19
|
+
params.push(streamTenantId);
|
|
20
|
+
return (`, (SELECT MAX("version") FROM "kumiko_events" ` +
|
|
21
|
+
`WHERE "aggregate_id" = "${tableName}"."id" AND "tenant_id" = $${params.length}) ` +
|
|
22
|
+
`AS "${STREAM_VERSION_ALIAS}"`);
|
|
23
|
+
}
|
|
24
|
+
// The alias is a query artifact, not a column of this entity — stripped before
|
|
25
|
+
// coercion so it never reaches the caller or the entity cache.
|
|
26
|
+
export function takeStreamVersions(rawRows) {
|
|
27
|
+
return rawRows.map((r) => {
|
|
28
|
+
const v = r[STREAM_VERSION_ALIAS];
|
|
29
|
+
delete r[STREAM_VERSION_ALIAS];
|
|
30
|
+
return typeof v === "number" ? v : 0;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type OwnershipClause } from "../engine/ownership.ts";
|
|
2
|
+
import type { EntityDefinition, SessionUser, TenantId } from "../engine/types/index.ts";
|
|
3
|
+
import type { SearchAdapter } from "../search/types.ts";
|
|
4
|
+
import type { EventStoreExecutor } from "./event-store-executor.ts";
|
|
5
|
+
import type { Table } from "./event-store-executor-context.ts";
|
|
6
|
+
import { type ListWhere } from "./event-store-executor-read-filter.ts";
|
|
7
|
+
import type { TenantDb } from "./tenant-db.ts";
|
|
8
|
+
type ReferenceSearchOption = NonNullable<Parameters<EventStoreExecutor["list"]>[3]>["referenceSearch"];
|
|
9
|
+
export declare function isSoftDeletedTargetHidden(targetEntity: EntityDefinition, targetTable: Table): boolean;
|
|
10
|
+
export type ListSearchRequest = {
|
|
11
|
+
readonly searchTerm: string;
|
|
12
|
+
readonly searchAdapter: SearchAdapter | undefined;
|
|
13
|
+
readonly entityName: string;
|
|
14
|
+
readonly table: Table;
|
|
15
|
+
readonly referenceSearch: ReferenceSearchOption;
|
|
16
|
+
readonly fieldReadClause: (field: string) => OwnershipClause;
|
|
17
|
+
readonly streamTenantFor: (user: SessionUser) => TenantId;
|
|
18
|
+
readonly user: SessionUser;
|
|
19
|
+
readonly db: TenantDb;
|
|
20
|
+
};
|
|
21
|
+
export type ListSearchOutcome = {
|
|
22
|
+
readonly kind: "noMatches";
|
|
23
|
+
} | {
|
|
24
|
+
readonly kind: "applied";
|
|
25
|
+
readonly searchTruncated: boolean;
|
|
26
|
+
};
|
|
27
|
+
export declare function applyListSearch(request: ListSearchRequest, where: ListWhere): Promise<ListSearchOutcome>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { escapeLikePattern } from "../crypto/ciphertext-pattern.js";
|
|
2
|
+
import { buildOwnershipClause, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
|
|
3
|
+
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
4
|
+
import { UnprocessableError } from "../errors/index.js";
|
|
5
|
+
import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
|
|
6
|
+
import { hasSearchablePlaintext, isSensitiveLabelField } from "./entity-field-encryption.js";
|
|
7
|
+
import { bindPlaceholders } from "./event-store-executor-read-filter.js";
|
|
8
|
+
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
9
|
+
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
10
|
+
import { schemaTableName } from "./table-introspection.js";
|
|
11
|
+
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
12
|
+
// Above this many target-row matches, drop the reference clause
|
|
13
|
+
// entirely instead of truncating: an arbitrary 200-row slice of "which
|
|
14
|
+
// targets match" would silently hide rows a full-cardinality match would
|
|
15
|
+
// have found. Native text search on the entity's own fields still applies.
|
|
16
|
+
const MAX_REFERENCE_SEARCH_IDS = 200;
|
|
17
|
+
// The hit ids become an `id IN (...)` filter beneath the SQL cursor/filters/sort, so the
|
|
18
|
+
// adapter must return the whole candidate set, not a page; matches Meilisearch's default
|
|
19
|
+
// maxTotalHits, which caps a response at exactly this many hits however high a larger limit is
|
|
20
|
+
// asked. A response that reaches the cap is therefore flagged `searchTruncated` rather than
|
|
21
|
+
// treated as complete (a cap of N+1 could never be observed).
|
|
22
|
+
const MAX_SEARCH_CANDIDATE_IDS = 1000;
|
|
23
|
+
// Registry-declared `searchable` reference fields, plus the implicit
|
|
24
|
+
// tenantId→tenant.name row-meta reference — both resolve a text
|
|
25
|
+
// search term against a target row's label instead of the raw FK column.
|
|
26
|
+
function collectReferenceSearchDescriptors(table, referenceSearch, dbMode) {
|
|
27
|
+
if (!referenceSearch)
|
|
28
|
+
return [];
|
|
29
|
+
const descriptors = referenceSearch.fields.map((f) => ({
|
|
30
|
+
ownColumn: f.fieldName,
|
|
31
|
+
targetEntityName: f.targetEntityName,
|
|
32
|
+
labelField: f.labelField,
|
|
33
|
+
}));
|
|
34
|
+
for (const [ownColumn, ref] of Object.entries(LIST_ROW_META_REFERENCES)) {
|
|
35
|
+
if (table[ownColumn] === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
// In "tenant" mode the outer tenant filter already pins tenantId to one
|
|
38
|
+
// of exactly two values, so a by-name lookup here can never narrow that further.
|
|
39
|
+
if (ownColumn === "tenantId" && dbMode === "tenant")
|
|
40
|
+
continue;
|
|
41
|
+
descriptors.push({ ownColumn, targetEntityName: ref.refEntity, labelField: ref.refLabelField });
|
|
42
|
+
}
|
|
43
|
+
return descriptors;
|
|
44
|
+
}
|
|
45
|
+
// Matches the main list's own default: soft-deleted rows are invisible, so a
|
|
46
|
+
// deleted target must neither match a search nor influence an ordering.
|
|
47
|
+
export function isSoftDeletedTargetHidden(targetEntity, targetTable) {
|
|
48
|
+
return targetEntity.softDelete === true && targetTable["isDeleted"] !== undefined;
|
|
49
|
+
}
|
|
50
|
+
// Tenant-scoped ILIKE lookup against one reference target — never raw/
|
|
51
|
+
// unscoped SQL, a match in a foreign tenant must never surface a row here.
|
|
52
|
+
// Mirrors the main table's own tenant filter (see the `table["tenantId"]`
|
|
53
|
+
// check further down in list()).
|
|
54
|
+
async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db) {
|
|
55
|
+
// The label must not leak through match counts: a viewer who cannot read the
|
|
56
|
+
// target row or its label field would otherwise probe names by searching.
|
|
57
|
+
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
|
|
58
|
+
if (labelAccess.kind !== "pass")
|
|
59
|
+
return undefined;
|
|
60
|
+
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
61
|
+
if (ownership.kind === "empty")
|
|
62
|
+
return undefined;
|
|
63
|
+
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
64
|
+
const subParams = [`%${escapeLikePattern(searchTerm)}%`];
|
|
65
|
+
let tenantClause = "";
|
|
66
|
+
if (targetTable["tenantId"] !== undefined && db.mode === "tenant") {
|
|
67
|
+
subParams.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
68
|
+
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
|
|
69
|
+
}
|
|
70
|
+
let ownershipClause = "";
|
|
71
|
+
if (ownership.kind === "sql") {
|
|
72
|
+
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, subParams.length);
|
|
73
|
+
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
74
|
+
for (const p of shifted.params)
|
|
75
|
+
subParams.push(p);
|
|
76
|
+
}
|
|
77
|
+
const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
|
|
78
|
+
? ` AND "${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
|
|
79
|
+
: "";
|
|
80
|
+
// ::text cast covers a non-text labelField (e.g. a number/select column
|
|
81
|
+
// used as label) — Postgres has no ILIKE for those types otherwise.
|
|
82
|
+
const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause}${ownershipClause}${deletedClause} ` +
|
|
83
|
+
`LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
|
|
84
|
+
const rows = await executeRawQueryRead(tenantDbRunner(db), sql, subParams);
|
|
85
|
+
if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
86
|
+
// skip: no match, or over the cap — drop the clause rather than
|
|
87
|
+
// truncate to an arbitrary slice of matching targets
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
|
|
91
|
+
}
|
|
92
|
+
// Same lookup, but the target's index is tenant-wide, not per-viewer, so
|
|
93
|
+
// candidate ids get re-checked against the target entity's own read access.
|
|
94
|
+
async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter) {
|
|
95
|
+
const searchTenantId = targetEntity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
|
|
96
|
+
const results = await searchAdapter.search(searchTenantId, searchTerm, {
|
|
97
|
+
filterType: descriptor.targetEntityName,
|
|
98
|
+
limit: MAX_REFERENCE_SEARCH_IDS + 1,
|
|
99
|
+
});
|
|
100
|
+
if (results.length === 0 || results.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
101
|
+
// skip: no match, or over the cap — same drop-the-clause policy as the ILIKE path
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
105
|
+
if (ownership.kind === "empty")
|
|
106
|
+
return undefined;
|
|
107
|
+
const candidateIds = results.map((r) => String(r.entityId));
|
|
108
|
+
// The index is only trusted to name candidates: a stale or foreign-tenant document must
|
|
109
|
+
// never become a filter value, so tenant-scoped targets are always re-read from the DB.
|
|
110
|
+
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
111
|
+
if (ownership.kind === "pass" && !tenantScoped) {
|
|
112
|
+
return { ownColumn: descriptor.ownColumn, ids: candidateIds };
|
|
113
|
+
}
|
|
114
|
+
const params = [...candidateIds];
|
|
115
|
+
const idPlaceholders = candidateIds.map((_, i) => `$${i + 1}`);
|
|
116
|
+
let tenantClause = "";
|
|
117
|
+
if (tenantScoped) {
|
|
118
|
+
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
119
|
+
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($${params.length - 1}, $${params.length})`;
|
|
120
|
+
}
|
|
121
|
+
let ownershipClause = "";
|
|
122
|
+
if (ownership.kind === "sql") {
|
|
123
|
+
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
|
|
124
|
+
for (const p of shifted.params)
|
|
125
|
+
params.push(p);
|
|
126
|
+
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
127
|
+
}
|
|
128
|
+
const sql = `SELECT "id" FROM "${targetTableName}" WHERE "id" IN (${idPlaceholders.join(", ")})` +
|
|
129
|
+
`${tenantClause}${ownershipClause}`;
|
|
130
|
+
const rows = await executeRawQueryRead(tenantDbRunner(db), sql, params);
|
|
131
|
+
if (rows.length === 0)
|
|
132
|
+
return undefined;
|
|
133
|
+
return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
|
|
134
|
+
}
|
|
135
|
+
async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db, user, searchAdapter) {
|
|
136
|
+
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
137
|
+
if (targetEntity === undefined) {
|
|
138
|
+
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
139
|
+
// should have caught; fail open (no clause) rather than 500 the request
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
if (targetEntity.fields[descriptor.labelField] === undefined) {
|
|
143
|
+
// skip: labelField isn't a real (declared) column on the target entity —
|
|
144
|
+
// row-meta references (e.g. tenant.name) always resolve here since
|
|
145
|
+
// `name` is a declared field on the tenant entity, not an id/row-meta column
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
|
|
149
|
+
const targetTableName = String(schemaTableName(targetTable));
|
|
150
|
+
if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
151
|
+
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
|
|
152
|
+
}
|
|
153
|
+
if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
|
|
154
|
+
// skip: no plaintext anywhere to match — boot rejects this normally,
|
|
155
|
+
// this is defense-in-depth for a target entity resolved only at runtime.
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter);
|
|
159
|
+
}
|
|
160
|
+
// Building the table is pure but not free, and runs on every search request;
|
|
161
|
+
// keyed by the definition object so a re-registered entity never sees a stale table.
|
|
162
|
+
const referenceTargetTables = new WeakMap();
|
|
163
|
+
function referenceTargetTable(name, entity) {
|
|
164
|
+
const cached = referenceTargetTables.get(entity);
|
|
165
|
+
if (cached?.name === name)
|
|
166
|
+
return cached.table;
|
|
167
|
+
const table = buildEntityTable(name, entity);
|
|
168
|
+
referenceTargetTables.set(entity, { name, table });
|
|
169
|
+
return table;
|
|
170
|
+
}
|
|
171
|
+
export async function applyListSearch(request, where) {
|
|
172
|
+
const { searchAdapter } = request;
|
|
173
|
+
// A search term with no adapter wired must fail loud, not
|
|
174
|
+
// silently return the unfiltered list dressed up as a search result.
|
|
175
|
+
if (!searchAdapter) {
|
|
176
|
+
throw new UnprocessableError("search_adapter_not_wired", {
|
|
177
|
+
details: {
|
|
178
|
+
entity: request.entityName,
|
|
179
|
+
hint: "Wire a SearchAdapter for this entity, or remove `searchable` from the field/screen.",
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// Same choke-point as writes/index: systemStream docs live under
|
|
184
|
+
// SYSTEM_TENANT_ID; everything else under the session tenant — db.mode
|
|
185
|
+
// must not invent a second predicate.
|
|
186
|
+
const searchTenantId = request.streamTenantFor(request.user);
|
|
187
|
+
const results = await searchAdapter.search(searchTenantId, request.searchTerm, {
|
|
188
|
+
filterType: request.entityName,
|
|
189
|
+
limit: MAX_SEARCH_CANDIDATE_IDS,
|
|
190
|
+
});
|
|
191
|
+
const filterIds = results.map((r) => r.entityId);
|
|
192
|
+
const referenceMatches = await resolveSearchableReferenceMatches(request, searchAdapter);
|
|
193
|
+
if (filterIds.length === 0 && referenceMatches.length === 0)
|
|
194
|
+
return { kind: "noMatches" };
|
|
195
|
+
const orParts = [];
|
|
196
|
+
if (filterIds.length > 0) {
|
|
197
|
+
orParts.push(`${where.colSql("id")} IN (${bindPlaceholders(where.params, filterIds)})`);
|
|
198
|
+
}
|
|
199
|
+
for (const match of referenceMatches) {
|
|
200
|
+
orParts.push(`${where.colSql(match.ownColumn)} IN (${bindPlaceholders(where.params, match.ids)})`);
|
|
201
|
+
}
|
|
202
|
+
// Parenthesized as one whereSql entry — whereSql is joined with
|
|
203
|
+
// " AND " below, an unparenthesized OR here would leak past the
|
|
204
|
+
// tenant filter that follows.
|
|
205
|
+
where.whereSql.push(`(${orParts.join(" OR ")})`);
|
|
206
|
+
return { kind: "applied", searchTruncated: results.length >= MAX_SEARCH_CANDIDATE_IDS };
|
|
207
|
+
}
|
|
208
|
+
// A searchable reference field (or the implicit tenantId→tenant.name row-meta
|
|
209
|
+
// reference) whose target row's labelField matches the search term also counts
|
|
210
|
+
// as a hit on this entity, not just a raw text-field hit.
|
|
211
|
+
async function resolveSearchableReferenceMatches(request, searchAdapter) {
|
|
212
|
+
const { referenceSearch } = request;
|
|
213
|
+
if (!referenceSearch)
|
|
214
|
+
return [];
|
|
215
|
+
// A caller who cannot read the reference column itself must not be able
|
|
216
|
+
// to infer its value by matching the target's label — same probe
|
|
217
|
+
// surface as filtering/sorting on it directly.
|
|
218
|
+
const descriptors = collectReferenceSearchDescriptors(request.table, referenceSearch, request.db.mode).filter((d) => request.fieldReadClause(d.ownColumn).kind === "pass");
|
|
219
|
+
const matches = await Promise.all(descriptors.map((d) => resolveReferenceMatches(d, request.searchTerm, referenceSearch.resolveEntity, request.db, request.user, searchAdapter)));
|
|
220
|
+
return matches.filter((m) => m !== undefined);
|
|
221
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type OwnershipClause } from "../engine/ownership.ts";
|
|
2
|
+
import type { EntityDefinition, SessionUser } from "../engine/types/index.ts";
|
|
3
|
+
import type { EventStoreExecutor } from "./event-store-executor.ts";
|
|
4
|
+
import type { Table } from "./event-store-executor-context.ts";
|
|
5
|
+
import type { ListWhere } from "./event-store-executor-read-filter.ts";
|
|
6
|
+
import type { TenantDb } from "./tenant-db.ts";
|
|
7
|
+
type ListPayload = Parameters<EventStoreExecutor["list"]>[0];
|
|
8
|
+
type ReferenceSortOption = NonNullable<Parameters<EventStoreExecutor["list"]>[3]>["referenceSort"];
|
|
9
|
+
export type ListSort = {
|
|
10
|
+
readonly kind: "unsorted";
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: "column";
|
|
13
|
+
readonly field: string;
|
|
14
|
+
readonly descending: boolean;
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: "referenceLabel";
|
|
17
|
+
readonly field: string;
|
|
18
|
+
readonly descending: boolean;
|
|
19
|
+
readonly labelSql: (params: unknown[]) => string;
|
|
20
|
+
};
|
|
21
|
+
export type SortedListSort = Exclude<ListSort, {
|
|
22
|
+
readonly kind: "unsorted";
|
|
23
|
+
}>;
|
|
24
|
+
export declare function resolveListSort(request: {
|
|
25
|
+
readonly payload: Pick<ListPayload, "sort" | "sortDirection">;
|
|
26
|
+
readonly table: Table;
|
|
27
|
+
readonly entity: EntityDefinition;
|
|
28
|
+
readonly tableName: string;
|
|
29
|
+
readonly colSql: (field: string) => string;
|
|
30
|
+
readonly fieldReadClause: (field: string) => OwnershipClause;
|
|
31
|
+
readonly referenceSort: ReferenceSortOption;
|
|
32
|
+
readonly db: TenantDb;
|
|
33
|
+
readonly user: SessionUser;
|
|
34
|
+
}): ListSort;
|
|
35
|
+
export declare function renderSortSql(sort: SortedListSort, where: ListWhere): string;
|
|
36
|
+
export declare function renderListOrderBy(sort: ListSort, where: ListWhere): {
|
|
37
|
+
readonly orderByClause: string;
|
|
38
|
+
readonly sortLabelProjection: string;
|
|
39
|
+
};
|
|
40
|
+
export declare function takeLastSortLabel(rawRows: readonly Record<string, unknown>[], sort: ListSort): unknown;
|
|
41
|
+
export {};
|