@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
|
@@ -2,8 +2,10 @@ import { fetchOne } from "../db/query.js";
|
|
|
2
2
|
import { createSystemUser } from "../engine/system-user.js";
|
|
3
3
|
import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
|
|
4
4
|
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
5
|
+
import { getStreamVersion } from "../event-store/event-store.js";
|
|
5
6
|
import { entityTableFromRegistry } from "./entity-table-from-registry.js";
|
|
6
7
|
import { createEventStoreExecutor } from "./event-store-executor.js";
|
|
8
|
+
import { deleteRowsWithoutReplayableStream } from "./queries/event-store-orphan-rows.js";
|
|
7
9
|
import { createTenantDb } from "./tenant-db.js";
|
|
8
10
|
/**
|
|
9
11
|
* Seed reference data at boot time.
|
|
@@ -37,7 +39,22 @@ export async function seedReferenceData(defs, registry, db) {
|
|
|
37
39
|
const keyValue = row[upsertKey];
|
|
38
40
|
if (keyValue === undefined)
|
|
39
41
|
continue;
|
|
40
|
-
|
|
42
|
+
// Soft-deleted rows are invisible to executor.update, so treat them as missing and re-create.
|
|
43
|
+
const existingRow = await fetchOne(db, table, {
|
|
44
|
+
[upsertKey]: keyValue,
|
|
45
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
46
|
+
...(entity.softDelete && { isDeleted: false }),
|
|
47
|
+
});
|
|
48
|
+
// Rows seeded before reference data went through the executor have no events: update would
|
|
49
|
+
// hit a stream-version conflict and a rebuild would drop them. Drop the row and re-create
|
|
50
|
+
// it under the same id so its stream starts now.
|
|
51
|
+
let existing = existingRow;
|
|
52
|
+
let recreateId;
|
|
53
|
+
if (existingRow && (await getStreamVersion(db, existingRow.id, SYSTEM_TENANT_ID)) === 0) {
|
|
54
|
+
await deleteRowsWithoutReplayableStream(tdb, { table, ids: [existingRow.id] });
|
|
55
|
+
existing = undefined;
|
|
56
|
+
recreateId = existingRow.id;
|
|
57
|
+
}
|
|
41
58
|
if (existing) {
|
|
42
59
|
const changes = {};
|
|
43
60
|
for (const [field, value] of Object.entries(row)) {
|
|
@@ -55,7 +72,7 @@ export async function seedReferenceData(defs, registry, db) {
|
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
74
|
else {
|
|
58
|
-
const result = await executor.create({ ...row, tenantId: SYSTEM_TENANT_ID }, by, tdb);
|
|
75
|
+
const result = await executor.create({ ...row, ...(recreateId && { id: recreateId }), tenantId: SYSTEM_TENANT_ID }, by, tdb);
|
|
59
76
|
if (!result.isSuccess)
|
|
60
77
|
throw reraiseAsKumikoError(result.error);
|
|
61
78
|
inserted++;
|
|
@@ -4,7 +4,8 @@ const UNBOUNDED_POOL_CLOSE_TIMEOUT_SECONDS = 5;
|
|
|
4
4
|
// PII backfill and projection rebuilds legitimately run longer. The cap is a connect-time session
|
|
5
5
|
// setting, so the run gets its own short-lived pool with the same options minus the cap: one shared
|
|
6
6
|
// connection would deadlock code that holds a lock in a tx while its DDL runs on another connection.
|
|
7
|
-
// Handles not created by createDbConnection (tx, savepoint, siblings) are passed through
|
|
7
|
+
// Handles not created by createDbConnection (tx, savepoint, siblings, gated runner proxies) are passed through
|
|
8
|
+
// unchanged and keep the cap; a caller holding such a runner lifts it itself (SET LOCAL in its own transaction).
|
|
8
9
|
export async function withoutStatementTimeout(db, run) {
|
|
9
10
|
const unbounded = openUnboundedSiblingPool(db);
|
|
10
11
|
if (unbounded === undefined)
|
package/dist/db/table-builder.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
2
|
import { toSnakeCase } from "../utils/case.js";
|
|
3
|
-
import { assertUnreachable } from "../utils/index.js";
|
|
4
3
|
import { markExecutorManagedTable } from "./executor-managed-tables.js";
|
|
5
4
|
import { columnHandleName } from "./table-introspection.js";
|
|
6
5
|
export { toSnakeCase } from "../utils/case.js";
|
|
@@ -13,172 +12,120 @@ export const SOFT_DELETE_LIVE_ROW_PREDICATE = `"is_deleted" = false`;
|
|
|
13
12
|
export function physicalColumnName(table, field) {
|
|
14
13
|
return columnHandleName(table, field) ?? toSnakeCase(field);
|
|
15
14
|
}
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// longText currently has no default field in its type, but checking
|
|
36
|
-
// `field.default !== undefined` is defensive.
|
|
37
|
-
const base = text(snakeName);
|
|
38
|
-
const withDefault = "default" in field && field.default !== undefined ? base.default(field.default) : base;
|
|
39
|
-
const mainCol = { [name]: field.required ? withDefault.notNull() : withDefault };
|
|
40
|
-
// lookupable → nullable HMAC blind-index column (kept in lockstep with
|
|
41
|
-
// fieldToColumnMeta in entity-table-meta.ts).
|
|
42
|
-
if (field.type === "text" && field.lookupable === true) {
|
|
43
|
-
return { ...mainCol, [`${name}Bidx`]: text(`${snakeName}_bidx`) };
|
|
44
|
-
}
|
|
45
|
-
return mainCol;
|
|
46
|
-
}
|
|
47
|
-
case "boolean":
|
|
48
|
-
return {
|
|
49
|
-
[name]: field.default !== undefined
|
|
50
|
-
? boolean(snakeName).default(field.default).notNull()
|
|
51
|
-
: field.required
|
|
52
|
-
? boolean(snakeName).notNull()
|
|
53
|
-
: boolean(snakeName),
|
|
54
|
-
};
|
|
55
|
-
case "select": {
|
|
56
|
-
// Pass default() through because entity-table-meta renders declared
|
|
57
|
-
// defaults. This builder must stay in lockstep; otherwise the metadata
|
|
58
|
-
// on the buildEntityTable object describes a different column than the
|
|
59
|
-
// migration.
|
|
60
|
-
const base = text(snakeName);
|
|
61
|
-
const col = field.default !== undefined ? base.default(field.default) : base;
|
|
62
|
-
return { [name]: field.required ? col.notNull() : col };
|
|
63
|
-
}
|
|
64
|
-
case "multiSelect":
|
|
65
|
-
// jsonb string array with default `[]`, always NOT NULL.
|
|
66
|
-
//
|
|
67
|
-
// The `required` flag on MultiSelectFieldDef is intentionally ignored:
|
|
68
|
-
// with default `[]`, the field is structurally never null (an insert
|
|
69
|
-
// without a value produces an empty array, not NULL). Read-side code
|
|
70
|
-
// therefore needs no null check; this is an API guarantee. Code that
|
|
71
|
-
// needs actual null (meaning the field has never been set) should use
|
|
72
|
-
// a separate status field or an optional-typed reference instead of a
|
|
73
|
-
// multi-select.
|
|
74
|
-
return { [name]: jsonb(snakeName).default([]).notNull() };
|
|
75
|
-
case "number": {
|
|
76
|
-
const base = field.integer === true ? integer(snakeName) : doublePrecision(snakeName);
|
|
77
|
-
const col = field.default !== undefined ? base.default(field.default) : base;
|
|
78
|
-
return { [name]: field.required ? col.notNull() : col };
|
|
79
|
-
}
|
|
80
|
-
case "decimal": {
|
|
81
|
-
const base = decimalColumn(snakeName, field.precision, field.scale);
|
|
82
|
-
const col = field.default !== undefined ? base.default(field.default) : base;
|
|
83
|
-
return { [name]: field.required ? col.notNull() : col };
|
|
84
|
-
}
|
|
85
|
-
case "bigInt": {
|
|
86
|
-
// 64-bit integer for audit counters, byte sizes, and cumulative sums.
|
|
87
|
-
// mode:"number" produces a JS `number` (safe up to 2^53 ≈ 9 PB) rather
|
|
88
|
-
// than JS `bigint`, making it JSON-serializable and frontend-friendly.
|
|
89
|
-
// Values above 2^53 should use a text field with a custom codec.
|
|
90
|
-
const base = bigint(snakeName, { mode: "number" });
|
|
91
|
-
const col = field.default !== undefined ? base.default(field.default) : base;
|
|
92
|
-
return { [name]: field.required ? col.notNull() : col };
|
|
93
|
-
}
|
|
94
|
-
case "reference":
|
|
95
|
-
// FK-style UUID column. Multi-mode stores UUIDs as a jsonb string array;
|
|
96
|
-
// single-mode remains a conventional UUID column (NOT NULL only when
|
|
97
|
-
// required).
|
|
98
|
-
if (field.multiple === true) {
|
|
99
|
-
return { [name]: jsonb(snakeName).default([]).notNull() };
|
|
100
|
-
}
|
|
101
|
-
return {
|
|
102
|
-
[name]: field.required ? uuid(snakeName).notNull() : uuid(snakeName),
|
|
103
|
-
};
|
|
104
|
-
case "money":
|
|
105
|
-
// BIGINT stores the integer minor unit (cents for EUR, yen for JPY —
|
|
106
|
-
// the currency column identifies which). INTEGER would cap at about
|
|
107
|
-
// 21 M EUR, which is too low for B2B invoices, property values, or
|
|
108
|
-
// balance aggregates. BIGINT safely handles up to about 90 trillion
|
|
109
|
-
// EUR in JS.
|
|
110
|
-
// Currency always has a default, so it is structurally .notNull().
|
|
111
|
-
return {
|
|
112
|
-
[name]: field.required ? moneyAmount(snakeName).notNull() : moneyAmount(snakeName),
|
|
113
|
-
[`${name}Currency`]: text(`${snakeName}_currency`)
|
|
114
|
-
.default(entity.defaultCurrency ?? "EUR")
|
|
115
|
-
.notNull(),
|
|
116
|
-
};
|
|
117
|
-
case "embedded":
|
|
118
|
-
// jsonb with default `{}`, always NOT NULL, analogous to multiSelect.
|
|
119
|
-
// `required` is intentionally ignored because the default makes the
|
|
120
|
-
// field structurally never null. To make embedded data optional (so
|
|
121
|
-
// the field can be omitted entirely), model it with a wrapper field
|
|
122
|
-
// and boolean flag or a discriminated union.
|
|
123
|
-
// multiple → the value is a list of rows, so the empty default is `[]`.
|
|
124
|
-
return field.multiple === true
|
|
125
|
-
? { [name]: jsonb(snakeName).default([]).notNull() }
|
|
126
|
-
: { [name]: jsonb(snakeName).default({}).notNull() };
|
|
127
|
-
case "jsonb":
|
|
128
|
-
// Free-form jsonb — keys are not schema-validated. Default `{}`, NOT NULL
|
|
129
|
-
// (analogous to embedded). Use case: the host entity's custom-fields
|
|
130
|
-
// bundle in the `customFields` column, whose keys are tenant-defined.
|
|
131
|
-
return { [name]: jsonb(snakeName).default({}).notNull() };
|
|
132
|
-
case "date": {
|
|
133
|
-
// Real PG `date` — no time-of-day, no timezone. Temporal.PlainDate
|
|
134
|
-
// end-to-end (see plainDate() in dialect.ts). Was aliased on instant()
|
|
135
|
-
// = TIMESTAMPTZ (TZ-dependent round-trip).
|
|
136
|
-
const col = plainDate(snakeName);
|
|
137
|
-
return { [name]: field.required ? col.notNull() : col };
|
|
138
|
-
}
|
|
139
|
-
case "timestamp": {
|
|
140
|
-
// UTC instant — stored as TIMESTAMPTZ in PG and read/written as
|
|
141
|
-
// Temporal.Instant through the instant() customType (see dialect.ts).
|
|
142
|
-
// Callers use only Temporal.Instant, never JS Date. Comparisons
|
|
143
|
-
// (lte/gt/orderBy) also accept Instants directly, so no .toString()
|
|
144
|
-
// cast is needed.
|
|
145
|
-
const col = instant(snakeName);
|
|
146
|
-
return { [name]: field.required ? col.notNull() : col };
|
|
147
|
-
}
|
|
148
|
-
case "tz": {
|
|
149
|
-
// IANA time-zone name as TEXT — validated through the Zod schema in the
|
|
150
|
-
// validator step. Snake-case convention: `pickup_tz`.
|
|
151
|
-
const col = text(snakeName);
|
|
152
|
-
return { [name]: field.required ? col.notNull() : col };
|
|
153
|
-
}
|
|
154
|
-
case "locatedTimestamp": {
|
|
155
|
-
// Two columns form an atomic pair: <name>_utc TIMESTAMPTZ + <name>_tz TEXT.
|
|
156
|
-
// _utc is instant() (Temporal.Instant), and _tz is text (IANA name).
|
|
157
|
-
// The executor wires automatic conversion (at+tz → utc on insert;
|
|
158
|
-
// utc+tz → at on read). required propagates to both columns.
|
|
159
|
-
const utc = instant(`${snakeName}_utc`);
|
|
160
|
-
const tz = text(`${snakeName}_tz`);
|
|
161
|
-
return {
|
|
162
|
-
[`${name}Utc`]: field.required ? utc.notNull() : utc,
|
|
163
|
-
[`${name}Tz`]: field.required ? tz.notNull() : tz,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
case "file":
|
|
167
|
-
case "image": {
|
|
168
|
-
// Single file: stores fileRefId as UUID — must match fileRefsTable.id
|
|
169
|
-
// (uuid column). Anything narrower (integer, text length-limited) would
|
|
170
|
-
// silently truncate or type-coerce at INSERT time and the FK reference
|
|
171
|
-
// would be unusable.
|
|
172
|
-
const col = uuid(snakeName);
|
|
173
|
-
return { [name]: field.required ? col.notNull() : col };
|
|
174
|
-
}
|
|
175
|
-
case "files":
|
|
176
|
-
case "images":
|
|
177
|
-
// Ordered fileRef UUID array, same shape as multiSelect: never null.
|
|
178
|
-
return { [name]: jsonb(snakeName).default([]).notNull() };
|
|
179
|
-
default:
|
|
180
|
-
assertUnreachable(field, "field type");
|
|
15
|
+
// `required: true` maps to NOT NULL for every field type, so r.entity stays the
|
|
16
|
+
// single source of truth and hand-written tables never add .notNull() on top.
|
|
17
|
+
function notNullWhenRequired(column, required) {
|
|
18
|
+
return required ? column.notNull() : column;
|
|
19
|
+
}
|
|
20
|
+
// Without a DEFAULT clause ALTER TABLE ADD COLUMN breaks on existing rows, and
|
|
21
|
+
// entity-table-meta renders declared defaults too, so both builders stay in lockstep.
|
|
22
|
+
function withDeclaredDefault(column, value) {
|
|
23
|
+
return value !== undefined ? column.default(value) : column;
|
|
24
|
+
}
|
|
25
|
+
// longText differs from text only in the type layer (no sortable/searchable/filterable).
|
|
26
|
+
function textColumns(name, snakeName, field) {
|
|
27
|
+
const declaredDefault = "default" in field ? field.default : undefined;
|
|
28
|
+
const mainCol = {
|
|
29
|
+
[name]: notNullWhenRequired(withDeclaredDefault(text(snakeName), declaredDefault), field.required),
|
|
30
|
+
};
|
|
31
|
+
// Nullable HMAC blind-index column, in lockstep with fieldToColumnMeta in entity-table-meta.ts.
|
|
32
|
+
if (field.type === "text" && field.lookupable === true) {
|
|
33
|
+
return { ...mainCol, [`${name}Bidx`]: text(`${snakeName}_bidx`) };
|
|
181
34
|
}
|
|
35
|
+
return mainCol;
|
|
36
|
+
}
|
|
37
|
+
function booleanColumns(name, snakeName, field) {
|
|
38
|
+
if (field.default !== undefined) {
|
|
39
|
+
return { [name]: boolean(snakeName).default(field.default).notNull() };
|
|
40
|
+
}
|
|
41
|
+
return { [name]: notNullWhenRequired(boolean(snakeName), field.required) };
|
|
42
|
+
}
|
|
43
|
+
function numberColumns(name, snakeName, field) {
|
|
44
|
+
const base = field.integer === true ? integer(snakeName) : doublePrecision(snakeName);
|
|
45
|
+
return { [name]: notNullWhenRequired(withDeclaredDefault(base, field.default), field.required) };
|
|
46
|
+
}
|
|
47
|
+
function referenceColumns(name, snakeName, field) {
|
|
48
|
+
if (field.multiple === true)
|
|
49
|
+
return { [name]: emptyJsonbArray(snakeName) };
|
|
50
|
+
return { [name]: notNullWhenRequired(uuid(snakeName), field.required) };
|
|
51
|
+
}
|
|
52
|
+
// BIGINT minor units: INTEGER would cap at about 21 M EUR. The currency column
|
|
53
|
+
// always has a default, so it is structurally NOT NULL.
|
|
54
|
+
function moneyColumns(name, snakeName, field, entity) {
|
|
55
|
+
return {
|
|
56
|
+
[name]: notNullWhenRequired(moneyAmount(snakeName), field.required),
|
|
57
|
+
[`${name}Currency`]: text(`${snakeName}_currency`)
|
|
58
|
+
.default(entity.defaultCurrency ?? "EUR")
|
|
59
|
+
.notNull(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// <name>_utc + <name>_tz form an atomic pair the executor converts on write and
|
|
63
|
+
// read, so `required` propagates to both columns.
|
|
64
|
+
function locatedTimestampColumns(name, snakeName, field) {
|
|
65
|
+
return {
|
|
66
|
+
[`${name}Utc`]: notNullWhenRequired(instant(`${snakeName}_utc`), field.required),
|
|
67
|
+
[`${name}Tz`]: notNullWhenRequired(text(`${snakeName}_tz`), field.required),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// The `[]` default makes the column structurally never null, so `required` is
|
|
71
|
+
// intentionally ignored and read-side code needs no null check.
|
|
72
|
+
function emptyJsonbArray(snakeName) {
|
|
73
|
+
return jsonb(snakeName).default([]).notNull();
|
|
74
|
+
}
|
|
75
|
+
// Same reasoning as emptyJsonbArray; optional embedded data needs a wrapper field.
|
|
76
|
+
function emptyJsonbObject(snakeName) {
|
|
77
|
+
return jsonb(snakeName).default({}).notNull();
|
|
78
|
+
}
|
|
79
|
+
function singleColumnNotNullWhenRequired(column) {
|
|
80
|
+
return (name, snakeName, field) => ({
|
|
81
|
+
[name]: notNullWhenRequired(column(snakeName), field.required),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Each entry has a type-level twin in ColumnsForField and a meta-level twin in
|
|
85
|
+
// fieldToColumnMeta (entity-table-meta.ts).
|
|
86
|
+
const COLUMN_EMITTERS = {
|
|
87
|
+
text: textColumns,
|
|
88
|
+
longText: textColumns,
|
|
89
|
+
boolean: booleanColumns,
|
|
90
|
+
select: (name, snakeName, field) => ({
|
|
91
|
+
[name]: notNullWhenRequired(withDeclaredDefault(text(snakeName), field.default), field.required),
|
|
92
|
+
}),
|
|
93
|
+
multiSelect: (name, snakeName) => ({ [name]: emptyJsonbArray(snakeName) }),
|
|
94
|
+
number: numberColumns,
|
|
95
|
+
decimal: (name, snakeName, field) => ({
|
|
96
|
+
[name]: notNullWhenRequired(withDeclaredDefault(decimalColumn(snakeName, field.precision, field.scale), field.default), field.required),
|
|
97
|
+
}),
|
|
98
|
+
// mode "number" keeps the value JSON-serializable (safe up to 2^53).
|
|
99
|
+
bigInt: (name, snakeName, field) => ({
|
|
100
|
+
[name]: notNullWhenRequired(withDeclaredDefault(bigint(snakeName, { mode: "number" }), field.default), field.required),
|
|
101
|
+
}),
|
|
102
|
+
reference: referenceColumns,
|
|
103
|
+
money: moneyColumns,
|
|
104
|
+
embedded: (name, snakeName, field) => ({
|
|
105
|
+
[name]: field.multiple === true ? emptyJsonbArray(snakeName) : emptyJsonbObject(snakeName),
|
|
106
|
+
}),
|
|
107
|
+
jsonb: (name, snakeName) => ({ [name]: emptyJsonbObject(snakeName) }),
|
|
108
|
+
date: singleColumnNotNullWhenRequired(plainDate),
|
|
109
|
+
timestamp: singleColumnNotNullWhenRequired(instant),
|
|
110
|
+
tz: singleColumnNotNullWhenRequired(text),
|
|
111
|
+
locatedTimestamp: locatedTimestampColumns,
|
|
112
|
+
// Must match fileRefsTable.id (uuid); anything narrower breaks the reference.
|
|
113
|
+
file: singleColumnNotNullWhenRequired(uuid),
|
|
114
|
+
image: singleColumnNotNullWhenRequired(uuid),
|
|
115
|
+
files: (name, snakeName) => ({ [name]: emptyJsonbArray(snakeName) }),
|
|
116
|
+
images: (name, snakeName) => ({ [name]: emptyJsonbArray(snakeName) }),
|
|
117
|
+
};
|
|
118
|
+
function emitFieldColumns(type, name, field, entity) {
|
|
119
|
+
// A type from untyped input must not reach an Object.prototype member.
|
|
120
|
+
if (!Object.hasOwn(COLUMN_EMITTERS, type)) {
|
|
121
|
+
throw new Error(`[Kumiko] unhandled field type: ${String(field)}`);
|
|
122
|
+
}
|
|
123
|
+
const emit = COLUMN_EMITTERS[type];
|
|
124
|
+
return emit(name, toSnakeCase(name), field, entity);
|
|
125
|
+
}
|
|
126
|
+
// Most fields return one column; money and locatedTimestamp return two.
|
|
127
|
+
function fieldToColumns(name, field, entity) {
|
|
128
|
+
return emitFieldColumns(field.type, name, field, entity);
|
|
182
129
|
}
|
|
183
130
|
// Accepts both camelCase (`tenantMembership`) and kebab-case (`tenant-membership`)
|
|
184
131
|
// entity / field names. Implementation lives in utils/case — re-exported here
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TableLike } from "./query.ts";
|
|
2
|
+
export type TableInfo = {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly columnOf: (field: string) => string;
|
|
5
|
+
readonly pgTypeOf: (column: string) => string | undefined;
|
|
6
|
+
readonly bigintJsModeOf: (column: string) => "number" | "bigint" | undefined;
|
|
7
|
+
readonly fieldOf: (column: string) => string;
|
|
8
|
+
readonly hasColumn: (fieldOrColumn: string) => boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare function extractTableInfo(table: TableLike): TableInfo;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { camelCase as envCamelCase } from "../env/index.js";
|
|
2
|
+
import { toSnakeCase } from "./table-builder.js";
|
|
3
|
+
import { asEntityTableMeta } from "./table-introspection.js";
|
|
4
|
+
// Idempotent snake_case → camelCase. `env.camelCase` always lowercases first
|
|
5
|
+
// (designed for SHOUT_CASE input) — for already-camelCase keys (mock rows
|
|
6
|
+
// in tests, projection-aliased columns) it would silently produce "tenantid"
|
|
7
|
+
// instead of leaving "tenantId" alone. Guard with an underscore check so
|
|
8
|
+
// the conversion only fires when the key is actually snake-shaped.
|
|
9
|
+
function snakeToCamel(key) {
|
|
10
|
+
if (!key.includes("_"))
|
|
11
|
+
return key;
|
|
12
|
+
return envCamelCase(key);
|
|
13
|
+
}
|
|
14
|
+
export function extractTableInfo(table) {
|
|
15
|
+
const meta = asEntityTableMeta(table);
|
|
16
|
+
if (!meta) {
|
|
17
|
+
throw new Error("extractTableInfo: table is not a kumiko EntityTableMeta — " +
|
|
18
|
+
"build it via buildEntityTable / deriveEntityTableMeta / table().");
|
|
19
|
+
}
|
|
20
|
+
const colByField = new Map();
|
|
21
|
+
const fieldByCol = new Map();
|
|
22
|
+
const typeByCol = new Map();
|
|
23
|
+
const bigintModeByCol = new Map();
|
|
24
|
+
for (const c of meta.columns) {
|
|
25
|
+
typeByCol.set(c.name, c.pgType);
|
|
26
|
+
if (c.bigintJsMode !== undefined)
|
|
27
|
+
bigintModeByCol.set(c.name, c.bigintJsMode);
|
|
28
|
+
// EntityTableMeta column names are snake_case. Map snake → snake AND
|
|
29
|
+
// derive a camelCase JS field-name so result rows can be renamed back
|
|
30
|
+
// to the API shape (`aggregate_id` → `aggregateId`).
|
|
31
|
+
colByField.set(c.name, c.name);
|
|
32
|
+
const camel = snakeToCamel(c.name);
|
|
33
|
+
if (camel !== c.name)
|
|
34
|
+
colByField.set(camel, c.name);
|
|
35
|
+
fieldByCol.set(c.name, camel === c.name ? c.name : camel);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
name: meta.tableName,
|
|
39
|
+
columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
|
|
40
|
+
pgTypeOf: (col) => typeByCol.get(col),
|
|
41
|
+
bigintJsModeOf: (col) => bigintModeByCol.get(col),
|
|
42
|
+
fieldOf: (col) => fieldByCol.get(col) ?? snakeToCamel(col),
|
|
43
|
+
hasColumn: (fieldOrColumn) => colByField.has(fieldOrColumn) || fieldByCol.has(fieldOrColumn),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
|
+
export declare function isTemporalInstant(v: unknown): v is Temporal.Instant;
|
|
3
|
+
export declare function isTemporalPlainDate(v: unknown): v is Temporal.PlainDate;
|
|
4
|
+
export declare function isTemporalPlainDateTimeLike(v: unknown): v is {
|
|
5
|
+
toPlainDate(): Temporal.PlainDate;
|
|
6
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
export function isTemporalInstant(v) {
|
|
3
|
+
return propertyOf(v, Symbol.toStringTag) === "Temporal.Instant";
|
|
4
|
+
}
|
|
5
|
+
export function isTemporalPlainDate(v) {
|
|
6
|
+
return propertyOf(v, Symbol.toStringTag) === "Temporal.PlainDate";
|
|
7
|
+
}
|
|
8
|
+
export function isTemporalPlainDateTimeLike(v) {
|
|
9
|
+
const tag = propertyOf(v, Symbol.toStringTag);
|
|
10
|
+
return ((tag === "Temporal.PlainDateTime" || tag === "Temporal.ZonedDateTime") &&
|
|
11
|
+
typeof v === "object" &&
|
|
12
|
+
v !== null &&
|
|
13
|
+
"toPlainDate" in v &&
|
|
14
|
+
typeof v.toPlainDate === "function");
|
|
15
|
+
}
|
|
@@ -6,5 +6,6 @@ export declare function isDbRunner(value: unknown): value is DbRunner;
|
|
|
6
6
|
export declare function bindTenantDbRunner(tenantDb: TenantDb, runner: DbRunner): void;
|
|
7
7
|
export declare function tenantDbRunner(tenantDb: TenantDb): DbRunner;
|
|
8
8
|
export declare function denyTenantDbWrites(tenantDb: TenantDb, reason: string): void;
|
|
9
|
+
export declare function tenantDbWritesDeniedReason(tenantDb: TenantDb): string | undefined;
|
|
9
10
|
export declare function tenantDbWritesDeniedError(operation: string, reason: string): InternalError;
|
|
10
11
|
export declare function tenantDbWriteRunner(tenantDb: TenantDb, operation: string): DbRunner;
|
|
@@ -26,6 +26,9 @@ const writesDeniedReasons = new WeakMap();
|
|
|
26
26
|
export function denyTenantDbWrites(tenantDb, reason) {
|
|
27
27
|
writesDeniedReasons.set(tenantDb, reason);
|
|
28
28
|
}
|
|
29
|
+
export function tenantDbWritesDeniedReason(tenantDb) {
|
|
30
|
+
return writesDeniedReasons.get(tenantDb);
|
|
31
|
+
}
|
|
29
32
|
export function tenantDbWritesDeniedError(operation, reason) {
|
|
30
33
|
return new InternalError({
|
|
31
34
|
message: `${operation}: writes are not available ${reason}; run the write through ctx.write so it gets its own transaction`,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TenantDbMode, TenantDbUpsertOptions } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
2
|
+
import { AccessDeniedError } from "../errors/index.ts";
|
|
3
|
+
import { type AnyDb, type TableLike } from "./query.ts";
|
|
4
|
+
export declare function upsertTableDenied(table: TableLike, mode: TenantDbMode): AccessDeniedError | undefined;
|
|
5
|
+
export declare function upsertTenantRow<TRow>(db: AnyDb, table: TableLike, values: Record<string, unknown>, options: TenantDbUpsertOptions): Promise<TRow | undefined>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { quoteIdent } from "../crypto/ciphertext-pattern.js";
|
|
2
|
+
import { AccessDeniedError } from "../errors/index.js";
|
|
3
|
+
import { isExecutorManagedTable } from "./executor-managed-tables.js";
|
|
4
|
+
import { buildInsertSql, coerceRow, conflictUpdateParts, insertEntries, unsafeLoggingClosedConnection, } from "./query.js";
|
|
5
|
+
import { extractTableInfo } from "./table-info.js";
|
|
6
|
+
import { asEntityTableMeta, tableNameOf } from "./table-introspection.js";
|
|
7
|
+
const TENANT_COLUMN = "tenant_id";
|
|
8
|
+
// Upsert is never allowed on event-fed tables (even with the write capability) and has no tenant-mode meaning for "global" ones.
|
|
9
|
+
export function upsertTableDenied(table, mode) {
|
|
10
|
+
if (isExecutorManagedTable(table)) {
|
|
11
|
+
return new AccessDeniedError({
|
|
12
|
+
message: `ctx.db.upsert(${tableNameOf(table)}): table is fed by events; upsert is not supported on it.`,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
if (mode === "tenant" && asEntityTableMeta(table)?.tenancy === "global") {
|
|
16
|
+
return new AccessDeniedError({
|
|
17
|
+
message: `ctx.db.upsert(${tableNameOf(table)}): "global" table rows must carry SYSTEM_TENANT_ID; ` +
|
|
18
|
+
"use db.global(table) with escapeHatch instead.",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
function sameColumnSet(a, b) {
|
|
24
|
+
return a.length === b.length && new Set(a).size === a.length && a.every((c) => b.includes(c));
|
|
25
|
+
}
|
|
26
|
+
function declaredUniqueColumnSets(meta) {
|
|
27
|
+
const sets = meta.indexes
|
|
28
|
+
.filter((i) => i.unique === true && i.whereSql === undefined && i.needsManualWhere !== true)
|
|
29
|
+
.map((i) => i.columns);
|
|
30
|
+
if (meta.compositePrimaryKey)
|
|
31
|
+
sets.push(meta.compositePrimaryKey.columns);
|
|
32
|
+
const primaryKeyColumns = meta.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
33
|
+
if (primaryKeyColumns.length > 0)
|
|
34
|
+
sets.push(primaryKeyColumns);
|
|
35
|
+
return sets;
|
|
36
|
+
}
|
|
37
|
+
function primaryKeyColumnsOf(meta) {
|
|
38
|
+
return [
|
|
39
|
+
...meta.columns.filter((c) => c.primaryKey).map((c) => c.name),
|
|
40
|
+
...(meta.compositePrimaryKey?.columns ?? []),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
function assertTenantSafeConflictTarget(tableName, meta, conflictCols) {
|
|
44
|
+
if (conflictCols.length === 0 || !conflictCols.includes(TENANT_COLUMN)) {
|
|
45
|
+
throw new Error(`tenantDb.upsert(${tableName}): conflictTarget must include "tenantId" — a target without it ` +
|
|
46
|
+
"could match, and overwrite, another tenant's row.");
|
|
47
|
+
}
|
|
48
|
+
if (!declaredUniqueColumnSets(meta).some((set) => sameColumnSet(set, conflictCols))) {
|
|
49
|
+
throw new Error(`tenantDb.upsert(${tableName}): conflictTarget (${conflictCols.join(", ")}) matches no ` +
|
|
50
|
+
"declared unique index (without a WHERE clause) or primary key.");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Handles a tenant's own row only: values carry the tenant already, and the DO UPDATE guard
|
|
54
|
+
// compares against the tenant being inserted, so a conflict on another tenant's row updates nothing.
|
|
55
|
+
export async function upsertTenantRow(db, table, values, options) {
|
|
56
|
+
const info = extractTableInfo(table);
|
|
57
|
+
const meta = asEntityTableMeta(table);
|
|
58
|
+
if (!info.hasColumn("tenantId")) {
|
|
59
|
+
throw new Error(`tenantDb.upsert(${info.name}): table has no tenant column — use the free upsertOnConflict ` +
|
|
60
|
+
"helper on an unsafeRaw runner instead.");
|
|
61
|
+
}
|
|
62
|
+
if (!meta) {
|
|
63
|
+
throw new Error(`tenantDb.upsert(${info.name}): needs an EntityTableMeta table (declared unique indexes and primary key), got a table without meta.`);
|
|
64
|
+
}
|
|
65
|
+
const conflictCols = options.conflictTarget.map((field) => info.columnOf(field));
|
|
66
|
+
assertTenantSafeConflictTarget(info.name, meta, conflictCols);
|
|
67
|
+
if (options.update &&
|
|
68
|
+
Object.keys(options.update).some((f) => info.columnOf(f) === TENANT_COLUMN)) {
|
|
69
|
+
throw new Error(`tenantDb.upsert(${info.name}): update must not set "tenantId" — it would move the row into another tenant.`);
|
|
70
|
+
}
|
|
71
|
+
const entries = insertEntries(info, values);
|
|
72
|
+
if (!entries.some((e) => e.col === TENANT_COLUMN)) {
|
|
73
|
+
throw new Error(`tenantDb.upsert(${info.name}): values carry no tenantId.`);
|
|
74
|
+
}
|
|
75
|
+
const { sqlPrefix, params } = buildInsertSql(info, entries);
|
|
76
|
+
const skipCols = new Set([...conflictCols, ...primaryKeyColumnsOf(meta), TENANT_COLUMN]);
|
|
77
|
+
const setParts = conflictUpdateParts(info, entries, options.update, skipCols, params);
|
|
78
|
+
if (setParts.length === 0) {
|
|
79
|
+
throw new Error(`tenantDb.upsert(${info.name}): nothing to update on conflict`);
|
|
80
|
+
}
|
|
81
|
+
const tenantRef = quoteIdent(TENANT_COLUMN);
|
|
82
|
+
const sqlText = `${sqlPrefix} ON CONFLICT (${conflictCols.map((c) => quoteIdent(c)).join(", ")}) ` +
|
|
83
|
+
`DO UPDATE SET ${setParts.join(", ")} ` +
|
|
84
|
+
`WHERE ${quoteIdent(info.name)}.${tenantRef} = EXCLUDED.${tenantRef} RETURNING *`;
|
|
85
|
+
const rows = await unsafeLoggingClosedConnection(db, sqlText, params, {
|
|
86
|
+
operation: "tenantDb.upsert",
|
|
87
|
+
table: info.name,
|
|
88
|
+
});
|
|
89
|
+
const first = rows[0];
|
|
90
|
+
return first ? coerceRow(first, info) : undefined;
|
|
91
|
+
}
|