@cosmicdrift/kumiko-framework 0.1.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/README.md +159 -0
- package/package.json +91 -0
- package/src/__tests__/anonymous-access.integration.ts +325 -0
- package/src/__tests__/error-contract.integration.ts +435 -0
- package/src/__tests__/field-access.integration.ts +269 -0
- package/src/__tests__/full-stack.integration.ts +914 -0
- package/src/__tests__/ownership.integration.ts +449 -0
- package/src/__tests__/reference-data.integration.ts +198 -0
- package/src/__tests__/transition-guard.integration.ts +340 -0
- package/src/api/__tests__/api.test.ts +337 -0
- package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
- package/src/api/__tests__/batch.integration.ts +404 -0
- package/src/api/__tests__/body-limit.test.ts +88 -0
- package/src/api/__tests__/csrf-middleware.test.ts +97 -0
- package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
- package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
- package/src/api/__tests__/nested-write.integration.ts +213 -0
- package/src/api/__tests__/readiness.test.ts +76 -0
- package/src/api/__tests__/request-id-middleware.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +58 -0
- package/src/api/__tests__/sse-route.test.ts +112 -0
- package/src/api/anonymous-cookie.ts +60 -0
- package/src/api/api-constants.ts +64 -0
- package/src/api/auth-middleware.ts +418 -0
- package/src/api/auth-routes.ts +982 -0
- package/src/api/csrf-middleware.ts +77 -0
- package/src/api/index.ts +31 -0
- package/src/api/jwt.ts +66 -0
- package/src/api/observability-middleware.ts +89 -0
- package/src/api/readiness.ts +132 -0
- package/src/api/request-context.ts +49 -0
- package/src/api/request-id-middleware.ts +50 -0
- package/src/api/route-registrars.ts +195 -0
- package/src/api/routes.ts +135 -0
- package/src/api/server.ts +640 -0
- package/src/api/sse-broker.ts +71 -0
- package/src/api/sse-route.ts +62 -0
- package/src/api/tokens.ts +16 -0
- package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
- package/src/db/__tests__/compound-types.test.ts +114 -0
- package/src/db/__tests__/connection-options.test.ts +68 -0
- package/src/db/__tests__/cursor.test.ts +41 -0
- package/src/db/__tests__/db-helpers.test.ts +369 -0
- package/src/db/__tests__/dialect-instant.test.ts +50 -0
- package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
- package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
- package/src/db/__tests__/encryption.test.ts +39 -0
- package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
- package/src/db/__tests__/event-store-executor.integration.ts +235 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
- package/src/db/__tests__/located-timestamp.test.ts +184 -0
- package/src/db/__tests__/money.test.ts +199 -0
- package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
- package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
- package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
- package/src/db/__tests__/row-helpers.test.ts +59 -0
- package/src/db/__tests__/schema-migration.integration.ts +273 -0
- package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
- package/src/db/__tests__/table-builder-required.test.ts +216 -0
- package/src/db/__tests__/tenant-db.integration.ts +606 -0
- package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
- package/src/db/apply-entity-event.ts +188 -0
- package/src/db/assert-exists-in.ts +59 -0
- package/src/db/compound-types.ts +47 -0
- package/src/db/connection.ts +104 -0
- package/src/db/cursor.ts +83 -0
- package/src/db/dialect.ts +109 -0
- package/src/db/eagerload.ts +174 -0
- package/src/db/encryption.ts +39 -0
- package/src/db/event-store-executor.ts +906 -0
- package/src/db/index.ts +55 -0
- package/src/db/located-timestamp.ts +114 -0
- package/src/db/money.ts +120 -0
- package/src/db/pg-error.ts +46 -0
- package/src/db/reference-data.ts +77 -0
- package/src/db/row-helpers.ts +53 -0
- package/src/db/schema-inspection.ts +25 -0
- package/src/db/table-builder.ts +475 -0
- package/src/db/tenant-db.ts +434 -0
- package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
- package/src/engine/__tests__/boot-validator.test.ts +1865 -0
- package/src/engine/__tests__/build-app-schema.test.ts +154 -0
- package/src/engine/__tests__/claim-keys.test.ts +274 -0
- package/src/engine/__tests__/config-helpers.test.ts +236 -0
- package/src/engine/__tests__/effective-features.test.ts +86 -0
- package/src/engine/__tests__/engine.test.ts +1461 -0
- package/src/engine/__tests__/entity-handlers.test.ts +274 -0
- package/src/engine/__tests__/event-helpers.test.ts +68 -0
- package/src/engine/__tests__/extends-registrar.test.ts +159 -0
- package/src/engine/__tests__/factories-long-text.test.ts +84 -0
- package/src/engine/__tests__/factories-time.test.ts +158 -0
- package/src/engine/__tests__/field-predicates.test.ts +48 -0
- package/src/engine/__tests__/hook-phases.test.ts +132 -0
- package/src/engine/__tests__/identifiers.test.ts +35 -0
- package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
- package/src/engine/__tests__/nav.test.ts +267 -0
- package/src/engine/__tests__/ownership.test.ts +421 -0
- package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
- package/src/engine/__tests__/projection-helpers.test.ts +62 -0
- package/src/engine/__tests__/projection.test.ts +191 -0
- package/src/engine/__tests__/qualified-name.test.ts +264 -0
- package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
- package/src/engine/__tests__/run-in.test.ts +38 -0
- package/src/engine/__tests__/schema-builder.test.ts +380 -0
- package/src/engine/__tests__/screen.test.ts +408 -0
- package/src/engine/__tests__/state-machine.test.ts +148 -0
- package/src/engine/__tests__/system-user.test.ts +57 -0
- package/src/engine/__tests__/validation-hooks.test.ts +71 -0
- package/src/engine/access.ts +23 -0
- package/src/engine/boot-validator.ts +1528 -0
- package/src/engine/build-app-schema.ts +125 -0
- package/src/engine/config-helpers.ts +115 -0
- package/src/engine/constants.ts +85 -0
- package/src/engine/create-app.ts +98 -0
- package/src/engine/define-feature.ts +702 -0
- package/src/engine/define-handler.ts +78 -0
- package/src/engine/define-roles.ts +19 -0
- package/src/engine/effective-features.ts +87 -0
- package/src/engine/entity-handlers.ts +364 -0
- package/src/engine/event-helpers.ts +73 -0
- package/src/engine/factories.ts +328 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
- package/src/engine/feature-ast/extractors.ts +2562 -0
- package/src/engine/feature-ast/index.ts +105 -0
- package/src/engine/feature-ast/parse.ts +369 -0
- package/src/engine/feature-ast/patch.ts +525 -0
- package/src/engine/feature-ast/patcher.ts +518 -0
- package/src/engine/feature-ast/patterns.ts +434 -0
- package/src/engine/feature-ast/render.ts +602 -0
- package/src/engine/feature-ast/source-location.ts +45 -0
- package/src/engine/field-access.ts +120 -0
- package/src/engine/index.ts +254 -0
- package/src/engine/ownership.ts +337 -0
- package/src/engine/parse-ref-target.ts +22 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
- package/src/engine/pattern-library/index.ts +24 -0
- package/src/engine/pattern-library/library.ts +1117 -0
- package/src/engine/pattern-library/types.ts +255 -0
- package/src/engine/projection-helpers.ts +85 -0
- package/src/engine/qualified-name.ts +122 -0
- package/src/engine/read-claim.ts +31 -0
- package/src/engine/registry.ts +1325 -0
- package/src/engine/resolve-config-or-param.ts +153 -0
- package/src/engine/run-in.ts +29 -0
- package/src/engine/schema-builder.ts +175 -0
- package/src/engine/screen-filter-ops.ts +51 -0
- package/src/engine/state-machine.ts +70 -0
- package/src/engine/system-user.ts +32 -0
- package/src/engine/types/config.ts +306 -0
- package/src/engine/types/event-type-map.ts +37 -0
- package/src/engine/types/feature.ts +574 -0
- package/src/engine/types/fields.ts +422 -0
- package/src/engine/types/handlers.ts +742 -0
- package/src/engine/types/hooks.ts +142 -0
- package/src/engine/types/http-route.ts +54 -0
- package/src/engine/types/identifiers.ts +47 -0
- package/src/engine/types/index.ts +208 -0
- package/src/engine/types/nav.ts +46 -0
- package/src/engine/types/projection.ts +132 -0
- package/src/engine/types/relations.ts +51 -0
- package/src/engine/types/screen.ts +452 -0
- package/src/engine/types/workspace.ts +42 -0
- package/src/engine/validation.ts +33 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
- package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
- package/src/entrypoint/index.ts +442 -0
- package/src/errors/__tests__/classes.test.ts +371 -0
- package/src/errors/__tests__/write-failures.test.ts +109 -0
- package/src/errors/classes.ts +249 -0
- package/src/errors/i18n/de.yaml +83 -0
- package/src/errors/i18n/en.yaml +80 -0
- package/src/errors/index.ts +41 -0
- package/src/errors/kumiko-error.ts +67 -0
- package/src/errors/reasons.ts +36 -0
- package/src/errors/serialize.ts +136 -0
- package/src/errors/transition-details.ts +30 -0
- package/src/errors/write-error-info.ts +123 -0
- package/src/errors/zod-bridge.ts +49 -0
- package/src/event-store/__tests__/admin-api.integration.ts +361 -0
- package/src/event-store/__tests__/event-store.integration.ts +584 -0
- package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
- package/src/event-store/__tests__/perf.integration.ts +255 -0
- package/src/event-store/__tests__/snapshot.integration.ts +267 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
- package/src/event-store/__tests__/upcaster.integration.ts +460 -0
- package/src/event-store/admin-api.ts +257 -0
- package/src/event-store/archive.ts +106 -0
- package/src/event-store/errors.ts +35 -0
- package/src/event-store/event-store.ts +405 -0
- package/src/event-store/events-schema.ts +90 -0
- package/src/event-store/index.ts +50 -0
- package/src/event-store/snapshot.ts +210 -0
- package/src/event-store/upcaster-dead-letter.ts +119 -0
- package/src/event-store/upcaster.ts +147 -0
- package/src/files/__tests__/content-disposition.test.ts +123 -0
- package/src/files/__tests__/file-field-column.integration.ts +103 -0
- package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
- package/src/files/__tests__/file-handle.test.ts +122 -0
- package/src/files/__tests__/files.integration.ts +830 -0
- package/src/files/__tests__/storage-tracking.integration.ts +153 -0
- package/src/files/content-disposition.ts +55 -0
- package/src/files/file-handle.ts +63 -0
- package/src/files/file-ref-table.ts +22 -0
- package/src/files/file-routes.ts +353 -0
- package/src/files/in-memory-provider.ts +62 -0
- package/src/files/index.ts +29 -0
- package/src/files/local-provider.ts +35 -0
- package/src/files/storage-tracking.ts +60 -0
- package/src/files/types.ts +118 -0
- package/src/i18n/__tests__/i18n.test.ts +72 -0
- package/src/i18n/index.ts +29 -0
- package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
- package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
- package/src/jobs/__tests__/jobs.integration.ts +566 -0
- package/src/jobs/index.ts +2 -0
- package/src/jobs/job-runner.ts +574 -0
- package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
- package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
- package/src/lifecycle/index.ts +13 -0
- package/src/lifecycle/lifecycle.ts +160 -0
- package/src/lifecycle/signal-handlers.ts +62 -0
- package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/pino-logger.ts +64 -0
- package/src/logging/types.ts +7 -0
- package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
- package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
- package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
- package/src/migrations/index.ts +28 -0
- package/src/migrations/projection-detection.ts +149 -0
- package/src/migrations/rebuild-marker.ts +64 -0
- package/src/migrations/schema-drift.ts +395 -0
- package/src/observability/__tests__/console-provider.test.ts +67 -0
- package/src/observability/__tests__/metric-validator.test.ts +87 -0
- package/src/observability/__tests__/noop-provider.test.ts +82 -0
- package/src/observability/__tests__/observability.integration.ts +559 -0
- package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
- package/src/observability/__tests__/recording-meter.test.ts +101 -0
- package/src/observability/__tests__/recording-tracer.test.ts +110 -0
- package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
- package/src/observability/console-provider.ts +130 -0
- package/src/observability/context.ts +26 -0
- package/src/observability/fallback.ts +34 -0
- package/src/observability/ids.ts +25 -0
- package/src/observability/index.ts +79 -0
- package/src/observability/metric-validator.ts +86 -0
- package/src/observability/metrics-handle.ts +56 -0
- package/src/observability/noop-provider.ts +146 -0
- package/src/observability/prometheus-meter.ts +284 -0
- package/src/observability/recording-meter.ts +156 -0
- package/src/observability/recording-tracer.ts +198 -0
- package/src/observability/redis-wrapper.ts +132 -0
- package/src/observability/sensitive-filter.ts +108 -0
- package/src/observability/standard-metrics.ts +213 -0
- package/src/observability/types/index.ts +29 -0
- package/src/observability/types/metric.ts +56 -0
- package/src/observability/types/provider.ts +32 -0
- package/src/observability/types/span.ts +64 -0
- package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
- package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
- package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
- package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
- package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
- package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
- package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
- package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
- package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
- package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
- package/src/pipeline/append-event-core.ts +117 -0
- package/src/pipeline/auth-claims-resolver.ts +103 -0
- package/src/pipeline/cascade-handler.ts +113 -0
- package/src/pipeline/dispatcher.ts +1585 -0
- package/src/pipeline/distributed-lock.ts +37 -0
- package/src/pipeline/entity-cache.ts +113 -0
- package/src/pipeline/event-consumer-state.ts +108 -0
- package/src/pipeline/event-dedup.ts +23 -0
- package/src/pipeline/event-dispatcher.ts +1016 -0
- package/src/pipeline/event-retention.ts +154 -0
- package/src/pipeline/idempotency.ts +76 -0
- package/src/pipeline/index.ts +66 -0
- package/src/pipeline/lifecycle-pipeline.ts +409 -0
- package/src/pipeline/msp-rebuild.ts +242 -0
- package/src/pipeline/multi-stream-apply-context.ts +115 -0
- package/src/pipeline/projection-rebuild.ts +334 -0
- package/src/pipeline/projection-state.ts +72 -0
- package/src/pipeline/projections-runner.ts +56 -0
- package/src/pipeline/redis-keys.ts +11 -0
- package/src/pipeline/system-hooks.ts +190 -0
- package/src/random/__tests__/generate.test.ts +149 -0
- package/src/random/generate.ts +141 -0
- package/src/random/index.ts +8 -0
- package/src/random/words.ts +392 -0
- package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
- package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
- package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
- package/src/rate-limit/bucket.ts +36 -0
- package/src/rate-limit/index.ts +14 -0
- package/src/rate-limit/middleware.ts +152 -0
- package/src/rate-limit/resolver.ts +267 -0
- package/src/redis/__tests__/redis-options.test.ts +54 -0
- package/src/redis/index.ts +74 -0
- package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
- package/src/search/__tests__/search-adapter.test.ts +256 -0
- package/src/search/in-memory-adapter.ts +123 -0
- package/src/search/index.ts +12 -0
- package/src/search/meilisearch-adapter.ts +106 -0
- package/src/search/types.ts +39 -0
- package/src/secrets/__tests__/dek-cache.test.ts +213 -0
- package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
- package/src/secrets/__tests__/envelope.test.ts +74 -0
- package/src/secrets/__tests__/leak-guard.test.ts +92 -0
- package/src/secrets/__tests__/rotation.test.ts +149 -0
- package/src/secrets/dek-cache.ts +116 -0
- package/src/secrets/env-master-key-provider.ts +162 -0
- package/src/secrets/envelope.ts +55 -0
- package/src/secrets/index.ts +19 -0
- package/src/secrets/leak-guard.ts +87 -0
- package/src/secrets/rotation.ts +34 -0
- package/src/secrets/types.ts +107 -0
- package/src/stack/db.ts +104 -0
- package/src/stack/event-collector.ts +23 -0
- package/src/stack/index.ts +32 -0
- package/src/stack/redis.ts +44 -0
- package/src/stack/request-helper.ts +168 -0
- package/src/stack/table-helpers.ts +104 -0
- package/src/stack/test-stack.ts +357 -0
- package/src/stack/test-users.ts +37 -0
- package/src/testing/__tests__/e2e-generator.test.ts +230 -0
- package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
- package/src/testing/access-assertions.ts +15 -0
- package/src/testing/assertions.ts +35 -0
- package/src/testing/e2e-generator.ts +465 -0
- package/src/testing/expect-error.ts +25 -0
- package/src/testing/handler-context.ts +125 -0
- package/src/testing/http-cookies.ts +52 -0
- package/src/testing/index.ts +41 -0
- package/src/testing/late-bound.ts +39 -0
- package/src/testing/mutable-master-key-provider.ts +31 -0
- package/src/testing/observability-recorder.ts +54 -0
- package/src/testing/shared-entities.ts +49 -0
- package/src/testing/utils.ts +1 -0
- package/src/testing/wait-for.ts +31 -0
- package/src/time/__tests__/polyfill.test.ts +73 -0
- package/src/time/__tests__/tz-context.test.ts +121 -0
- package/src/time/index.ts +21 -0
- package/src/time/polyfill.ts +70 -0
- package/src/time/tz-context.ts +107 -0
- package/src/ui-types/app-schema.ts +57 -0
- package/src/ui-types/index.ts +65 -0
- package/src/utils/__tests__/assert.test.ts +17 -0
- package/src/utils/__tests__/env-parse.test.ts +54 -0
- package/src/utils/assert.ts +18 -0
- package/src/utils/env-parse.ts +16 -0
- package/src/utils/ids.ts +16 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/safe-json.ts +30 -0
- package/src/utils/serialization.ts +7 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
3
|
+
import { createBooleanField, createEntity, createTextField } from "../../engine";
|
|
4
|
+
import {
|
|
5
|
+
createEntityTable,
|
|
6
|
+
createTestDb,
|
|
7
|
+
pushTables,
|
|
8
|
+
type TestDb,
|
|
9
|
+
TestUsers,
|
|
10
|
+
testTenantId,
|
|
11
|
+
} from "../../stack";
|
|
12
|
+
import { table as pgTable, serial, text, timestamp } from "../dialect";
|
|
13
|
+
import { buildDrizzleTable } from "../table-builder";
|
|
14
|
+
import { createTenantDb } from "../tenant-db";
|
|
15
|
+
|
|
16
|
+
// --- Entity table (has tenantId via buildBaseColumns) ---
|
|
17
|
+
|
|
18
|
+
const entity = createEntity({
|
|
19
|
+
table: "tenant_db_items",
|
|
20
|
+
fields: {
|
|
21
|
+
name: createTextField({ required: true }),
|
|
22
|
+
status: createTextField({ default: "draft" }),
|
|
23
|
+
isActive: createBooleanField({ default: true }),
|
|
24
|
+
},
|
|
25
|
+
softDelete: true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const table = buildDrizzleTable("tenantDbItem", entity);
|
|
29
|
+
|
|
30
|
+
// --- System table (no tenantId — like job_runs) ---
|
|
31
|
+
|
|
32
|
+
const systemTable = pgTable("tdb_system_entries", {
|
|
33
|
+
id: serial("id").primaryKey(),
|
|
34
|
+
label: text("label").notNull(),
|
|
35
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let testDb: TestDb;
|
|
39
|
+
const tenant1 = TestUsers.admin; // tenantId: 1
|
|
40
|
+
const tenant2 = TestUsers.otherTenant; // tenantId: 2
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
testDb = await createTestDb();
|
|
44
|
+
await createEntityTable(testDb.db, entity, "tenantDbItem");
|
|
45
|
+
await pushTables(testDb.db, { tdb_system_entries: systemTable });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await testDb.cleanup();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// =============================================================================
|
|
53
|
+
// MODE 1: Scoped (default) — tenant filter on reads, tenantId forced on insert
|
|
54
|
+
// =============================================================================
|
|
55
|
+
|
|
56
|
+
describe("scoped mode (default)", () => {
|
|
57
|
+
describe("insert", () => {
|
|
58
|
+
test("auto-injects tenantId into values", async () => {
|
|
59
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
60
|
+
|
|
61
|
+
const rows = await tdb.insert(table).values({ name: "Item 1" }).returning();
|
|
62
|
+
expect(rows[0]?.["tenantId"]).toBe(testTenantId(1));
|
|
63
|
+
expect(rows[0]?.["name"]).toBe("Item 1");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("cannot override tenantId via values", async () => {
|
|
67
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
68
|
+
|
|
69
|
+
const rows = await tdb
|
|
70
|
+
.insert(table)
|
|
71
|
+
.values({ name: "Sneaky", tenantId: testTenantId(999) })
|
|
72
|
+
.returning();
|
|
73
|
+
expect(rows[0]?.["tenantId"]).toBe(testTenantId(1));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("select", () => {
|
|
78
|
+
test("only returns rows for own tenant", async () => {
|
|
79
|
+
const tdb1 = createTenantDb(testDb.db, tenant1.tenantId);
|
|
80
|
+
const tdb2 = createTenantDb(testDb.db, tenant2.tenantId);
|
|
81
|
+
|
|
82
|
+
await tdb1.insert(table).values({ name: "T1 Scoped" }).returning();
|
|
83
|
+
await tdb2.insert(table).values({ name: "T2 Scoped" }).returning();
|
|
84
|
+
|
|
85
|
+
const rows1 = await tdb1.select().from(table);
|
|
86
|
+
const rows2 = await tdb2.select().from(table);
|
|
87
|
+
|
|
88
|
+
expect(rows1.every((r) => r!["tenantId"] === testTenantId(1))).toBe(true);
|
|
89
|
+
expect(rows2.every((r) => r!["tenantId"] === testTenantId(2))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("additional where conditions combine with tenant filter", async () => {
|
|
93
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
94
|
+
|
|
95
|
+
await tdb.insert(table).values({ name: "findme", status: "active" }).returning();
|
|
96
|
+
await tdb.insert(table).values({ name: "notme", status: "draft" }).returning();
|
|
97
|
+
|
|
98
|
+
const rows = await tdb.select().from(table).where(eq(table["status"], "active"));
|
|
99
|
+
|
|
100
|
+
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
101
|
+
expect(
|
|
102
|
+
rows.every((r) => r!["tenantId"] === testTenantId(1) && r!["status"] === "active"),
|
|
103
|
+
).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("select with columns", async () => {
|
|
107
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
108
|
+
|
|
109
|
+
await tdb.insert(table).values({ name: "ColSelect" }).returning();
|
|
110
|
+
|
|
111
|
+
const rows = await tdb
|
|
112
|
+
.select({ id: table["id"], name: table["name"] })
|
|
113
|
+
.from(table)
|
|
114
|
+
.where(eq(table["name"], "ColSelect"));
|
|
115
|
+
|
|
116
|
+
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
117
|
+
const row = rows[0]!;
|
|
118
|
+
expect(row["name"]).toBe("ColSelect");
|
|
119
|
+
expect(row["id"]).toBeDefined();
|
|
120
|
+
expect(row["status"]).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("select with limit", async () => {
|
|
124
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < 5; i++) {
|
|
127
|
+
await tdb
|
|
128
|
+
.insert(table)
|
|
129
|
+
.values({ name: `Limit${i}` })
|
|
130
|
+
.returning();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const rows = await tdb.select().from(table).limit(2);
|
|
134
|
+
expect(rows).toHaveLength(2);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("update", () => {
|
|
139
|
+
test("only updates rows for own tenant", async () => {
|
|
140
|
+
const tdb1 = createTenantDb(testDb.db, tenant1.tenantId);
|
|
141
|
+
const tdb2 = createTenantDb(testDb.db, tenant2.tenantId);
|
|
142
|
+
|
|
143
|
+
const [row] = await tdb1.insert(table).values({ name: "T1 Update" }).returning();
|
|
144
|
+
const id = row!["id"] as string;
|
|
145
|
+
|
|
146
|
+
const result = await tdb2
|
|
147
|
+
.update(table)
|
|
148
|
+
.set({ name: "Hacked" })
|
|
149
|
+
.where(eq(table["id"], id))
|
|
150
|
+
.returning();
|
|
151
|
+
|
|
152
|
+
expect(result).toHaveLength(0);
|
|
153
|
+
|
|
154
|
+
const [updated] = await tdb1
|
|
155
|
+
.update(table)
|
|
156
|
+
.set({ name: "Updated" })
|
|
157
|
+
.where(eq(table["id"], id))
|
|
158
|
+
.returning();
|
|
159
|
+
|
|
160
|
+
expect(updated!["name"]).toBe("Updated");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("update without returning", async () => {
|
|
164
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
165
|
+
|
|
166
|
+
const [row] = await tdb.insert(table).values({ name: "NoReturn" }).returning();
|
|
167
|
+
const id = row!["id"] as string;
|
|
168
|
+
|
|
169
|
+
await tdb.update(table).set({ name: "NoReturnUpdated" }).where(eq(table["id"], id));
|
|
170
|
+
|
|
171
|
+
const [updated] = await tdb.select().from(table).where(eq(table["id"], id));
|
|
172
|
+
expect(updated!["name"]).toBe("NoReturnUpdated");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("delete", () => {
|
|
177
|
+
test("only deletes rows for own tenant", async () => {
|
|
178
|
+
const tdb1 = createTenantDb(testDb.db, tenant1.tenantId);
|
|
179
|
+
const tdb2 = createTenantDb(testDb.db, tenant2.tenantId);
|
|
180
|
+
|
|
181
|
+
const [row] = await tdb1.insert(table).values({ name: "T1 Delete" }).returning();
|
|
182
|
+
const id = row!["id"] as string;
|
|
183
|
+
|
|
184
|
+
await tdb2.delete(table).where(eq(table["id"], id));
|
|
185
|
+
|
|
186
|
+
const rows = await tdb1.select().from(table).where(eq(table["id"], id));
|
|
187
|
+
expect(rows).toHaveLength(1);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("cross-tenant isolation", () => {
|
|
192
|
+
test("tenant cannot see, update, or delete other tenant data", async () => {
|
|
193
|
+
const tdb1 = createTenantDb(testDb.db, tenant1.tenantId);
|
|
194
|
+
const tdb2 = createTenantDb(testDb.db, tenant2.tenantId);
|
|
195
|
+
|
|
196
|
+
const [created] = await tdb1.insert(table).values({ name: "Secret" }).returning();
|
|
197
|
+
const id = created!["id"] as string;
|
|
198
|
+
|
|
199
|
+
const seen = await tdb2.select().from(table).where(eq(table["id"], id));
|
|
200
|
+
expect(seen).toHaveLength(0);
|
|
201
|
+
|
|
202
|
+
const updated = await tdb2
|
|
203
|
+
.update(table)
|
|
204
|
+
.set({ name: "Hacked" })
|
|
205
|
+
.where(eq(table["id"], id))
|
|
206
|
+
.returning();
|
|
207
|
+
expect(updated).toHaveLength(0);
|
|
208
|
+
|
|
209
|
+
await tdb2.delete(table).where(eq(table["id"], id));
|
|
210
|
+
const stillThere = await tdb1.select().from(table).where(eq(table["id"], id));
|
|
211
|
+
expect(stillThere).toHaveLength(1);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("reference data (tenantId = 0)", () => {
|
|
216
|
+
test("scoped select includes rows with tenantId = 0", async () => {
|
|
217
|
+
// Seed reference data with tenantId = 0 (like seedReferenceData does)
|
|
218
|
+
await testDb.db.insert(table).values({
|
|
219
|
+
name: "GlobalRef",
|
|
220
|
+
status: "ref",
|
|
221
|
+
tenantId: "00000000-0000-4000-8000-000000000000",
|
|
222
|
+
version: 1,
|
|
223
|
+
insertedAt: Temporal.Now.instant(),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const tdb1 = createTenantDb(testDb.db, tenant1.tenantId);
|
|
227
|
+
const tdb2 = createTenantDb(testDb.db, tenant2.tenantId);
|
|
228
|
+
|
|
229
|
+
// Both tenants can see the global reference row
|
|
230
|
+
const rows1 = await tdb1.select().from(table).where(eq(table["name"], "GlobalRef"));
|
|
231
|
+
expect(rows1).toHaveLength(1);
|
|
232
|
+
|
|
233
|
+
const rows2 = await tdb2.select().from(table).where(eq(table["name"], "GlobalRef"));
|
|
234
|
+
expect(rows2).toHaveLength(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("scoped update does NOT affect tenantId = 0 rows", async () => {
|
|
238
|
+
await testDb.db.insert(table).values({
|
|
239
|
+
name: "RefNoUpdate",
|
|
240
|
+
status: "ref",
|
|
241
|
+
tenantId: "00000000-0000-4000-8000-000000000000",
|
|
242
|
+
version: 1,
|
|
243
|
+
insertedAt: Temporal.Now.instant(),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const tdb1 = createTenantDb(testDb.db, tenant1.tenantId);
|
|
247
|
+
|
|
248
|
+
const result = await tdb1
|
|
249
|
+
.update(table)
|
|
250
|
+
.set({ name: "Hacked" })
|
|
251
|
+
.where(eq(table["name"], "RefNoUpdate"))
|
|
252
|
+
.returning();
|
|
253
|
+
|
|
254
|
+
// Writes from a tenant scope must never touch reference rows (tenantId = 0).
|
|
255
|
+
// Reading them is fine, modifying them is a cross-tenant integrity bug.
|
|
256
|
+
expect(result).toHaveLength(0);
|
|
257
|
+
|
|
258
|
+
const [untouched] = await testDb.db
|
|
259
|
+
.select()
|
|
260
|
+
.from(table)
|
|
261
|
+
.where(eq(table["name"], "RefNoUpdate"));
|
|
262
|
+
expect(untouched!["name"]).toBe("RefNoUpdate");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("scoped delete does NOT affect tenantId = 0 rows", async () => {
|
|
266
|
+
await testDb.db.insert(table).values({
|
|
267
|
+
name: "RefNoDelete",
|
|
268
|
+
status: "ref",
|
|
269
|
+
tenantId: "00000000-0000-4000-8000-000000000000",
|
|
270
|
+
version: 1,
|
|
271
|
+
insertedAt: Temporal.Now.instant(),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const tdb1 = createTenantDb(testDb.db, tenant1.tenantId);
|
|
275
|
+
await tdb1.delete(table).where(eq(table["name"], "RefNoDelete"));
|
|
276
|
+
|
|
277
|
+
const [stillThere] = await testDb.db
|
|
278
|
+
.select()
|
|
279
|
+
.from(table)
|
|
280
|
+
.where(eq(table["name"], "RefNoDelete"));
|
|
281
|
+
expect(stillThere).toBeDefined();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// =============================================================================
|
|
287
|
+
// MODE 2: System (r.systemScope()) — no tenant filter, tenantId as default
|
|
288
|
+
// =============================================================================
|
|
289
|
+
|
|
290
|
+
describe("system mode (r.systemScope())", () => {
|
|
291
|
+
test("select returns rows from ALL tenants", async () => {
|
|
292
|
+
const scoped1 = createTenantDb(testDb.db, tenant1.tenantId);
|
|
293
|
+
const scoped2 = createTenantDb(testDb.db, tenant2.tenantId);
|
|
294
|
+
|
|
295
|
+
await scoped1.insert(table).values({ name: "System-T1" }).returning();
|
|
296
|
+
await scoped2.insert(table).values({ name: "System-T2" }).returning();
|
|
297
|
+
|
|
298
|
+
const systemDb = createTenantDb(testDb.db, tenant1.tenantId, "system");
|
|
299
|
+
const rows = await systemDb.select().from(table);
|
|
300
|
+
|
|
301
|
+
const tenantIds = new Set(rows.map((r) => r!["tenantId"]));
|
|
302
|
+
// Must see rows from at least 2 different tenants
|
|
303
|
+
expect(tenantIds.size).toBeGreaterThanOrEqual(2);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("insert uses tenantId as default but handler can override", async () => {
|
|
307
|
+
const systemDb = createTenantDb(testDb.db, tenant1.tenantId, "system");
|
|
308
|
+
|
|
309
|
+
// Without explicit tenantId — uses the default (tenant1)
|
|
310
|
+
const [defaultRow] = await systemDb.insert(table).values({ name: "SystemDefault" }).returning();
|
|
311
|
+
expect(defaultRow!["tenantId"]).toBe(testTenantId(1));
|
|
312
|
+
|
|
313
|
+
// With explicit tenantId — handler's value wins
|
|
314
|
+
const [overrideRow] = await systemDb
|
|
315
|
+
.insert(table)
|
|
316
|
+
.values({ name: "SystemOverride", tenantId: testTenantId(99) })
|
|
317
|
+
.returning();
|
|
318
|
+
expect(overrideRow!["tenantId"]).toBe(testTenantId(99));
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("insert with tenantId null (system config pattern)", async () => {
|
|
322
|
+
// Config feature sets tenantId = null for system-scoped values
|
|
323
|
+
// This requires the column to allow NULL — using systemTable which has no tenantId col,
|
|
324
|
+
// but we can test the spread order logic directly:
|
|
325
|
+
const systemDb = createTenantDb(testDb.db, tenant1.tenantId, "system");
|
|
326
|
+
|
|
327
|
+
// In scoped mode, tenantId: 77 would be overridden to 1
|
|
328
|
+
const scopedDb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
329
|
+
const [scopedRow] = await scopedDb
|
|
330
|
+
.insert(table)
|
|
331
|
+
.values({ name: "ScopedForce", tenantId: testTenantId(77) })
|
|
332
|
+
.returning();
|
|
333
|
+
expect(scopedRow!["tenantId"]).toBe(testTenantId(1)); // forced
|
|
334
|
+
|
|
335
|
+
// In unscoped mode, explicit tenantId wins
|
|
336
|
+
const [unscopedRow] = await systemDb
|
|
337
|
+
.insert(table)
|
|
338
|
+
.values({ name: "SystemExplicit", tenantId: testTenantId(77) })
|
|
339
|
+
.returning();
|
|
340
|
+
expect(unscopedRow!["tenantId"]).toBe(testTenantId(77)); // handler wins
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("update affects rows from any tenant", async () => {
|
|
344
|
+
const scoped2 = createTenantDb(testDb.db, tenant2.tenantId);
|
|
345
|
+
const [row] = await scoped2.insert(table).values({ name: "T2-System-Upd" }).returning();
|
|
346
|
+
const id = row!["id"] as string;
|
|
347
|
+
|
|
348
|
+
// Scoped tenant 1 cannot update tenant 2's row
|
|
349
|
+
const scoped1 = createTenantDb(testDb.db, tenant1.tenantId);
|
|
350
|
+
const scopedResult = await scoped1
|
|
351
|
+
.update(table)
|
|
352
|
+
.set({ name: "ScopedFail" })
|
|
353
|
+
.where(eq(table["id"], id))
|
|
354
|
+
.returning();
|
|
355
|
+
expect(scopedResult).toHaveLength(0);
|
|
356
|
+
|
|
357
|
+
// Unscoped CAN update tenant 2's row
|
|
358
|
+
const systemDb = createTenantDb(testDb.db, tenant1.tenantId, "system");
|
|
359
|
+
const [updated] = await systemDb
|
|
360
|
+
.update(table)
|
|
361
|
+
.set({ name: "SystemWin" })
|
|
362
|
+
.where(eq(table["id"], id))
|
|
363
|
+
.returning();
|
|
364
|
+
expect(updated!["name"]).toBe("SystemWin");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("delete affects rows from any tenant", async () => {
|
|
368
|
+
const scoped2 = createTenantDb(testDb.db, tenant2.tenantId);
|
|
369
|
+
const [row] = await scoped2.insert(table).values({ name: "T2-System-Del" }).returning();
|
|
370
|
+
const id = row!["id"] as string;
|
|
371
|
+
|
|
372
|
+
// Unscoped can delete tenant 2's row from tenant 1 context
|
|
373
|
+
const systemDb = createTenantDb(testDb.db, tenant1.tenantId, "system");
|
|
374
|
+
await systemDb.delete(table).where(eq(table["id"], id));
|
|
375
|
+
|
|
376
|
+
// Verify it's gone
|
|
377
|
+
const remaining = await scoped2.select().from(table).where(eq(table["id"], id));
|
|
378
|
+
expect(remaining).toHaveLength(0);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// =============================================================================
|
|
383
|
+
// MODE 3: Tables without tenantId column — no filter, no injection
|
|
384
|
+
// =============================================================================
|
|
385
|
+
|
|
386
|
+
describe("tables without tenantId column", () => {
|
|
387
|
+
test("select returns all rows (no tenant filter)", async () => {
|
|
388
|
+
// Insert two rows via raw db
|
|
389
|
+
await testDb.db.insert(systemTable).values({ label: "System-A" });
|
|
390
|
+
await testDb.db.insert(systemTable).values({ label: "System-B" });
|
|
391
|
+
|
|
392
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
393
|
+
const rows = await tdb.select().from(systemTable);
|
|
394
|
+
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("insert does not inject tenantId", async () => {
|
|
398
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
399
|
+
|
|
400
|
+
const [row] = await tdb.insert(systemTable).values({ label: "NoTenantInjection" }).returning();
|
|
401
|
+
const data = row!;
|
|
402
|
+
expect(data["label"]).toBe("NoTenantInjection");
|
|
403
|
+
// No tenantId column at all — should not be in the result
|
|
404
|
+
expect(data["tenantId"]).toBeUndefined();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("select with where works without tenant filter", async () => {
|
|
408
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
409
|
+
|
|
410
|
+
await tdb.insert(systemTable).values({ label: "FindThis" }).returning();
|
|
411
|
+
|
|
412
|
+
const rows = await tdb.select().from(systemTable).where(eq(systemTable["label"], "FindThis"));
|
|
413
|
+
expect(rows).toHaveLength(1);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("update works without tenant filter", async () => {
|
|
417
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
418
|
+
|
|
419
|
+
const [row] = await tdb.insert(systemTable).values({ label: "BeforeUpd" }).returning();
|
|
420
|
+
const id = row!["id"] as number;
|
|
421
|
+
|
|
422
|
+
await tdb.update(systemTable).set({ label: "AfterUpd" }).where(eq(systemTable["id"], id));
|
|
423
|
+
|
|
424
|
+
const [updated] = await tdb.select().from(systemTable).where(eq(systemTable["id"], id));
|
|
425
|
+
expect(updated!["label"]).toBe("AfterUpd");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("delete works without tenant filter", async () => {
|
|
429
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
430
|
+
|
|
431
|
+
const [row] = await tdb.insert(systemTable).values({ label: "ToDelete" }).returning();
|
|
432
|
+
const id = row!["id"] as number;
|
|
433
|
+
|
|
434
|
+
await tdb.delete(systemTable).where(eq(systemTable["id"], id));
|
|
435
|
+
|
|
436
|
+
const remaining = await tdb.select().from(systemTable).where(eq(systemTable["id"], id));
|
|
437
|
+
expect(remaining).toHaveLength(0);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// =============================================================================
|
|
442
|
+
// Misc
|
|
443
|
+
// =============================================================================
|
|
444
|
+
|
|
445
|
+
describe("tenantId property", () => {
|
|
446
|
+
test("exposes tenantId for use in cursor queries etc.", () => {
|
|
447
|
+
const tdb = createTenantDb(testDb.db, testTenantId(42));
|
|
448
|
+
expect(tdb.tenantId).toBe(testTenantId(42));
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// =============================================================================
|
|
453
|
+
// Mass-update guard — update().set() without .where() must refuse.
|
|
454
|
+
// =============================================================================
|
|
455
|
+
//
|
|
456
|
+
// Rationale: without the guard, a handler that forgets the WHERE clause would
|
|
457
|
+
// overwrite every row for the current tenant. Drizzle itself doesn't flag this
|
|
458
|
+
// (plain SQL behaviour); TenantDb is the layer where we can notice and stop it.
|
|
459
|
+
|
|
460
|
+
describe("mass-update guard", () => {
|
|
461
|
+
test(".set().returning() without .where() rejects with a clear error", async () => {
|
|
462
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
463
|
+
await tdb.insert(table).values({ name: "MassUpdateVictim1" }).returning();
|
|
464
|
+
await tdb.insert(table).values({ name: "MassUpdateVictim2" }).returning();
|
|
465
|
+
|
|
466
|
+
await expect(tdb.update(table).set({ name: "Wiped" }).returning()).rejects.toThrow(
|
|
467
|
+
/without \.where\(\) would mass-update/,
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
// Rows must be untouched — the rejection happened before any SQL ran.
|
|
471
|
+
const untouched = await tdb.select().from(table);
|
|
472
|
+
const touched = untouched.filter((r) => r["name"] === "Wiped");
|
|
473
|
+
expect(touched).toHaveLength(0);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test(".set() awaited without .where() rejects too", async () => {
|
|
477
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
478
|
+
await tdb.insert(table).values({ name: "AwaitGuardVictim" }).returning();
|
|
479
|
+
|
|
480
|
+
const promise = tdb.update(table).set({ name: "WipedByAwait" }) as unknown as Promise<void>;
|
|
481
|
+
await expect(promise).rejects.toThrow(/awaited without \.where\(\) would mass-update/);
|
|
482
|
+
|
|
483
|
+
const untouched = await tdb.select().from(table);
|
|
484
|
+
const touched = untouched.filter((r) => r["name"] === "WipedByAwait");
|
|
485
|
+
expect(touched).toHaveLength(0);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test(".set().where(...).returning() still works (guard only triggers on missing where)", async () => {
|
|
489
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
490
|
+
const [row] = await tdb.insert(table).values({ name: "HappyPath" }).returning();
|
|
491
|
+
const id = row!["id"] as string;
|
|
492
|
+
|
|
493
|
+
const updated = await tdb
|
|
494
|
+
.update(table)
|
|
495
|
+
.set({ name: "HappyPathUpdated" })
|
|
496
|
+
.where(eq(table["id"], id))
|
|
497
|
+
.returning();
|
|
498
|
+
expect(updated[0]!["name"]).toBe("HappyPathUpdated");
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
describe("pre-flight signal cancellation", () => {
|
|
503
|
+
test("aborted signal: select throws AbortError before SQL is issued", async () => {
|
|
504
|
+
const controller = new AbortController();
|
|
505
|
+
controller.abort();
|
|
506
|
+
const tdb = createTenantDb(
|
|
507
|
+
testDb.db,
|
|
508
|
+
tenant1.tenantId,
|
|
509
|
+
"tenant",
|
|
510
|
+
undefined,
|
|
511
|
+
undefined,
|
|
512
|
+
controller.signal,
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
let thrown: unknown;
|
|
516
|
+
try {
|
|
517
|
+
await tdb.select().from(table);
|
|
518
|
+
} catch (e) {
|
|
519
|
+
thrown = e;
|
|
520
|
+
}
|
|
521
|
+
expect((thrown as Error).name).toBe("AbortError");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("aborted signal: insert/update/delete all throw AbortError", async () => {
|
|
525
|
+
const controller = new AbortController();
|
|
526
|
+
controller.abort();
|
|
527
|
+
const tdb = createTenantDb(
|
|
528
|
+
testDb.db,
|
|
529
|
+
tenant1.tenantId,
|
|
530
|
+
"tenant",
|
|
531
|
+
undefined,
|
|
532
|
+
undefined,
|
|
533
|
+
controller.signal,
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
let insertThrown: unknown;
|
|
537
|
+
try {
|
|
538
|
+
await tdb.insert(table).values({ name: "x" }).returning();
|
|
539
|
+
} catch (e) {
|
|
540
|
+
insertThrown = e;
|
|
541
|
+
}
|
|
542
|
+
expect((insertThrown as Error).name).toBe("AbortError");
|
|
543
|
+
|
|
544
|
+
let updateThrown: unknown;
|
|
545
|
+
try {
|
|
546
|
+
await tdb
|
|
547
|
+
.update(table)
|
|
548
|
+
.set({ name: "y" })
|
|
549
|
+
.where(eq(table["id"], "00000000-0000-0000-0000-000000000001"));
|
|
550
|
+
} catch (e) {
|
|
551
|
+
updateThrown = e;
|
|
552
|
+
}
|
|
553
|
+
expect((updateThrown as Error).name).toBe("AbortError");
|
|
554
|
+
|
|
555
|
+
let deleteThrown: unknown;
|
|
556
|
+
try {
|
|
557
|
+
await tdb.delete(table).where(eq(table["id"], "00000000-0000-0000-0000-000000000001"));
|
|
558
|
+
} catch (e) {
|
|
559
|
+
deleteThrown = e;
|
|
560
|
+
}
|
|
561
|
+
expect((deleteThrown as Error).name).toBe("AbortError");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("mid-chain abort: first query succeeds, abort, next query throws", async () => {
|
|
565
|
+
// Simulates a handler doing N sequential queries where the client
|
|
566
|
+
// disconnects after query 1. Without the pre-flight check, queries
|
|
567
|
+
// 2..N would all execute and waste DB-CPU. With it, the chain stops
|
|
568
|
+
// immediately.
|
|
569
|
+
const controller = new AbortController();
|
|
570
|
+
const tdb = createTenantDb(
|
|
571
|
+
testDb.db,
|
|
572
|
+
tenant1.tenantId,
|
|
573
|
+
"tenant",
|
|
574
|
+
undefined,
|
|
575
|
+
undefined,
|
|
576
|
+
controller.signal,
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
const [first] = await tdb.insert(table).values({ name: "preflight-first" }).returning();
|
|
580
|
+
expect(first).toBeDefined();
|
|
581
|
+
|
|
582
|
+
controller.abort();
|
|
583
|
+
|
|
584
|
+
let secondThrown: unknown;
|
|
585
|
+
try {
|
|
586
|
+
await tdb.insert(table).values({ name: "preflight-second" }).returning();
|
|
587
|
+
} catch (e) {
|
|
588
|
+
secondThrown = e;
|
|
589
|
+
}
|
|
590
|
+
expect((secondThrown as Error).name).toBe("AbortError");
|
|
591
|
+
|
|
592
|
+
// Proves the first row was actually committed and the second never
|
|
593
|
+
// made it — the abort prevented future work, didn't roll back done
|
|
594
|
+
// work.
|
|
595
|
+
const rows = await testDb.db.select().from(table);
|
|
596
|
+
const names = rows.map((r) => r["name"] as string);
|
|
597
|
+
expect(names).toContain("preflight-first");
|
|
598
|
+
expect(names).not.toContain("preflight-second");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("no signal passed: queries run normally (signal is opt-in)", async () => {
|
|
602
|
+
const tdb = createTenantDb(testDb.db, tenant1.tenantId);
|
|
603
|
+
const result = await tdb.insert(table).values({ name: "no-signal" }).returning();
|
|
604
|
+
expect(result).toHaveLength(1);
|
|
605
|
+
});
|
|
606
|
+
});
|