@dotdo/postgres 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 +868 -0
- package/dist/cdc/change-stream.d.ts +44 -0
- package/dist/cdc/change-stream.d.ts.map +1 -0
- package/dist/cdc/change-stream.js +95 -0
- package/dist/cdc/change-stream.js.map +1 -0
- package/dist/cdc/filter.d.ts +58 -0
- package/dist/cdc/filter.d.ts.map +1 -0
- package/dist/cdc/filter.js +520 -0
- package/dist/cdc/filter.js.map +1 -0
- package/dist/cdc/index.d.ts +47 -0
- package/dist/cdc/index.d.ts.map +1 -0
- package/dist/cdc/index.js +50 -0
- package/dist/cdc/index.js.map +1 -0
- package/dist/cdc/resume-token.d.ts +60 -0
- package/dist/cdc/resume-token.d.ts.map +1 -0
- package/dist/cdc/resume-token.js +228 -0
- package/dist/cdc/resume-token.js.map +1 -0
- package/dist/cdc/transport/index.d.ts +7 -0
- package/dist/cdc/transport/index.d.ts.map +1 -0
- package/dist/cdc/transport/index.js +7 -0
- package/dist/cdc/transport/index.js.map +1 -0
- package/dist/cdc/transport/sse.d.ts +120 -0
- package/dist/cdc/transport/sse.d.ts.map +1 -0
- package/dist/cdc/transport/sse.js +590 -0
- package/dist/cdc/transport/sse.js.map +1 -0
- package/dist/cdc/transport/websocket.d.ts +130 -0
- package/dist/cdc/transport/websocket.d.ts.map +1 -0
- package/dist/cdc/transport/websocket.js +688 -0
- package/dist/cdc/transport/websocket.js.map +1 -0
- package/dist/cdc/types.d.ts +306 -0
- package/dist/cdc/types.d.ts.map +1 -0
- package/dist/cdc/types.js +8 -0
- package/dist/cdc/types.js.map +1 -0
- package/dist/config/index.d.ts +25 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +25 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/memory.d.ts +139 -0
- package/dist/config/memory.d.ts.map +1 -0
- package/dist/config/memory.js +157 -0
- package/dist/config/memory.js.map +1 -0
- package/dist/config/storage.d.ts +157 -0
- package/dist/config/storage.d.ts.map +1 -0
- package/dist/config/storage.js +178 -0
- package/dist/config/storage.js.map +1 -0
- package/dist/config/streaming.d.ts +117 -0
- package/dist/config/streaming.d.ts.map +1 -0
- package/dist/config/streaming.js +132 -0
- package/dist/config/streaming.js.map +1 -0
- package/dist/config/timeouts.d.ts +168 -0
- package/dist/config/timeouts.d.ts.map +1 -0
- package/dist/config/timeouts.js +192 -0
- package/dist/config/timeouts.js.map +1 -0
- package/dist/extensions/config.d.ts +89 -0
- package/dist/extensions/config.d.ts.map +1 -0
- package/dist/extensions/config.js +216 -0
- package/dist/extensions/config.js.map +1 -0
- package/dist/extensions/geo.d.ts +452 -0
- package/dist/extensions/geo.d.ts.map +1 -0
- package/dist/extensions/geo.js +583 -0
- package/dist/extensions/geo.js.map +1 -0
- package/dist/extensions/index.d.ts +167 -0
- package/dist/extensions/index.d.ts.map +1 -0
- package/dist/extensions/index.js +99 -0
- package/dist/extensions/index.js.map +1 -0
- package/dist/extensions/loader.d.ts +226 -0
- package/dist/extensions/loader.d.ts.map +1 -0
- package/dist/extensions/loader.js +456 -0
- package/dist/extensions/loader.js.map +1 -0
- package/dist/extensions/pgmq-lite.d.ts +330 -0
- package/dist/extensions/pgmq-lite.d.ts.map +1 -0
- package/dist/extensions/pgmq-lite.js +648 -0
- package/dist/extensions/pgmq-lite.js.map +1 -0
- package/dist/extensions/plugins.d.ts +260 -0
- package/dist/extensions/plugins.d.ts.map +1 -0
- package/dist/extensions/plugins.js +535 -0
- package/dist/extensions/plugins.js.map +1 -0
- package/dist/extensions/registry.d.ts +93 -0
- package/dist/extensions/registry.d.ts.map +1 -0
- package/dist/extensions/registry.js +182 -0
- package/dist/extensions/registry.js.map +1 -0
- package/dist/extensions/vector.d.ts +106 -0
- package/dist/extensions/vector.d.ts.map +1 -0
- package/dist/extensions/vector.js +129 -0
- package/dist/extensions/vector.js.map +1 -0
- package/dist/iceberg/analytics.d.ts +279 -0
- package/dist/iceberg/analytics.d.ts.map +1 -0
- package/dist/iceberg/analytics.js +448 -0
- package/dist/iceberg/analytics.js.map +1 -0
- package/dist/iceberg/catalog-api.d.ts +39 -0
- package/dist/iceberg/catalog-api.d.ts.map +1 -0
- package/dist/iceberg/catalog-api.js +388 -0
- package/dist/iceberg/catalog-api.js.map +1 -0
- package/dist/iceberg/catalog.d.ts +401 -0
- package/dist/iceberg/catalog.d.ts.map +1 -0
- package/dist/iceberg/catalog.js +677 -0
- package/dist/iceberg/catalog.js.map +1 -0
- package/dist/iceberg/duckdb-wasm.d.ts +447 -0
- package/dist/iceberg/duckdb-wasm.d.ts.map +1 -0
- package/dist/iceberg/duckdb-wasm.js +600 -0
- package/dist/iceberg/duckdb-wasm.js.map +1 -0
- package/dist/iceberg/index.d.ts +92 -0
- package/dist/iceberg/index.d.ts.map +1 -0
- package/dist/iceberg/index.js +119 -0
- package/dist/iceberg/index.js.map +1 -0
- package/dist/iceberg/metadata.d.ts +214 -0
- package/dist/iceberg/metadata.d.ts.map +1 -0
- package/dist/iceberg/metadata.js +535 -0
- package/dist/iceberg/metadata.js.map +1 -0
- package/dist/iceberg/optimizer.d.ts +296 -0
- package/dist/iceberg/optimizer.d.ts.map +1 -0
- package/dist/iceberg/optimizer.js +889 -0
- package/dist/iceberg/optimizer.js.map +1 -0
- package/dist/iceberg/parquet.d.ts +447 -0
- package/dist/iceberg/parquet.d.ts.map +1 -0
- package/dist/iceberg/parquet.js +1225 -0
- package/dist/iceberg/parquet.js.map +1 -0
- package/dist/iceberg/r2-organization.d.ts +422 -0
- package/dist/iceberg/r2-organization.d.ts.map +1 -0
- package/dist/iceberg/r2-organization.js +672 -0
- package/dist/iceberg/r2-organization.js.map +1 -0
- package/dist/iceberg/scheduler-do-example.d.ts +158 -0
- package/dist/iceberg/scheduler-do-example.d.ts.map +1 -0
- package/dist/iceberg/scheduler-do-example.js +261 -0
- package/dist/iceberg/scheduler-do-example.js.map +1 -0
- package/dist/iceberg/scheduler.d.ts +434 -0
- package/dist/iceberg/scheduler.d.ts.map +1 -0
- package/dist/iceberg/scheduler.js +818 -0
- package/dist/iceberg/scheduler.js.map +1 -0
- package/dist/iceberg/schema.d.ts +149 -0
- package/dist/iceberg/schema.d.ts.map +1 -0
- package/dist/iceberg/schema.js +525 -0
- package/dist/iceberg/schema.js.map +1 -0
- package/dist/iceberg/snapshot-manager.d.ts +406 -0
- package/dist/iceberg/snapshot-manager.d.ts.map +1 -0
- package/dist/iceberg/snapshot-manager.js +934 -0
- package/dist/iceberg/snapshot-manager.js.map +1 -0
- package/dist/iceberg/sql-router.d.ts +194 -0
- package/dist/iceberg/sql-router.d.ts.map +1 -0
- package/dist/iceberg/sql-router.js +180 -0
- package/dist/iceberg/sql-router.js.map +1 -0
- package/dist/iceberg/test-fixtures.d.ts +151 -0
- package/dist/iceberg/test-fixtures.d.ts.map +1 -0
- package/dist/iceberg/test-fixtures.js +446 -0
- package/dist/iceberg/test-fixtures.js.map +1 -0
- package/dist/iceberg/time-travel-api.d.ts +102 -0
- package/dist/iceberg/time-travel-api.d.ts.map +1 -0
- package/dist/iceberg/time-travel-api.js +437 -0
- package/dist/iceberg/time-travel-api.js.map +1 -0
- package/dist/iceberg/time-travel.d.ts +293 -0
- package/dist/iceberg/time-travel.d.ts.map +1 -0
- package/dist/iceberg/time-travel.js +689 -0
- package/dist/iceberg/time-travel.js.map +1 -0
- package/dist/iceberg/transformer.d.ts +356 -0
- package/dist/iceberg/transformer.d.ts.map +1 -0
- package/dist/iceberg/transformer.js +770 -0
- package/dist/iceberg/transformer.js.map +1 -0
- package/dist/iceberg/types.d.ts +318 -0
- package/dist/iceberg/types.d.ts.map +1 -0
- package/dist/iceberg/types.js +9 -0
- package/dist/iceberg/types.js.map +1 -0
- package/dist/iceberg/writer.d.ts +144 -0
- package/dist/iceberg/writer.d.ts.map +1 -0
- package/dist/iceberg/writer.js +452 -0
- package/dist/iceberg/writer.js.map +1 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/lineage/index.d.ts +11 -0
- package/dist/lineage/index.d.ts.map +1 -0
- package/dist/lineage/index.js +11 -0
- package/dist/lineage/index.js.map +1 -0
- package/dist/lineage/integration.d.ts +134 -0
- package/dist/lineage/integration.d.ts.map +1 -0
- package/dist/lineage/integration.js +258 -0
- package/dist/lineage/integration.js.map +1 -0
- package/dist/lineage/tracker.d.ts +189 -0
- package/dist/lineage/tracker.d.ts.map +1 -0
- package/dist/lineage/tracker.js +1352 -0
- package/dist/lineage/tracker.js.map +1 -0
- package/dist/lineage/types.d.ts +318 -0
- package/dist/lineage/types.d.ts.map +1 -0
- package/dist/lineage/types.js +9 -0
- package/dist/lineage/types.js.map +1 -0
- package/dist/middleware/index.d.ts +11 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +16 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/middleware/rate-limit.d.ts +397 -0
- package/dist/middleware/rate-limit.d.ts.map +1 -0
- package/dist/middleware/rate-limit.js +507 -0
- package/dist/middleware/rate-limit.js.map +1 -0
- package/dist/migration-tooling/external-migration.d.ts +601 -0
- package/dist/migration-tooling/external-migration.d.ts.map +1 -0
- package/dist/migration-tooling/external-migration.js +1612 -0
- package/dist/migration-tooling/external-migration.js.map +1 -0
- package/dist/migration-tooling/index.d.ts +19 -0
- package/dist/migration-tooling/index.d.ts.map +1 -0
- package/dist/migration-tooling/index.js +19 -0
- package/dist/migration-tooling/index.js.map +1 -0
- package/dist/migrations/auto-migrator.d.ts +289 -0
- package/dist/migrations/auto-migrator.d.ts.map +1 -0
- package/dist/migrations/auto-migrator.js +396 -0
- package/dist/migrations/auto-migrator.js.map +1 -0
- package/dist/migrations/bulk-orchestrator.d.ts +403 -0
- package/dist/migrations/bulk-orchestrator.d.ts.map +1 -0
- package/dist/migrations/bulk-orchestrator.js +646 -0
- package/dist/migrations/bulk-orchestrator.js.map +1 -0
- package/dist/migrations/compatibility.d.ts +216 -0
- package/dist/migrations/compatibility.d.ts.map +1 -0
- package/dist/migrations/compatibility.js +651 -0
- package/dist/migrations/compatibility.js.map +1 -0
- package/dist/migrations/do-migrations.d.ts +101 -0
- package/dist/migrations/do-migrations.d.ts.map +1 -0
- package/dist/migrations/do-migrations.js +1060 -0
- package/dist/migrations/do-migrations.js.map +1 -0
- package/dist/migrations/do-migrations.types.d.ts +550 -0
- package/dist/migrations/do-migrations.types.d.ts.map +1 -0
- package/dist/migrations/do-migrations.types.js +15 -0
- package/dist/migrations/do-migrations.types.js.map +1 -0
- package/dist/migrations/drizzle-compat.d.ts +163 -0
- package/dist/migrations/drizzle-compat.d.ts.map +1 -0
- package/dist/migrations/drizzle-compat.js +273 -0
- package/dist/migrations/drizzle-compat.js.map +1 -0
- package/dist/migrations/index.d.ts +109 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +127 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/migration-api.d.ts +161 -0
- package/dist/migrations/migration-api.d.ts.map +1 -0
- package/dist/migrations/migration-api.js +499 -0
- package/dist/migrations/migration-api.js.map +1 -0
- package/dist/migrations/progress-tracker-do.d.ts +195 -0
- package/dist/migrations/progress-tracker-do.d.ts.map +1 -0
- package/dist/migrations/progress-tracker-do.js +339 -0
- package/dist/migrations/progress-tracker-do.js.map +1 -0
- package/dist/migrations/progress-tracker-kv.d.ts +103 -0
- package/dist/migrations/progress-tracker-kv.d.ts.map +1 -0
- package/dist/migrations/progress-tracker-kv.js +231 -0
- package/dist/migrations/progress-tracker-kv.js.map +1 -0
- package/dist/migrations/progress-tracker.d.ts +320 -0
- package/dist/migrations/progress-tracker.d.ts.map +1 -0
- package/dist/migrations/progress-tracker.js +443 -0
- package/dist/migrations/progress-tracker.js.map +1 -0
- package/dist/migrations/registry.d.ts +231 -0
- package/dist/migrations/registry.d.ts.map +1 -0
- package/dist/migrations/registry.js +376 -0
- package/dist/migrations/registry.js.map +1 -0
- package/dist/migrations/runner.d.ts +197 -0
- package/dist/migrations/runner.d.ts.map +1 -0
- package/dist/migrations/runner.js +1167 -0
- package/dist/migrations/runner.js.map +1 -0
- package/dist/migrations/schema-generator.d.ts +111 -0
- package/dist/migrations/schema-generator.d.ts.map +1 -0
- package/dist/migrations/schema-generator.js +335 -0
- package/dist/migrations/schema-generator.js.map +1 -0
- package/dist/migrations/testing.d.ts +321 -0
- package/dist/migrations/testing.d.ts.map +1 -0
- package/dist/migrations/testing.js +645 -0
- package/dist/migrations/testing.js.map +1 -0
- package/dist/migrations/types.d.ts +503 -0
- package/dist/migrations/types.d.ts.map +1 -0
- package/dist/migrations/types.js +11 -0
- package/dist/migrations/types.js.map +1 -0
- package/dist/migrations/validator.d.ts +215 -0
- package/dist/migrations/validator.d.ts.map +1 -0
- package/dist/migrations/validator.js +494 -0
- package/dist/migrations/validator.js.map +1 -0
- package/dist/observability/alerting.d.ts +116 -0
- package/dist/observability/alerting.d.ts.map +1 -0
- package/dist/observability/alerting.js +353 -0
- package/dist/observability/alerting.js.map +1 -0
- package/dist/observability/analytics-engine.d.ts +357 -0
- package/dist/observability/analytics-engine.d.ts.map +1 -0
- package/dist/observability/analytics-engine.js +430 -0
- package/dist/observability/analytics-engine.js.map +1 -0
- package/dist/observability/cost-metrics.d.ts +269 -0
- package/dist/observability/cost-metrics.d.ts.map +1 -0
- package/dist/observability/cost-metrics.js +560 -0
- package/dist/observability/cost-metrics.js.map +1 -0
- package/dist/observability/cross-do-tracing.d.ts +305 -0
- package/dist/observability/cross-do-tracing.d.ts.map +1 -0
- package/dist/observability/cross-do-tracing.js +431 -0
- package/dist/observability/cross-do-tracing.js.map +1 -0
- package/dist/observability/error-rate-collector.d.ts +163 -0
- package/dist/observability/error-rate-collector.d.ts.map +1 -0
- package/dist/observability/error-rate-collector.js +306 -0
- package/dist/observability/error-rate-collector.js.map +1 -0
- package/dist/observability/exporters.d.ts +231 -0
- package/dist/observability/exporters.d.ts.map +1 -0
- package/dist/observability/exporters.js +479 -0
- package/dist/observability/exporters.js.map +1 -0
- package/dist/observability/health-check.d.ts +106 -0
- package/dist/observability/health-check.d.ts.map +1 -0
- package/dist/observability/health-check.js +243 -0
- package/dist/observability/health-check.js.map +1 -0
- package/dist/observability/index.d.ts +297 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +455 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/instrumentation.d.ts +222 -0
- package/dist/observability/instrumentation.d.ts.map +1 -0
- package/dist/observability/instrumentation.js +532 -0
- package/dist/observability/instrumentation.js.map +1 -0
- package/dist/observability/memory-metrics.d.ts +227 -0
- package/dist/observability/memory-metrics.d.ts.map +1 -0
- package/dist/observability/memory-metrics.js +688 -0
- package/dist/observability/memory-metrics.js.map +1 -0
- package/dist/observability/metrics-endpoint.d.ts +91 -0
- package/dist/observability/metrics-endpoint.d.ts.map +1 -0
- package/dist/observability/metrics-endpoint.js +246 -0
- package/dist/observability/metrics-endpoint.js.map +1 -0
- package/dist/observability/metrics.d.ts +88 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +253 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/observability/observability-features.d.ts +488 -0
- package/dist/observability/observability-features.d.ts.map +1 -0
- package/dist/observability/observability-features.js +773 -0
- package/dist/observability/observability-features.js.map +1 -0
- package/dist/observability/prometheus.d.ts +39 -0
- package/dist/observability/prometheus.d.ts.map +1 -0
- package/dist/observability/prometheus.js +120 -0
- package/dist/observability/prometheus.js.map +1 -0
- package/dist/observability/propagation.d.ts +126 -0
- package/dist/observability/propagation.d.ts.map +1 -0
- package/dist/observability/propagation.js +234 -0
- package/dist/observability/propagation.js.map +1 -0
- package/dist/observability/query-latency.d.ts +243 -0
- package/dist/observability/query-latency.d.ts.map +1 -0
- package/dist/observability/query-latency.js +292 -0
- package/dist/observability/query-latency.js.map +1 -0
- package/dist/observability/query-performance.d.ts +169 -0
- package/dist/observability/query-performance.d.ts.map +1 -0
- package/dist/observability/query-performance.js +290 -0
- package/dist/observability/query-performance.js.map +1 -0
- package/dist/observability/storage-tier-metrics.d.ts +174 -0
- package/dist/observability/storage-tier-metrics.d.ts.map +1 -0
- package/dist/observability/storage-tier-metrics.js +306 -0
- package/dist/observability/storage-tier-metrics.js.map +1 -0
- package/dist/observability/tier-cost-optimizer.d.ts +155 -0
- package/dist/observability/tier-cost-optimizer.d.ts.map +1 -0
- package/dist/observability/tier-cost-optimizer.js +536 -0
- package/dist/observability/tier-cost-optimizer.js.map +1 -0
- package/dist/observability/tracer.d.ts +149 -0
- package/dist/observability/tracer.d.ts.map +1 -0
- package/dist/observability/tracer.js +435 -0
- package/dist/observability/tracer.js.map +1 -0
- package/dist/observability/types.d.ts +402 -0
- package/dist/observability/types.d.ts.map +1 -0
- package/dist/observability/types.js +103 -0
- package/dist/observability/types.js.map +1 -0
- package/dist/pglite/workers-pglite.d.ts +138 -0
- package/dist/pglite/workers-pglite.d.ts.map +1 -0
- package/dist/pglite/workers-pglite.js +143 -0
- package/dist/pglite/workers-pglite.js.map +1 -0
- package/dist/pglite-assets/pglite.data +0 -0
- package/dist/pglite-assets/pglite.wasm +0 -0
- package/dist/playground/index.d.ts +52 -0
- package/dist/playground/index.d.ts.map +1 -0
- package/dist/playground/index.js +55 -0
- package/dist/playground/index.js.map +1 -0
- package/dist/playground/keyboard-shortcuts.d.ts +116 -0
- package/dist/playground/keyboard-shortcuts.d.ts.map +1 -0
- package/dist/playground/keyboard-shortcuts.js +588 -0
- package/dist/playground/keyboard-shortcuts.js.map +1 -0
- package/dist/playground/playground.d.ts +82 -0
- package/dist/playground/playground.d.ts.map +1 -0
- package/dist/playground/playground.js +271 -0
- package/dist/playground/playground.js.map +1 -0
- package/dist/playground/query-executor.d.ts +115 -0
- package/dist/playground/query-executor.d.ts.map +1 -0
- package/dist/playground/query-executor.js +558 -0
- package/dist/playground/query-executor.js.map +1 -0
- package/dist/playground/query-history.d.ts +92 -0
- package/dist/playground/query-history.d.ts.map +1 -0
- package/dist/playground/query-history.js +259 -0
- package/dist/playground/query-history.js.map +1 -0
- package/dist/playground/result-formatter.d.ts +59 -0
- package/dist/playground/result-formatter.d.ts.map +1 -0
- package/dist/playground/result-formatter.js +341 -0
- package/dist/playground/result-formatter.js.map +1 -0
- package/dist/playground/sample-datasets.d.ts +77 -0
- package/dist/playground/sample-datasets.d.ts.map +1 -0
- package/dist/playground/sample-datasets.js +641 -0
- package/dist/playground/sample-datasets.js.map +1 -0
- package/dist/playground/sample-queries.d.ts +73 -0
- package/dist/playground/sample-queries.d.ts.map +1 -0
- package/dist/playground/sample-queries.js +1095 -0
- package/dist/playground/sample-queries.js.map +1 -0
- package/dist/playground/schema-explorer.d.ts +55 -0
- package/dist/playground/schema-explorer.d.ts.map +1 -0
- package/dist/playground/schema-explorer.js +473 -0
- package/dist/playground/schema-explorer.js.map +1 -0
- package/dist/playground/types.d.ts +430 -0
- package/dist/playground/types.d.ts.map +1 -0
- package/dist/playground/types.js +10 -0
- package/dist/playground/types.js.map +1 -0
- package/dist/readonly/cache-reader.d.ts +145 -0
- package/dist/readonly/cache-reader.d.ts.map +1 -0
- package/dist/readonly/cache-reader.js +198 -0
- package/dist/readonly/cache-reader.js.map +1 -0
- package/dist/readonly/config.d.ts +74 -0
- package/dist/readonly/config.d.ts.map +1 -0
- package/dist/readonly/config.js +67 -0
- package/dist/readonly/config.js.map +1 -0
- package/dist/readonly/index.d.ts +22 -0
- package/dist/readonly/index.d.ts.map +1 -0
- package/dist/readonly/index.js +17 -0
- package/dist/readonly/index.js.map +1 -0
- package/dist/readonly/pglite-wrapper.d.ts +82 -0
- package/dist/readonly/pglite-wrapper.d.ts.map +1 -0
- package/dist/readonly/pglite-wrapper.js +123 -0
- package/dist/readonly/pglite-wrapper.js.map +1 -0
- package/dist/readonly/worker.d.ts +142 -0
- package/dist/readonly/worker.d.ts.map +1 -0
- package/dist/readonly/worker.js +187 -0
- package/dist/readonly/worker.js.map +1 -0
- package/dist/readonly/write-blocker.d.ts +47 -0
- package/dist/readonly/write-blocker.d.ts.map +1 -0
- package/dist/readonly/write-blocker.js +136 -0
- package/dist/readonly/write-blocker.js.map +1 -0
- package/dist/recovery/disaster-recovery.d.ts +326 -0
- package/dist/recovery/disaster-recovery.d.ts.map +1 -0
- package/dist/recovery/disaster-recovery.js +799 -0
- package/dist/recovery/disaster-recovery.js.map +1 -0
- package/dist/recovery/index.d.ts +12 -0
- package/dist/recovery/index.d.ts.map +1 -0
- package/dist/recovery/index.js +12 -0
- package/dist/recovery/index.js.map +1 -0
- package/dist/recovery/parquet-parser.d.ts +321 -0
- package/dist/recovery/parquet-parser.d.ts.map +1 -0
- package/dist/recovery/parquet-parser.js +797 -0
- package/dist/recovery/parquet-parser.js.map +1 -0
- package/dist/retention/index.d.ts +50 -0
- package/dist/retention/index.d.ts.map +1 -0
- package/dist/retention/index.js +50 -0
- package/dist/retention/index.js.map +1 -0
- package/dist/retention/policy.d.ts +344 -0
- package/dist/retention/policy.d.ts.map +1 -0
- package/dist/retention/policy.js +472 -0
- package/dist/retention/policy.js.map +1 -0
- package/dist/retention/purger.d.ts +187 -0
- package/dist/retention/purger.d.ts.map +1 -0
- package/dist/retention/purger.js +411 -0
- package/dist/retention/purger.js.map +1 -0
- package/dist/rls/auth-integration.d.ts +280 -0
- package/dist/rls/auth-integration.d.ts.map +1 -0
- package/dist/rls/auth-integration.js +399 -0
- package/dist/rls/auth-integration.js.map +1 -0
- package/dist/rls/generator.d.ts +249 -0
- package/dist/rls/generator.d.ts.map +1 -0
- package/dist/rls/generator.js +495 -0
- package/dist/rls/generator.js.map +1 -0
- package/dist/rls/index.d.ts +26 -0
- package/dist/rls/index.d.ts.map +1 -0
- package/dist/rls/index.js +58 -0
- package/dist/rls/index.js.map +1 -0
- package/dist/rls/policy.d.ts +116 -0
- package/dist/rls/policy.d.ts.map +1 -0
- package/dist/rls/policy.js +77 -0
- package/dist/rls/policy.js.map +1 -0
- package/dist/rls/validator.d.ts +155 -0
- package/dist/rls/validator.d.ts.map +1 -0
- package/dist/rls/validator.js +792 -0
- package/dist/rls/validator.js.map +1 -0
- package/dist/routing/adaptive-router.d.ts +317 -0
- package/dist/routing/adaptive-router.d.ts.map +1 -0
- package/dist/routing/adaptive-router.js +554 -0
- package/dist/routing/adaptive-router.js.map +1 -0
- package/dist/routing/circuit-breaker.d.ts +339 -0
- package/dist/routing/circuit-breaker.d.ts.map +1 -0
- package/dist/routing/circuit-breaker.js +620 -0
- package/dist/routing/circuit-breaker.js.map +1 -0
- package/dist/routing/cost-metrics.d.ts +133 -0
- package/dist/routing/cost-metrics.d.ts.map +1 -0
- package/dist/routing/cost-metrics.js +259 -0
- package/dist/routing/cost-metrics.js.map +1 -0
- package/dist/routing/do-connection-pool.d.ts +243 -0
- package/dist/routing/do-connection-pool.d.ts.map +1 -0
- package/dist/routing/do-connection-pool.js +572 -0
- package/dist/routing/do-connection-pool.js.map +1 -0
- package/dist/routing/index.d.ts +59 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +59 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/routing/query-complexity-estimator.d.ts +73 -0
- package/dist/routing/query-complexity-estimator.d.ts.map +1 -0
- package/dist/routing/query-complexity-estimator.js +327 -0
- package/dist/routing/query-complexity-estimator.js.map +1 -0
- package/dist/routing/request-coalescing.d.ts +178 -0
- package/dist/routing/request-coalescing.d.ts.map +1 -0
- package/dist/routing/request-coalescing.js +325 -0
- package/dist/routing/request-coalescing.js.map +1 -0
- package/dist/routing/runtime-router.d.ts +107 -0
- package/dist/routing/runtime-router.d.ts.map +1 -0
- package/dist/routing/runtime-router.js +246 -0
- package/dist/routing/runtime-router.js.map +1 -0
- package/dist/routing/tenant-router.d.ts +848 -0
- package/dist/routing/tenant-router.d.ts.map +1 -0
- package/dist/routing/tenant-router.js +1056 -0
- package/dist/routing/tenant-router.js.map +1 -0
- package/dist/routing/websocket-pool.d.ts +119 -0
- package/dist/routing/websocket-pool.d.ts.map +1 -0
- package/dist/routing/websocket-pool.js +436 -0
- package/dist/routing/websocket-pool.js.map +1 -0
- package/dist/storage/cache-layer.d.ts +159 -0
- package/dist/storage/cache-layer.d.ts.map +1 -0
- package/dist/storage/cache-layer.js +245 -0
- package/dist/storage/cache-layer.js.map +1 -0
- package/dist/storage/cost-aware-tiering.d.ts +258 -0
- package/dist/storage/cost-aware-tiering.d.ts.map +1 -0
- package/dist/storage/cost-aware-tiering.js +526 -0
- package/dist/storage/cost-aware-tiering.js.map +1 -0
- package/dist/storage/index.d.ts +87 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +78 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/interfaces.d.ts +856 -0
- package/dist/storage/interfaces.d.ts.map +1 -0
- package/dist/storage/interfaces.js +69 -0
- package/dist/storage/interfaces.js.map +1 -0
- package/dist/storage/r2-layer.d.ts +226 -0
- package/dist/storage/r2-layer.d.ts.map +1 -0
- package/dist/storage/r2-layer.js +307 -0
- package/dist/storage/r2-layer.js.map +1 -0
- package/dist/storage/r2-overflow.d.ts +344 -0
- package/dist/storage/r2-overflow.d.ts.map +1 -0
- package/dist/storage/r2-overflow.js +730 -0
- package/dist/storage/r2-overflow.js.map +1 -0
- package/dist/storage/r2-page-vfs.d.ts +374 -0
- package/dist/storage/r2-page-vfs.d.ts.map +1 -0
- package/dist/storage/r2-page-vfs.js +754 -0
- package/dist/storage/r2-page-vfs.js.map +1 -0
- package/dist/storage/swr-cache.d.ts +181 -0
- package/dist/storage/swr-cache.d.ts.map +1 -0
- package/dist/storage/swr-cache.js +295 -0
- package/dist/storage/swr-cache.js.map +1 -0
- package/dist/storage/tiered-orchestrator.d.ts +951 -0
- package/dist/storage/tiered-orchestrator.d.ts.map +1 -0
- package/dist/storage/tiered-orchestrator.js +1731 -0
- package/dist/storage/tiered-orchestrator.js.map +1 -0
- package/dist/storage/tiered-vfs-swr.d.ts +279 -0
- package/dist/storage/tiered-vfs-swr.d.ts.map +1 -0
- package/dist/storage/tiered-vfs-swr.js +584 -0
- package/dist/storage/tiered-vfs-swr.js.map +1 -0
- package/dist/storage/tiered-vfs.d.ts +405 -0
- package/dist/storage/tiered-vfs.d.ts.map +1 -0
- package/dist/storage/tiered-vfs.js +833 -0
- package/dist/storage/tiered-vfs.js.map +1 -0
- package/dist/streaming/backpressure-controller.d.ts +173 -0
- package/dist/streaming/backpressure-controller.d.ts.map +1 -0
- package/dist/streaming/backpressure-controller.js +344 -0
- package/dist/streaming/backpressure-controller.js.map +1 -0
- package/dist/streaming/buffer-pool.d.ts +241 -0
- package/dist/streaming/buffer-pool.d.ts.map +1 -0
- package/dist/streaming/buffer-pool.js +381 -0
- package/dist/streaming/buffer-pool.js.map +1 -0
- package/dist/streaming/cdc-iceberg-connector.d.ts +272 -0
- package/dist/streaming/cdc-iceberg-connector.d.ts.map +1 -0
- package/dist/streaming/cdc-iceberg-connector.js +408 -0
- package/dist/streaming/cdc-iceberg-connector.js.map +1 -0
- package/dist/streaming/index.d.ts +111 -0
- package/dist/streaming/index.d.ts.map +1 -0
- package/dist/streaming/index.js +128 -0
- package/dist/streaming/index.js.map +1 -0
- package/dist/streaming/live-cdc-stream.d.ts +400 -0
- package/dist/streaming/live-cdc-stream.d.ts.map +1 -0
- package/dist/streaming/live-cdc-stream.js +703 -0
- package/dist/streaming/live-cdc-stream.js.map +1 -0
- package/dist/streaming/memory-bounded-stream.d.ts +207 -0
- package/dist/streaming/memory-bounded-stream.d.ts.map +1 -0
- package/dist/streaming/memory-bounded-stream.js +340 -0
- package/dist/streaming/memory-bounded-stream.js.map +1 -0
- package/dist/streaming/query-streamer.d.ts +379 -0
- package/dist/streaming/query-streamer.d.ts.map +1 -0
- package/dist/streaming/query-streamer.js +495 -0
- package/dist/streaming/query-streamer.js.map +1 -0
- package/dist/streaming/response-streaming.d.ts +203 -0
- package/dist/streaming/response-streaming.d.ts.map +1 -0
- package/dist/streaming/response-streaming.js +449 -0
- package/dist/streaming/response-streaming.js.map +1 -0
- package/dist/types/branded.d.ts +859 -0
- package/dist/types/branded.d.ts.map +1 -0
- package/dist/types/branded.js +891 -0
- package/dist/types/branded.js.map +1 -0
- package/dist/types/utilities.d.ts +757 -0
- package/dist/types/utilities.d.ts.map +1 -0
- package/dist/types/utilities.js +447 -0
- package/dist/types/utilities.js.map +1 -0
- package/dist/wal/replay-engine.d.ts +344 -0
- package/dist/wal/replay-engine.d.ts.map +1 -0
- package/dist/wal/replay-engine.js +975 -0
- package/dist/wal/replay-engine.js.map +1 -0
- package/dist/worker/__mocks__/capnweb.d.ts +13 -0
- package/dist/worker/__mocks__/capnweb.d.ts.map +1 -0
- package/dist/worker/__mocks__/capnweb.js +15 -0
- package/dist/worker/__mocks__/capnweb.js.map +1 -0
- package/dist/worker/__mocks__/cloudflare-workers.d.ts +31 -0
- package/dist/worker/__mocks__/cloudflare-workers.d.ts.map +1 -0
- package/dist/worker/__mocks__/cloudflare-workers.js +33 -0
- package/dist/worker/__mocks__/cloudflare-workers.js.map +1 -0
- package/dist/worker/__mocks__/pglite.data.d.ts +3 -0
- package/dist/worker/__mocks__/pglite.data.d.ts.map +1 -0
- package/dist/worker/__mocks__/pglite.data.js +20 -0
- package/dist/worker/__mocks__/pglite.data.js.map +1 -0
- package/dist/worker/__mocks__/pglite.wasm.d.ts +3 -0
- package/dist/worker/__mocks__/pglite.wasm.d.ts.map +1 -0
- package/dist/worker/__mocks__/pglite.wasm.js +30 -0
- package/dist/worker/__mocks__/pglite.wasm.js.map +1 -0
- package/dist/worker/auth-rate-limiter.d.ts +270 -0
- package/dist/worker/auth-rate-limiter.d.ts.map +1 -0
- package/dist/worker/auth-rate-limiter.js +332 -0
- package/dist/worker/auth-rate-limiter.js.map +1 -0
- package/dist/worker/auth.d.ts +345 -0
- package/dist/worker/auth.d.ts.map +1 -0
- package/dist/worker/auth.js +837 -0
- package/dist/worker/auth.js.map +1 -0
- package/dist/worker/cdc-backpressure.d.ts +338 -0
- package/dist/worker/cdc-backpressure.d.ts.map +1 -0
- package/dist/worker/cdc-backpressure.js +619 -0
- package/dist/worker/cdc-backpressure.js.map +1 -0
- package/dist/worker/cdc-sse.d.ts +277 -0
- package/dist/worker/cdc-sse.d.ts.map +1 -0
- package/dist/worker/cdc-sse.js +528 -0
- package/dist/worker/cdc-sse.js.map +1 -0
- package/dist/worker/cdc-websocket.d.ts +252 -0
- package/dist/worker/cdc-websocket.d.ts.map +1 -0
- package/dist/worker/cdc-websocket.js +940 -0
- package/dist/worker/cdc-websocket.js.map +1 -0
- package/dist/worker/cdc.d.ts +95 -0
- package/dist/worker/cdc.d.ts.map +1 -0
- package/dist/worker/cdc.js +211 -0
- package/dist/worker/cdc.js.map +1 -0
- package/dist/worker/concerns/auth-concern.d.ts +50 -0
- package/dist/worker/concerns/auth-concern.d.ts.map +1 -0
- package/dist/worker/concerns/auth-concern.js +131 -0
- package/dist/worker/concerns/auth-concern.js.map +1 -0
- package/dist/worker/concerns/cdc-concern.d.ts +99 -0
- package/dist/worker/concerns/cdc-concern.d.ts.map +1 -0
- package/dist/worker/concerns/cdc-concern.js +137 -0
- package/dist/worker/concerns/cdc-concern.js.map +1 -0
- package/dist/worker/concerns/index.d.ts +22 -0
- package/dist/worker/concerns/index.d.ts.map +1 -0
- package/dist/worker/concerns/index.js +13 -0
- package/dist/worker/concerns/index.js.map +1 -0
- package/dist/worker/concerns/query-execution-concern.d.ts +104 -0
- package/dist/worker/concerns/query-execution-concern.d.ts.map +1 -0
- package/dist/worker/concerns/query-execution-concern.js +95 -0
- package/dist/worker/concerns/query-execution-concern.js.map +1 -0
- package/dist/worker/concerns/storage-orchestration-concern.d.ts +78 -0
- package/dist/worker/concerns/storage-orchestration-concern.d.ts.map +1 -0
- package/dist/worker/concerns/storage-orchestration-concern.js +240 -0
- package/dist/worker/concerns/storage-orchestration-concern.js.map +1 -0
- package/dist/worker/do-auth-manager.d.ts +108 -0
- package/dist/worker/do-auth-manager.d.ts.map +1 -0
- package/dist/worker/do-auth-manager.js +212 -0
- package/dist/worker/do-auth-manager.js.map +1 -0
- package/dist/worker/do-pglite-manager.d.ts +137 -0
- package/dist/worker/do-pglite-manager.d.ts.map +1 -0
- package/dist/worker/do-pglite-manager.js +228 -0
- package/dist/worker/do-pglite-manager.js.map +1 -0
- package/dist/worker/do.d.ts +556 -0
- package/dist/worker/do.d.ts.map +1 -0
- package/dist/worker/do.js +1441 -0
- package/dist/worker/do.js.map +1 -0
- package/dist/worker/entry.d.ts +23 -0
- package/dist/worker/entry.d.ts.map +1 -0
- package/dist/worker/entry.js +362 -0
- package/dist/worker/entry.js.map +1 -0
- package/dist/worker/errors.d.ts +106 -0
- package/dist/worker/errors.d.ts.map +1 -0
- package/dist/worker/errors.js +178 -0
- package/dist/worker/errors.js.map +1 -0
- package/dist/worker/health-check-manager.d.ts +141 -0
- package/dist/worker/health-check-manager.d.ts.map +1 -0
- package/dist/worker/health-check-manager.js +145 -0
- package/dist/worker/health-check-manager.js.map +1 -0
- package/dist/worker/index.d.ts +60 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +67 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/memory-pressure.d.ts +892 -0
- package/dist/worker/memory-pressure.d.ts.map +1 -0
- package/dist/worker/memory-pressure.js +1990 -0
- package/dist/worker/memory-pressure.js.map +1 -0
- package/dist/worker/migration-manager.d.ts +153 -0
- package/dist/worker/migration-manager.d.ts.map +1 -0
- package/dist/worker/migration-manager.js +461 -0
- package/dist/worker/migration-manager.js.map +1 -0
- package/dist/worker/plugin-manager.d.ts +147 -0
- package/dist/worker/plugin-manager.d.ts.map +1 -0
- package/dist/worker/plugin-manager.js +408 -0
- package/dist/worker/plugin-manager.js.map +1 -0
- package/dist/worker/proxy.d.ts +330 -0
- package/dist/worker/proxy.d.ts.map +1 -0
- package/dist/worker/proxy.js +504 -0
- package/dist/worker/proxy.js.map +1 -0
- package/dist/worker/query-execution-manager.d.ts +107 -0
- package/dist/worker/query-execution-manager.d.ts.map +1 -0
- package/dist/worker/query-execution-manager.js +155 -0
- package/dist/worker/query-execution-manager.js.map +1 -0
- package/dist/worker/query-executor.d.ts +163 -0
- package/dist/worker/query-executor.d.ts.map +1 -0
- package/dist/worker/query-executor.js +413 -0
- package/dist/worker/query-executor.js.map +1 -0
- package/dist/worker/query-stats-manager.d.ts +117 -0
- package/dist/worker/query-stats-manager.d.ts.map +1 -0
- package/dist/worker/query-stats-manager.js +162 -0
- package/dist/worker/query-stats-manager.js.map +1 -0
- package/dist/worker/result-handler.d.ts +192 -0
- package/dist/worker/result-handler.d.ts.map +1 -0
- package/dist/worker/result-handler.js +346 -0
- package/dist/worker/result-handler.js.map +1 -0
- package/dist/worker/routes.d.ts +135 -0
- package/dist/worker/routes.d.ts.map +1 -0
- package/dist/worker/routes.js +460 -0
- package/dist/worker/routes.js.map +1 -0
- package/dist/worker/rpc-methods-manager.d.ts +142 -0
- package/dist/worker/rpc-methods-manager.d.ts.map +1 -0
- package/dist/worker/rpc-methods-manager.js +195 -0
- package/dist/worker/rpc-methods-manager.js.map +1 -0
- package/dist/worker/rpc.d.ts +259 -0
- package/dist/worker/rpc.d.ts.map +1 -0
- package/dist/worker/rpc.js +398 -0
- package/dist/worker/rpc.js.map +1 -0
- package/dist/worker/schema-version.d.ts +209 -0
- package/dist/worker/schema-version.d.ts.map +1 -0
- package/dist/worker/schema-version.js +450 -0
- package/dist/worker/schema-version.js.map +1 -0
- package/dist/worker/session-manager.d.ts +282 -0
- package/dist/worker/session-manager.d.ts.map +1 -0
- package/dist/worker/session-manager.js +523 -0
- package/dist/worker/session-manager.js.map +1 -0
- package/dist/worker/shutdown-manager.d.ts +188 -0
- package/dist/worker/shutdown-manager.d.ts.map +1 -0
- package/dist/worker/shutdown-manager.js +347 -0
- package/dist/worker/shutdown-manager.js.map +1 -0
- package/dist/worker/sql-transform.d.ts +61 -0
- package/dist/worker/sql-transform.d.ts.map +1 -0
- package/dist/worker/sql-transform.js +312 -0
- package/dist/worker/sql-transform.js.map +1 -0
- package/dist/worker/types.d.ts +738 -0
- package/dist/worker/types.d.ts.map +1 -0
- package/dist/worker/types.js +6 -0
- package/dist/worker/types.js.map +1 -0
- package/dist/worker/user-routes.d.ts +76 -0
- package/dist/worker/user-routes.d.ts.map +1 -0
- package/dist/worker/user-routes.js +188 -0
- package/dist/worker/user-routes.js.map +1 -0
- package/dist/worker/wal-facade.d.ts +138 -0
- package/dist/worker/wal-facade.d.ts.map +1 -0
- package/dist/worker/wal-facade.js +184 -0
- package/dist/worker/wal-facade.js.map +1 -0
- package/dist/worker/wal-r2.d.ts +271 -0
- package/dist/worker/wal-r2.d.ts.map +1 -0
- package/dist/worker/wal-r2.js +689 -0
- package/dist/worker/wal-r2.js.map +1 -0
- package/dist/worker/wal-replay.d.ts +361 -0
- package/dist/worker/wal-replay.d.ts.map +1 -0
- package/dist/worker/wal-replay.js +628 -0
- package/dist/worker/wal-replay.js.map +1 -0
- package/dist/worker/wal-retention.d.ts +389 -0
- package/dist/worker/wal-retention.d.ts.map +1 -0
- package/dist/worker/wal-retention.js +763 -0
- package/dist/worker/wal-retention.js.map +1 -0
- package/dist/worker/wal.d.ts +278 -0
- package/dist/worker/wal.d.ts.map +1 -0
- package/dist/worker/wal.js +467 -0
- package/dist/worker/wal.js.map +1 -0
- package/dist/worker/websocket.d.ts +85 -0
- package/dist/worker/websocket.d.ts.map +1 -0
- package/dist/worker/websocket.js +227 -0
- package/dist/worker/websocket.js.map +1 -0
- package/package.json +108 -0
- package/src/cdc/change-stream.ts +137 -0
- package/src/cdc/filter.ts +646 -0
- package/src/cdc/index.ts +112 -0
- package/src/cdc/resume-token.ts +280 -0
- package/src/cdc/transport/index.ts +7 -0
- package/src/cdc/transport/sse.ts +723 -0
- package/src/cdc/transport/websocket.ts +873 -0
- package/src/cdc/types.ts +346 -0
- package/src/config/index.ts +25 -0
- package/src/config/memory.ts +177 -0
- package/src/config/storage.ts +204 -0
- package/src/config/streaming.ts +147 -0
- package/src/config/timeouts.ts +221 -0
- package/src/extensions/config.test.ts +187 -0
- package/src/extensions/config.ts +278 -0
- package/src/extensions/geo.test.ts +455 -0
- package/src/extensions/geo.ts +858 -0
- package/src/extensions/index.test.ts +259 -0
- package/src/extensions/index.ts +227 -0
- package/src/extensions/loader.test.ts +555 -0
- package/src/extensions/loader.ts +588 -0
- package/src/extensions/pgmq-lite.test.ts +727 -0
- package/src/extensions/pgmq-lite.ts +770 -0
- package/src/extensions/plugins.test.ts +528 -0
- package/src/extensions/plugins.ts +718 -0
- package/src/extensions/registry.test.ts +202 -0
- package/src/extensions/registry.ts +267 -0
- package/src/extensions/vector.test.ts +195 -0
- package/src/extensions/vector.ts +217 -0
- package/src/iceberg/SCHEDULER.md +580 -0
- package/src/iceberg/analytics.test.ts +703 -0
- package/src/iceberg/analytics.ts +727 -0
- package/src/iceberg/catalog-api.test.ts +838 -0
- package/src/iceberg/catalog-api.ts +520 -0
- package/src/iceberg/catalog.test.ts +680 -0
- package/src/iceberg/catalog.ts +1007 -0
- package/src/iceberg/iceberg.test.ts +705 -0
- package/src/iceberg/index.ts +406 -0
- package/src/iceberg/metadata.test.ts +632 -0
- package/src/iceberg/metadata.ts +649 -0
- package/src/iceberg/optimizer.test.ts +868 -0
- package/src/iceberg/optimizer.ts +1287 -0
- package/src/iceberg/parquet.test.ts +899 -0
- package/src/iceberg/parquet.ts +1640 -0
- package/src/iceberg/r2-organization.test.ts +615 -0
- package/src/iceberg/r2-organization.ts +951 -0
- package/src/iceberg/scheduler-do-example.ts +364 -0
- package/src/iceberg/scheduler.test.ts +861 -0
- package/src/iceberg/scheduler.ts +1201 -0
- package/src/iceberg/schema.test.ts +547 -0
- package/src/iceberg/schema.ts +616 -0
- package/src/iceberg/snapshot-manager.test.ts +919 -0
- package/src/iceberg/snapshot-manager.ts +1369 -0
- package/src/iceberg/sql-router.test.ts +334 -0
- package/src/iceberg/sql-router.ts +337 -0
- package/src/iceberg/test-fixtures.ts +605 -0
- package/src/iceberg/time-travel-api.test.ts +1029 -0
- package/src/iceberg/time-travel-api.ts +731 -0
- package/src/iceberg/time-travel.test.ts +1218 -0
- package/src/iceberg/time-travel.ts +1052 -0
- package/src/iceberg/transformer.test.ts +689 -0
- package/src/iceberg/transformer.ts +1029 -0
- package/src/iceberg/types.ts +373 -0
- package/src/iceberg/writer.test.ts +716 -0
- package/src/iceberg/writer.ts +590 -0
- package/src/index.ts +212 -0
- package/src/lineage/index.ts +42 -0
- package/src/lineage/integration.ts +334 -0
- package/src/lineage/tracker.ts +1618 -0
- package/src/lineage/types.ts +354 -0
- package/src/middleware/index.ts +36 -0
- package/src/middleware/rate-limit-concurrent.test.ts +794 -0
- package/src/middleware/rate-limit.test.ts +1568 -0
- package/src/middleware/rate-limit.ts +840 -0
- package/src/migration-tooling/external-migration.test.ts +1864 -0
- package/src/migration-tooling/external-migration.ts +2355 -0
- package/src/migration-tooling/index.ts +19 -0
- package/src/migrations/ARCHITECTURE.md +474 -0
- package/src/migrations/PROGRESS_TRACKING.md +485 -0
- package/src/migrations/auto-migrator.test.ts +732 -0
- package/src/migrations/auto-migrator.ts +531 -0
- package/src/migrations/bulk-orchestrator.test.ts +801 -0
- package/src/migrations/bulk-orchestrator.ts +1039 -0
- package/src/migrations/compatibility.test.ts +958 -0
- package/src/migrations/compatibility.ts +902 -0
- package/src/migrations/do-migrations.test.ts +2620 -0
- package/src/migrations/do-migrations.ts +1289 -0
- package/src/migrations/do-migrations.types.ts +715 -0
- package/src/migrations/drizzle-compat.test.ts +210 -0
- package/src/migrations/drizzle-compat.ts +337 -0
- package/src/migrations/index.ts +334 -0
- package/src/migrations/migration-api.test.ts +438 -0
- package/src/migrations/migration-api.ts +704 -0
- package/src/migrations/progress-tracker-do.ts +518 -0
- package/src/migrations/progress-tracker-kv.ts +305 -0
- package/src/migrations/progress-tracker.test.ts +937 -0
- package/src/migrations/progress-tracker.ts +665 -0
- package/src/migrations/registry.test.ts +331 -0
- package/src/migrations/registry.ts +468 -0
- package/src/migrations/rollback.test.ts +644 -0
- package/src/migrations/runner.test.ts +807 -0
- package/src/migrations/runner.test.ts.backup +759 -0
- package/src/migrations/runner.ts +1459 -0
- package/src/migrations/schema-generator.test.ts +649 -0
- package/src/migrations/schema-generator.ts +513 -0
- package/src/migrations/testing.ts +1037 -0
- package/src/migrations/types.ts +573 -0
- package/src/migrations/validator.test.ts +660 -0
- package/src/migrations/validator.ts +741 -0
- package/src/observability/alerting.test.ts +1133 -0
- package/src/observability/alerting.ts +455 -0
- package/src/observability/analytics-engine.ts +733 -0
- package/src/observability/cost-metrics.ts +804 -0
- package/src/observability/cross-do-tracing.test.ts +516 -0
- package/src/observability/cross-do-tracing.ts +588 -0
- package/src/observability/dashboards/postgres-do-overview.json +1656 -0
- package/src/observability/error-rate-collector.test.ts +977 -0
- package/src/observability/error-rate-collector.ts +518 -0
- package/src/observability/exporters.test.ts +365 -0
- package/src/observability/exporters.ts +650 -0
- package/src/observability/health-check.test.ts +353 -0
- package/src/observability/health-check.ts +341 -0
- package/src/observability/index.test.ts +298 -0
- package/src/observability/index.ts +885 -0
- package/src/observability/instrumentation.test.ts +428 -0
- package/src/observability/instrumentation.ts +788 -0
- package/src/observability/memory-metrics.test.ts +355 -0
- package/src/observability/memory-metrics.ts +990 -0
- package/src/observability/metrics-endpoint.test.ts +402 -0
- package/src/observability/metrics-endpoint.ts +374 -0
- package/src/observability/metrics.test.ts +291 -0
- package/src/observability/metrics.ts +315 -0
- package/src/observability/observability-features.ts +1296 -0
- package/src/observability/prometheus.test.ts +292 -0
- package/src/observability/prometheus.ts +170 -0
- package/src/observability/propagation.test.ts +417 -0
- package/src/observability/propagation.ts +294 -0
- package/src/observability/query-latency.ts +586 -0
- package/src/observability/query-performance.test.ts +406 -0
- package/src/observability/query-performance.ts +491 -0
- package/src/observability/storage-tier-metrics.test.ts +633 -0
- package/src/observability/storage-tier-metrics.ts +570 -0
- package/src/observability/tier-cost-optimizer.ts +740 -0
- package/src/observability/tracer.test.ts +346 -0
- package/src/observability/tracer.ts +585 -0
- package/src/observability/types.test.ts +726 -0
- package/src/observability/types.ts +434 -0
- package/src/pglite/auto-demotion.test.ts +477 -0
- package/src/pglite/auto-demotion.ts +385 -0
- package/src/pglite/auto-promotion.test.ts +824 -0
- package/src/pglite/auto-promotion.ts +547 -0
- package/src/pglite/cache-layer.test.ts +469 -0
- package/src/pglite/cache-layer.ts +271 -0
- package/src/pglite/cold-start-manager.ts +1260 -0
- package/src/pglite/cold-start-optimizer.test.ts +937 -0
- package/src/pglite/cold-start-optimizer.ts +1895 -0
- package/src/pglite/dovfs-adapter.ts +1122 -0
- package/src/pglite/dovfs.ts +1258 -0
- package/src/pglite/etag-cache.test.ts +844 -0
- package/src/pglite/etag-cache.ts +526 -0
- package/src/pglite/index.ts +442 -0
- package/src/pglite/init.test.ts +455 -0
- package/src/pglite/init.ts +574 -0
- package/src/pglite/lifecycle.test.ts +599 -0
- package/src/pglite/lifecycle.ts +704 -0
- package/src/pglite/parallel-loader.test.ts +586 -0
- package/src/pglite/parallel-loader.ts +481 -0
- package/src/pglite/production-pglite.test.ts +666 -0
- package/src/pglite/production-pglite.ts +537 -0
- package/src/pglite/query-executor.ts +614 -0
- package/src/pglite/r2-layer.test.ts +501 -0
- package/src/pglite/r2-layer.ts +322 -0
- package/src/pglite/tiered-init.test.ts +725 -0
- package/src/pglite/tiered-init.ts +556 -0
- package/src/pglite/tiered-vfs.test.ts +726 -0
- package/src/pglite/tiered-vfs.ts +33 -0
- package/src/pglite/tiering-stats.test.ts +531 -0
- package/src/pglite/tiering-stats.ts +407 -0
- package/src/pglite/transaction-hooks.ts +343 -0
- package/src/pglite/warm-loader.test.ts +1701 -0
- package/src/pglite/warm-loader.ts +528 -0
- package/src/pglite/workers-pglite.ts +224 -0
- package/src/pglite-assets/pglite.data +0 -0
- package/src/pglite-assets/pglite.wasm +0 -0
- package/src/pglite.d.ts +47 -0
- package/src/playground/index.ts +137 -0
- package/src/playground/keyboard-shortcuts.ts +677 -0
- package/src/playground/playground.ts +323 -0
- package/src/playground/query-executor.ts +669 -0
- package/src/playground/query-history.ts +328 -0
- package/src/playground/result-formatter.ts +420 -0
- package/src/playground/sample-datasets.ts +674 -0
- package/src/playground/sample-queries.ts +1168 -0
- package/src/playground/schema-explorer.ts +558 -0
- package/src/playground/types.ts +518 -0
- package/src/readonly/cache-reader.test.ts +460 -0
- package/src/readonly/cache-reader.ts +313 -0
- package/src/readonly/config.test.ts +187 -0
- package/src/readonly/config.ts +128 -0
- package/src/readonly/index.ts +50 -0
- package/src/readonly/pglite-wrapper.test.ts +278 -0
- package/src/readonly/pglite-wrapper.ts +184 -0
- package/src/readonly/worker.test.ts +533 -0
- package/src/readonly/worker.ts +341 -0
- package/src/readonly/write-blocker.test.ts +459 -0
- package/src/readonly/write-blocker.ts +175 -0
- package/src/recovery/disaster-recovery.test.ts +618 -0
- package/src/recovery/disaster-recovery.ts +1181 -0
- package/src/recovery/index.ts +43 -0
- package/src/recovery/parquet-parser.ts +974 -0
- package/src/retention/index.ts +74 -0
- package/src/retention/policy.test.ts +571 -0
- package/src/retention/policy.ts +774 -0
- package/src/retention/purger.test.ts +465 -0
- package/src/retention/purger.ts +558 -0
- package/src/rls/auth-integration.test.ts +752 -0
- package/src/rls/auth-integration.ts +533 -0
- package/src/rls/generator.test.ts +829 -0
- package/src/rls/generator.ts +573 -0
- package/src/rls/index.ts +128 -0
- package/src/rls/policy.ts +208 -0
- package/src/rls/rls.test.ts +1071 -0
- package/src/rls/validator.test.ts +930 -0
- package/src/rls/validator.ts +895 -0
- package/src/routing/adaptive-router.test.ts +884 -0
- package/src/routing/adaptive-router.ts +845 -0
- package/src/routing/circuit-breaker.test.ts +1505 -0
- package/src/routing/circuit-breaker.ts +852 -0
- package/src/routing/cost-metrics.test.ts +565 -0
- package/src/routing/cost-metrics.ts +408 -0
- package/src/routing/do-connection-pool.test.ts +1109 -0
- package/src/routing/do-connection-pool.ts +828 -0
- package/src/routing/index.ts +158 -0
- package/src/routing/query-complexity-estimator.test.ts +356 -0
- package/src/routing/query-complexity-estimator.ts +444 -0
- package/src/routing/request-coalescing.test.ts +738 -0
- package/src/routing/request-coalescing.ts +475 -0
- package/src/routing/runtime-router.test.ts +436 -0
- package/src/routing/runtime-router.ts +357 -0
- package/src/routing/tenant-router.test.ts +2493 -0
- package/src/routing/tenant-router.ts +1908 -0
- package/src/routing/websocket-pool.test.ts +551 -0
- package/src/routing/websocket-pool.ts +577 -0
- package/src/storage/access-pattern-tracker.test.ts +874 -0
- package/src/storage/cache-layer.test.ts +560 -0
- package/src/storage/cache-layer.ts +328 -0
- package/src/storage/cost-aware-tiering.test.ts +652 -0
- package/src/storage/cost-aware-tiering.ts +794 -0
- package/src/storage/do-sqlite-blobs.test.ts +937 -0
- package/src/storage/index.ts +272 -0
- package/src/storage/interfaces.ts +974 -0
- package/src/storage/r2-layer.test.ts +653 -0
- package/src/storage/r2-layer.ts +434 -0
- package/src/storage/r2-overflow.ts +920 -0
- package/src/storage/r2-page-vfs.test.ts +2348 -0
- package/src/storage/r2-page-vfs.ts +1054 -0
- package/src/storage/swr-cache.test.ts +832 -0
- package/src/storage/swr-cache.ts +398 -0
- package/src/storage/swr-tiered-integration.test.ts +617 -0
- package/src/storage/tiered-orchestrator.test.ts +2441 -0
- package/src/storage/tiered-orchestrator.ts +2081 -0
- package/src/storage/tiered-vfs-swr.test.ts +736 -0
- package/src/storage/tiered-vfs-swr.ts +735 -0
- package/src/storage/tiered-vfs.test.ts +793 -0
- package/src/storage/tiered-vfs.ts +1082 -0
- package/src/streaming/backpressure-controller.ts +452 -0
- package/src/streaming/buffer-pool.ts +484 -0
- package/src/streaming/cdc-iceberg-connector.ts +605 -0
- package/src/streaming/index.ts +225 -0
- package/src/streaming/live-cdc-stream.ts +985 -0
- package/src/streaming/memory-bounded-stream.ts +443 -0
- package/src/streaming/query-streamer.ts +662 -0
- package/src/streaming/response-streaming.ts +557 -0
- package/src/types/branded.ts +1075 -0
- package/src/types/branded.ts.backup +273 -0
- package/src/types/utilities.ts +1023 -0
- package/src/types/wasm.d.ts +30 -0
- package/src/validation/typed-errors.test.ts +420 -0
- package/src/wal/replay-engine.ts +1264 -0
- package/src/worker/__mocks__/capnweb.ts +15 -0
- package/src/worker/__mocks__/pglite.data.ts +22 -0
- package/src/worker/__mocks__/pglite.wasm.ts +33 -0
- package/src/worker/auth-rate-limiter.test.ts +272 -0
- package/src/worker/auth-rate-limiter.ts +448 -0
- package/src/worker/auth.security-red.test.ts +1236 -0
- package/src/worker/auth.security.test.ts +822 -0
- package/src/worker/auth.test.ts +469 -0
- package/src/worker/auth.ts +1104 -0
- package/src/worker/cdc-backpressure.test.ts +726 -0
- package/src/worker/cdc-backpressure.ts +866 -0
- package/src/worker/cdc-sse.test.ts +780 -0
- package/src/worker/cdc-sse.ts +728 -0
- package/src/worker/cdc-websocket.ts +1229 -0
- package/src/worker/cdc-ws.test.ts +1009 -0
- package/src/worker/cdc.test.ts +327 -0
- package/src/worker/cdc.ts +289 -0
- package/src/worker/concerns/auth-concern.ts +179 -0
- package/src/worker/concerns/cdc-concern.ts +247 -0
- package/src/worker/concerns/index.ts +58 -0
- package/src/worker/concerns/query-execution-concern.ts +194 -0
- package/src/worker/concerns/storage-orchestration-concern.ts +373 -0
- package/src/worker/discriminated-types.test.ts +280 -0
- package/src/worker/do-auth-manager.ts +257 -0
- package/src/worker/do-decomposition.test.ts +1236 -0
- package/src/worker/do-pglite-manager.ts +302 -0
- package/src/worker/do.test.ts +2254 -0
- package/src/worker/do.ts +1878 -0
- package/src/worker/entry.ts +417 -0
- package/src/worker/errors.ts +285 -0
- package/src/worker/health-check-manager.test.ts +261 -0
- package/src/worker/health-check-manager.ts +231 -0
- package/src/worker/index.ts +389 -0
- package/src/worker/memory-pressure.test.ts +1460 -0
- package/src/worker/memory-pressure.ts +2650 -0
- package/src/worker/migration-manager.ts +582 -0
- package/src/worker/neon-compat.test.ts +332 -0
- package/src/worker/plugin-manager.ts +485 -0
- package/src/worker/postgres.do-rpc.d.ts +76 -0
- package/src/worker/proxy.ts +694 -0
- package/src/worker/query-execution-manager.test.ts +303 -0
- package/src/worker/query-execution-manager.ts +219 -0
- package/src/worker/query-executor.test.ts +282 -0
- package/src/worker/query-executor.ts +560 -0
- package/src/worker/query-stats-manager.ts +229 -0
- package/src/worker/result-handler.test.ts +364 -0
- package/src/worker/result-handler.ts +510 -0
- package/src/worker/routes.test.ts +795 -0
- package/src/worker/routes.ts +650 -0
- package/src/worker/rpc-methods-manager.test.ts +326 -0
- package/src/worker/rpc-methods-manager.ts +276 -0
- package/src/worker/rpc.ts +524 -0
- package/src/worker/schema-version.ts +605 -0
- package/src/worker/session-manager.test.ts +506 -0
- package/src/worker/session-manager.ts +732 -0
- package/src/worker/shutdown-manager.ts +469 -0
- package/src/worker/sql-transform.test.ts +286 -0
- package/src/worker/sql-transform.ts +368 -0
- package/src/worker/supabase-compat.test.ts +621 -0
- package/src/worker/types.test.ts +292 -0
- package/src/worker/types.ts +873 -0
- package/src/worker/user-routes.test.ts +703 -0
- package/src/worker/user-routes.ts +303 -0
- package/src/worker/wal-facade.ts +235 -0
- package/src/worker/wal-r2.test.ts +570 -0
- package/src/worker/wal-r2.ts +930 -0
- package/src/worker/wal-replay.test.ts +845 -0
- package/src/worker/wal-replay.ts +897 -0
- package/src/worker/wal-retention.test.ts +758 -0
- package/src/worker/wal-retention.ts +1075 -0
- package/src/worker/wal.test.ts +618 -0
- package/src/worker/wal.ts +697 -0
- package/src/worker/websocket.test.ts +296 -0
- package/src/worker/websocket.ts +284 -0
|
@@ -0,0 +1,2493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RED PHASE TESTS: Hostname-based Multi-tenant DO Routing
|
|
3
|
+
*
|
|
4
|
+
* Purpose: Route incoming requests to the correct Durable Object based on
|
|
5
|
+
* tenant identification extracted from hostname, path, or headers.
|
|
6
|
+
*
|
|
7
|
+
* Key features:
|
|
8
|
+
* - Extract tenant from subdomain (tenant1.app.com)
|
|
9
|
+
* - Extract tenant from full hostname (custom domains)
|
|
10
|
+
* - Extract tenant from path (/tenant1/...)
|
|
11
|
+
* - Extract tenant from header (X-Tenant-ID)
|
|
12
|
+
* - Route to correct DO with tenant isolation
|
|
13
|
+
* - Support blocked tenant lists
|
|
14
|
+
* - Custom tenant resolver functions
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
18
|
+
import {
|
|
19
|
+
TenantRouter,
|
|
20
|
+
TenantRouterConfig,
|
|
21
|
+
TenantExtractor,
|
|
22
|
+
TenantExtractionResult,
|
|
23
|
+
TenantRoutingResult,
|
|
24
|
+
RoutingMetadata,
|
|
25
|
+
RoutingTiming,
|
|
26
|
+
TenantContext,
|
|
27
|
+
createTenantRouter,
|
|
28
|
+
extractors,
|
|
29
|
+
DEFAULT_HEADER_NAME,
|
|
30
|
+
DEFAULT_PATH_PREFIX,
|
|
31
|
+
SimpleTenantExtractor,
|
|
32
|
+
TenantRateLimiter,
|
|
33
|
+
RateLimitConfig,
|
|
34
|
+
RateLimitResult,
|
|
35
|
+
createDomainMappingCache,
|
|
36
|
+
MetricsCollector,
|
|
37
|
+
RequestMetrics,
|
|
38
|
+
TenantRouterLogger,
|
|
39
|
+
LogEntry,
|
|
40
|
+
TraceContext,
|
|
41
|
+
TraceContextExtractor,
|
|
42
|
+
DomainMappingCache,
|
|
43
|
+
DomainCacheConfig,
|
|
44
|
+
DomainCacheEntry,
|
|
45
|
+
CacheStats,
|
|
46
|
+
} from './tenant-router'
|
|
47
|
+
import {
|
|
48
|
+
createMockRequest,
|
|
49
|
+
createMockDONamespace,
|
|
50
|
+
} from '../__tests__/test-utils'
|
|
51
|
+
|
|
52
|
+
describe('TenantRouter', () => {
|
|
53
|
+
describe('hostname extraction', () => {
|
|
54
|
+
it('should extract tenant from subdomain: tenant1.app.com -> tenant1', () => {
|
|
55
|
+
const extractor = extractors.subdomain('app.com')
|
|
56
|
+
const request = createMockRequest('https://tenant1.app.com/api/users')
|
|
57
|
+
expect(extractor(request)).toBe('tenant1')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should extract tenant from subdomain with multiple levels: tenant1.api.app.com -> tenant1', () => {
|
|
61
|
+
const extractor = extractors.subdomain('app.com')
|
|
62
|
+
const request = createMockRequest('https://tenant1.api.app.com/api/users')
|
|
63
|
+
expect(extractor(request)).toBe('tenant1')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should extract tenant from full hostname: tenant1.example.com -> tenant1.example.com', () => {
|
|
67
|
+
// Without baseDomain, return the first subdomain segment
|
|
68
|
+
const extractor = extractors.subdomain()
|
|
69
|
+
const request = createMockRequest('https://tenant1.example.com/api/users')
|
|
70
|
+
expect(extractor(request)).toBe('tenant1')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should handle www prefix: www.tenant1.app.com -> tenant1', () => {
|
|
74
|
+
const extractor = extractors.subdomain('app.com')
|
|
75
|
+
const request = createMockRequest('https://www.tenant1.app.com/api/users')
|
|
76
|
+
expect(extractor(request)).toBe('tenant1')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should handle custom domain mapping lookup', async () => {
|
|
80
|
+
// Custom extractor that does domain mapping lookup
|
|
81
|
+
const domainMap: Record<string, string> = {
|
|
82
|
+
'custom.example.org': 'tenant1',
|
|
83
|
+
'another.example.org': 'tenant2',
|
|
84
|
+
}
|
|
85
|
+
const customExtractor: SimpleTenantExtractor = (request: Request) => {
|
|
86
|
+
const url = new URL(request.url)
|
|
87
|
+
return domainMap[url.hostname] || null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const request1 = createMockRequest('https://custom.example.org/api')
|
|
91
|
+
const request2 = createMockRequest('https://another.example.org/api')
|
|
92
|
+
|
|
93
|
+
expect(customExtractor(request1)).toBe('tenant1')
|
|
94
|
+
expect(customExtractor(request2)).toBe('tenant2')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should return null for apex domain: app.com -> null', () => {
|
|
98
|
+
const extractor = extractors.subdomain('app.com')
|
|
99
|
+
const request = createMockRequest('https://app.com/api/users')
|
|
100
|
+
expect(extractor(request)).toBe(null)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should handle localhost with port: tenant1.localhost:8787 -> tenant1', () => {
|
|
104
|
+
const extractor = extractors.subdomain('localhost')
|
|
105
|
+
const request = createMockRequest('http://tenant1.localhost:8787/api/users')
|
|
106
|
+
expect(extractor(request)).toBe('tenant1')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should normalize tenant identifiers to lowercase', () => {
|
|
110
|
+
const extractor = extractors.subdomain('app.com')
|
|
111
|
+
const request = createMockRequest('https://TenAnt1.app.com/api/users')
|
|
112
|
+
// The URL parser already lowercases hostnames
|
|
113
|
+
expect(extractor(request)).toBe('tenant1')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should strip invalid characters from tenant identifiers', async () => {
|
|
117
|
+
const mockNamespace = createMockDONamespace()
|
|
118
|
+
const router = createTenantRouter({
|
|
119
|
+
doNamespace: mockNamespace,
|
|
120
|
+
extractTenant: 'path',
|
|
121
|
+
transformTenant: (id) => id.replace(/[^a-z0-9-]/gi, ''),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const request = createMockRequest('https://app.com/tenant@1!/api')
|
|
125
|
+
const tenantId = await router.getTenantId(request)
|
|
126
|
+
expect(tenantId).toBe('tenant1')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should handle IP addresses gracefully: 192.168.1.1 -> null', () => {
|
|
130
|
+
const extractor = extractors.subdomain()
|
|
131
|
+
const request = createMockRequest('http://192.168.1.1:8080/api/users')
|
|
132
|
+
expect(extractor(request)).toBe(null)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('path-based extraction', () => {
|
|
137
|
+
it('should extract tenant from path: /tenant1/api/users -> tenant1', () => {
|
|
138
|
+
const extractor = extractors.path()
|
|
139
|
+
const request = createMockRequest('https://app.com/tenant1/api/users')
|
|
140
|
+
expect(extractor(request)).toBe('tenant1')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('should extract tenant with custom prefix: /orgs/tenant1/api -> tenant1', () => {
|
|
144
|
+
const extractor = extractors.path('/orgs')
|
|
145
|
+
const request = createMockRequest('https://app.com/orgs/tenant1/api')
|
|
146
|
+
expect(extractor(request)).toBe('tenant1')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should return null for root path: / -> null', () => {
|
|
150
|
+
const extractor = extractors.path()
|
|
151
|
+
const request = createMockRequest('https://app.com/')
|
|
152
|
+
expect(extractor(request)).toBe(null)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should handle URL-encoded tenant names: /tenant%20name/api -> tenant name', () => {
|
|
156
|
+
const extractor = extractors.path()
|
|
157
|
+
const request = createMockRequest('https://app.com/tenant%20name/api')
|
|
158
|
+
expect(extractor(request)).toBe('tenant name')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should preserve remaining path after tenant extraction', async () => {
|
|
162
|
+
const mockNamespace = createMockDONamespace()
|
|
163
|
+
const router = createTenantRouter({
|
|
164
|
+
doNamespace: mockNamespace,
|
|
165
|
+
extractTenant: 'path',
|
|
166
|
+
stripTenantFromPath: true,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const request = createMockRequest('https://app.com/tenant1/api/users')
|
|
170
|
+
const result = await router.extractTenant(request)
|
|
171
|
+
expect(result.modifiedPath).toBe('/api/users')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('should handle trailing slashes: /tenant1/ -> tenant1', () => {
|
|
175
|
+
const extractor = extractors.path()
|
|
176
|
+
const request = createMockRequest('https://app.com/tenant1/')
|
|
177
|
+
expect(extractor(request)).toBe('tenant1')
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('header-based extraction', () => {
|
|
182
|
+
it('should extract tenant from X-Tenant-ID header', () => {
|
|
183
|
+
const extractor = extractors.header()
|
|
184
|
+
const request = createMockRequest('https://app.com/api', {
|
|
185
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
186
|
+
})
|
|
187
|
+
expect(extractor(request)).toBe('tenant1')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should support custom header name via config', () => {
|
|
191
|
+
const extractor = extractors.header('X-Org-ID')
|
|
192
|
+
const request = createMockRequest('https://app.com/api', {
|
|
193
|
+
headers: { 'X-Org-ID': 'org123' },
|
|
194
|
+
})
|
|
195
|
+
expect(extractor(request)).toBe('org123')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('should return null when header is missing', () => {
|
|
199
|
+
const extractor = extractors.header()
|
|
200
|
+
const request = createMockRequest('https://app.com/api')
|
|
201
|
+
expect(extractor(request)).toBe(null)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('should trim whitespace from header value', () => {
|
|
205
|
+
const extractor = extractors.header()
|
|
206
|
+
const request = createMockRequest('https://app.com/api', {
|
|
207
|
+
headers: { 'X-Tenant-ID': ' tenant1 ' },
|
|
208
|
+
})
|
|
209
|
+
expect(extractor(request)).toBe('tenant1')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('should handle empty header value: X-Tenant-ID: "" -> null', () => {
|
|
213
|
+
const extractor = extractors.header()
|
|
214
|
+
const request = createMockRequest('https://app.com/api', {
|
|
215
|
+
headers: { 'X-Tenant-ID': '' },
|
|
216
|
+
})
|
|
217
|
+
expect(extractor(request)).toBe(null)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('should be case-insensitive for header name lookup', () => {
|
|
221
|
+
const extractor = extractors.header('X-Tenant-ID')
|
|
222
|
+
const request = createMockRequest('https://app.com/api', {
|
|
223
|
+
headers: { 'x-tenant-id': 'tenant1' },
|
|
224
|
+
})
|
|
225
|
+
expect(extractor(request)).toBe('tenant1')
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
describe('custom extractor', () => {
|
|
230
|
+
it('should support custom tenant resolver function', async () => {
|
|
231
|
+
const mockNamespace = createMockDONamespace()
|
|
232
|
+
const customExtractor: SimpleTenantExtractor = (request: Request) => {
|
|
233
|
+
const url = new URL(request.url)
|
|
234
|
+
return url.searchParams.get('tenant')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const router = createTenantRouter({
|
|
238
|
+
doNamespace: mockNamespace,
|
|
239
|
+
extractTenant: customExtractor,
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const request = createMockRequest('https://app.com/api?tenant=custom1')
|
|
243
|
+
const tenantId = await router.getTenantId(request)
|
|
244
|
+
expect(tenantId).toBe('custom1')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('should pass full request to custom extractor', async () => {
|
|
248
|
+
const mockNamespace = createMockDONamespace()
|
|
249
|
+
let receivedRequest: Request | null = null
|
|
250
|
+
|
|
251
|
+
const customExtractor: SimpleTenantExtractor = (request: Request) => {
|
|
252
|
+
receivedRequest = request
|
|
253
|
+
return 'tenant1'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const router = createTenantRouter({
|
|
257
|
+
doNamespace: mockNamespace,
|
|
258
|
+
extractTenant: customExtractor,
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const request = createMockRequest('https://app.com/api', {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: { 'Content-Type': 'application/json' },
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
await router.getTenantId(request)
|
|
267
|
+
expect(receivedRequest).not.toBe(null)
|
|
268
|
+
expect(receivedRequest!.method).toBe('POST')
|
|
269
|
+
expect(receivedRequest!.headers.get('Content-Type')).toBe('application/json')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('should handle async custom extractors', async () => {
|
|
273
|
+
const mockNamespace = createMockDONamespace()
|
|
274
|
+
const asyncExtractor: TenantExtractor = async (request: Request) => {
|
|
275
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
276
|
+
const url = new URL(request.url)
|
|
277
|
+
return {
|
|
278
|
+
tenantId: url.searchParams.get('tenant'),
|
|
279
|
+
source: 'custom' as const,
|
|
280
|
+
originalHostname: url.hostname,
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const router = createTenantRouter({
|
|
285
|
+
doNamespace: mockNamespace,
|
|
286
|
+
extractTenant: asyncExtractor,
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
const request = createMockRequest('https://app.com/api?tenant=async1')
|
|
290
|
+
const tenantId = await router.getTenantId(request)
|
|
291
|
+
expect(tenantId).toBe('async1')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('should allow custom extractor to return metadata', async () => {
|
|
295
|
+
const mockNamespace = createMockDONamespace()
|
|
296
|
+
const customExtractor: TenantExtractor = (request: Request) => {
|
|
297
|
+
const url = new URL(request.url)
|
|
298
|
+
return {
|
|
299
|
+
tenantId: url.searchParams.get('tenant'),
|
|
300
|
+
source: 'custom' as const,
|
|
301
|
+
originalHostname: url.hostname,
|
|
302
|
+
metadata: {
|
|
303
|
+
plan: 'enterprise',
|
|
304
|
+
region: 'us-east-1',
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const router = createTenantRouter({
|
|
310
|
+
doNamespace: mockNamespace,
|
|
311
|
+
extractTenant: customExtractor,
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
const request = createMockRequest('https://app.com/api?tenant=meta1')
|
|
315
|
+
const result = await router.extractTenant(request)
|
|
316
|
+
expect(result.metadata).toEqual({ plan: 'enterprise', region: 'us-east-1' })
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('should fallback to null when custom extractor throws', async () => {
|
|
320
|
+
const mockNamespace = createMockDONamespace()
|
|
321
|
+
const throwingExtractor: SimpleTenantExtractor = () => {
|
|
322
|
+
throw new Error('Extractor error')
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const router = createTenantRouter({
|
|
326
|
+
doNamespace: mockNamespace,
|
|
327
|
+
extractTenant: throwingExtractor,
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const request = createMockRequest('https://app.com/api')
|
|
331
|
+
// The route method should handle the error
|
|
332
|
+
const response = await router.route(request)
|
|
333
|
+
expect(response.status).toBe(500)
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
describe('DO routing', () => {
|
|
338
|
+
let mockNamespace: DurableObjectNamespace
|
|
339
|
+
let mockStub: { fetch: ReturnType<typeof vi.fn> }
|
|
340
|
+
|
|
341
|
+
beforeEach(() => {
|
|
342
|
+
mockStub = {
|
|
343
|
+
fetch: vi.fn().mockResolvedValue(new Response('OK', { status: 200 })),
|
|
344
|
+
}
|
|
345
|
+
mockNamespace = {
|
|
346
|
+
idFromName: vi.fn((name: string) => ({ name, toString: () => `id-${name}` })),
|
|
347
|
+
idFromString: vi.fn((id: string) => ({ id, toString: () => id })),
|
|
348
|
+
newUniqueId: vi.fn(() => ({ toString: () => 'unique-id' })),
|
|
349
|
+
get: vi.fn(() => mockStub),
|
|
350
|
+
jurisdiction: vi.fn(),
|
|
351
|
+
} as unknown as DurableObjectNamespace
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('should create DO ID from tenant identifier using idFromName', async () => {
|
|
355
|
+
const router = createTenantRouter({
|
|
356
|
+
doNamespace: mockNamespace,
|
|
357
|
+
extractTenant: 'header',
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const request = createMockRequest('https://app.com/api', {
|
|
361
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
await router.route(request)
|
|
365
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith('tenant1')
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('should route request to correct DO stub', async () => {
|
|
369
|
+
const router = createTenantRouter({
|
|
370
|
+
doNamespace: mockNamespace,
|
|
371
|
+
extractTenant: 'header',
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
const request = createMockRequest('https://app.com/api', {
|
|
375
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
await router.route(request)
|
|
379
|
+
expect(mockNamespace.get).toHaveBeenCalled()
|
|
380
|
+
expect(mockStub.fetch).toHaveBeenCalled()
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('should forward all original headers to DO', async () => {
|
|
384
|
+
const router = createTenantRouter({
|
|
385
|
+
doNamespace: mockNamespace,
|
|
386
|
+
extractTenant: 'header',
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
const request = createMockRequest('https://app.com/api', {
|
|
390
|
+
headers: {
|
|
391
|
+
'X-Tenant-ID': 'tenant1',
|
|
392
|
+
'Content-Type': 'application/json',
|
|
393
|
+
'Authorization': 'Bearer token123',
|
|
394
|
+
},
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
await router.route(request)
|
|
398
|
+
|
|
399
|
+
const forwardedRequest = mockStub.fetch.mock.calls[0][0] as Request
|
|
400
|
+
expect(forwardedRequest.headers.get('Content-Type')).toBe('application/json')
|
|
401
|
+
expect(forwardedRequest.headers.get('Authorization')).toBe('Bearer token123')
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('should forward request body to DO', async () => {
|
|
405
|
+
const router = createTenantRouter({
|
|
406
|
+
doNamespace: mockNamespace,
|
|
407
|
+
extractTenant: 'header',
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const body = JSON.stringify({ data: 'test' })
|
|
411
|
+
const request = createMockRequest('https://app.com/api', {
|
|
412
|
+
method: 'POST',
|
|
413
|
+
headers: {
|
|
414
|
+
'X-Tenant-ID': 'tenant1',
|
|
415
|
+
'Content-Type': 'application/json',
|
|
416
|
+
},
|
|
417
|
+
body,
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
await router.route(request)
|
|
421
|
+
|
|
422
|
+
const forwardedRequest = mockStub.fetch.mock.calls[0][0] as Request
|
|
423
|
+
const forwardedBody = await forwardedRequest.text()
|
|
424
|
+
expect(forwardedBody).toBe(body)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('should forward request method to DO', async () => {
|
|
428
|
+
const router = createTenantRouter({
|
|
429
|
+
doNamespace: mockNamespace,
|
|
430
|
+
extractTenant: 'header',
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
const request = createMockRequest('https://app.com/api', {
|
|
434
|
+
method: 'PUT',
|
|
435
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
await router.route(request)
|
|
439
|
+
|
|
440
|
+
const forwardedRequest = mockStub.fetch.mock.calls[0][0] as Request
|
|
441
|
+
expect(forwardedRequest.method).toBe('PUT')
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('should add X-Tenant-ID header to forwarded request', async () => {
|
|
445
|
+
const router = createTenantRouter({
|
|
446
|
+
doNamespace: mockNamespace,
|
|
447
|
+
extractTenant: 'subdomain',
|
|
448
|
+
baseDomain: 'app.com',
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
const request = createMockRequest('https://tenant1.app.com/api')
|
|
452
|
+
|
|
453
|
+
await router.route(request)
|
|
454
|
+
|
|
455
|
+
const forwardedRequest = mockStub.fetch.mock.calls[0][0] as Request
|
|
456
|
+
expect(forwardedRequest.headers.get('X-Tenant-ID')).toBe('tenant1')
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('should preserve query string in forwarded request', async () => {
|
|
460
|
+
const router = createTenantRouter({
|
|
461
|
+
doNamespace: mockNamespace,
|
|
462
|
+
extractTenant: 'header',
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
const request = createMockRequest('https://app.com/api?foo=bar&baz=qux', {
|
|
466
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
await router.route(request)
|
|
470
|
+
|
|
471
|
+
const forwardedRequest = mockStub.fetch.mock.calls[0][0] as Request
|
|
472
|
+
const url = new URL(forwardedRequest.url)
|
|
473
|
+
expect(url.searchParams.get('foo')).toBe('bar')
|
|
474
|
+
expect(url.searchParams.get('baz')).toBe('qux')
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('should handle streaming request bodies', async () => {
|
|
478
|
+
const router = createTenantRouter({
|
|
479
|
+
doNamespace: mockNamespace,
|
|
480
|
+
extractTenant: 'header',
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
// Create a streaming body using ReadableStream
|
|
484
|
+
const stream = new ReadableStream({
|
|
485
|
+
start(controller) {
|
|
486
|
+
controller.enqueue(new TextEncoder().encode('chunk1'))
|
|
487
|
+
controller.enqueue(new TextEncoder().encode('chunk2'))
|
|
488
|
+
controller.close()
|
|
489
|
+
},
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
const request = new Request('https://app.com/api', {
|
|
493
|
+
method: 'POST',
|
|
494
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
495
|
+
body: stream,
|
|
496
|
+
// @ts-expect-error duplex is needed for streaming
|
|
497
|
+
duplex: 'half',
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
await router.route(request)
|
|
501
|
+
expect(mockStub.fetch).toHaveBeenCalled()
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('should return DO response directly to caller', async () => {
|
|
505
|
+
mockStub.fetch.mockResolvedValue(
|
|
506
|
+
new Response(JSON.stringify({ result: 'success' }), {
|
|
507
|
+
status: 201,
|
|
508
|
+
headers: { 'Content-Type': 'application/json' },
|
|
509
|
+
})
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
const router = createTenantRouter({
|
|
513
|
+
doNamespace: mockNamespace,
|
|
514
|
+
extractTenant: 'header',
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
const request = createMockRequest('https://app.com/api', {
|
|
518
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
const response = await router.route(request)
|
|
522
|
+
expect(response.status).toBe(201)
|
|
523
|
+
expect(response.headers.get('Content-Type')).toBe('application/json')
|
|
524
|
+
const body = await response.json()
|
|
525
|
+
expect(body).toEqual({ result: 'success' })
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('should timeout DO requests after configured duration', async () => {
|
|
529
|
+
// The timeout is implemented using AbortController
|
|
530
|
+
// For this test, we simulate the abort by having fetch reject with AbortError
|
|
531
|
+
const abortError = new Error('Aborted')
|
|
532
|
+
abortError.name = 'AbortError'
|
|
533
|
+
mockStub.fetch.mockRejectedValue(abortError)
|
|
534
|
+
|
|
535
|
+
const router = createTenantRouter({
|
|
536
|
+
doNamespace: mockNamespace,
|
|
537
|
+
extractTenant: 'header',
|
|
538
|
+
requestTimeoutMs: 50, // Very short timeout for testing
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
const request = createMockRequest('https://app.com/api', {
|
|
542
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
const response = await router.route(request)
|
|
546
|
+
expect(response.status).toBe(504)
|
|
547
|
+
})
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
describe('tenant isolation', () => {
|
|
551
|
+
let mockNamespace: DurableObjectNamespace
|
|
552
|
+
let stubCalls: Map<string, DurableObjectStub>
|
|
553
|
+
|
|
554
|
+
beforeEach(() => {
|
|
555
|
+
stubCalls = new Map()
|
|
556
|
+
mockNamespace = {
|
|
557
|
+
idFromName: vi.fn((name: string) => ({ name, toString: () => `id-${name}` })),
|
|
558
|
+
idFromString: vi.fn((id: string) => ({ id, toString: () => id })),
|
|
559
|
+
newUniqueId: vi.fn(() => ({ toString: () => 'unique-id' })),
|
|
560
|
+
get: vi.fn((id: { name: string }) => {
|
|
561
|
+
const stub = {
|
|
562
|
+
fetch: vi.fn().mockResolvedValue(new Response(`Response for ${id.name}`)),
|
|
563
|
+
}
|
|
564
|
+
stubCalls.set(id.name, stub as unknown as DurableObjectStub)
|
|
565
|
+
return stub
|
|
566
|
+
}),
|
|
567
|
+
jurisdiction: vi.fn(),
|
|
568
|
+
} as unknown as DurableObjectNamespace
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('should never route tenant1 requests to tenant2 DO', async () => {
|
|
572
|
+
const router = createTenantRouter({
|
|
573
|
+
doNamespace: mockNamespace,
|
|
574
|
+
extractTenant: 'header',
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
const request1 = createMockRequest('https://app.com/api', {
|
|
578
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
579
|
+
})
|
|
580
|
+
const request2 = createMockRequest('https://app.com/api', {
|
|
581
|
+
headers: { 'X-Tenant-ID': 'tenant2' },
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
await router.route(request1)
|
|
585
|
+
await router.route(request2)
|
|
586
|
+
|
|
587
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith('tenant1')
|
|
588
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith('tenant2')
|
|
589
|
+
expect(stubCalls.has('tenant1')).toBe(true)
|
|
590
|
+
expect(stubCalls.has('tenant2')).toBe(true)
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('should create separate DO for each unique tenant', async () => {
|
|
594
|
+
const router = createTenantRouter({
|
|
595
|
+
doNamespace: mockNamespace,
|
|
596
|
+
extractTenant: 'header',
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
for (const tenant of ['tenant1', 'tenant2', 'tenant3']) {
|
|
600
|
+
const request = createMockRequest('https://app.com/api', {
|
|
601
|
+
headers: { 'X-Tenant-ID': tenant },
|
|
602
|
+
})
|
|
603
|
+
await router.route(request)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledTimes(3)
|
|
607
|
+
expect(stubCalls.size).toBe(3)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('should use consistent DO ID for same tenant across requests', async () => {
|
|
611
|
+
const router = createTenantRouter({
|
|
612
|
+
doNamespace: mockNamespace,
|
|
613
|
+
extractTenant: 'header',
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
for (let i = 0; i < 3; i++) {
|
|
617
|
+
const request = createMockRequest('https://app.com/api', {
|
|
618
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
619
|
+
})
|
|
620
|
+
await router.route(request)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// All calls should use the same tenant ID
|
|
624
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith('tenant1')
|
|
625
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledTimes(3)
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
it('should not leak tenant information in error responses', async () => {
|
|
629
|
+
const router = createTenantRouter({
|
|
630
|
+
doNamespace: mockNamespace,
|
|
631
|
+
extractTenant: 'header',
|
|
632
|
+
blockedTenants: ['blocked-tenant'],
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
const request = createMockRequest('https://app.com/api', {
|
|
636
|
+
headers: { 'X-Tenant-ID': 'blocked-tenant' },
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
const response = await router.route(request)
|
|
640
|
+
const body = await response.json() as { error: string }
|
|
641
|
+
|
|
642
|
+
// Should return generic 404, not mention tenant
|
|
643
|
+
expect(response.status).toBe(404)
|
|
644
|
+
expect(body.error).toBe('Not Found')
|
|
645
|
+
expect(body.error).not.toContain('blocked-tenant')
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it('should validate tenant ID format before routing', async () => {
|
|
649
|
+
const router = createTenantRouter({
|
|
650
|
+
doNamespace: mockNamespace,
|
|
651
|
+
extractTenant: 'header',
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
const request = createMockRequest('https://app.com/api', {
|
|
655
|
+
headers: { 'X-Tenant-ID': '../malicious' },
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
const response = await router.route(request)
|
|
659
|
+
expect(response.status).toBe(400)
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('should prevent tenant ID injection via path traversal', async () => {
|
|
663
|
+
const router = createTenantRouter({
|
|
664
|
+
doNamespace: mockNamespace,
|
|
665
|
+
extractTenant: 'header',
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
// Using header-based extraction to test the validation directly
|
|
669
|
+
// Path-based URLs get normalized by URL parser before we see them
|
|
670
|
+
const request = createMockRequest('https://app.com/api', {
|
|
671
|
+
headers: { 'X-Tenant-ID': 'tenant/../../etc/passwd' },
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
const response = await router.route(request)
|
|
675
|
+
expect(response.status).toBe(400)
|
|
676
|
+
})
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
describe('configuration', () => {
|
|
680
|
+
let mockNamespace: DurableObjectNamespace
|
|
681
|
+
|
|
682
|
+
beforeEach(() => {
|
|
683
|
+
mockNamespace = createMockDONamespace()
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
it('should support subdomain-based tenancy mode', async () => {
|
|
687
|
+
const router = createTenantRouter({
|
|
688
|
+
doNamespace: mockNamespace,
|
|
689
|
+
extractTenant: 'subdomain',
|
|
690
|
+
baseDomain: 'app.com',
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
const request = createMockRequest('https://tenant1.app.com/api')
|
|
694
|
+
const tenantId = await router.getTenantId(request)
|
|
695
|
+
expect(tenantId).toBe('tenant1')
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('should support path-based tenancy mode: /tenant1/...', async () => {
|
|
699
|
+
const router = createTenantRouter({
|
|
700
|
+
doNamespace: mockNamespace,
|
|
701
|
+
extractTenant: 'path',
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
const request = createMockRequest('https://app.com/tenant1/api')
|
|
705
|
+
const tenantId = await router.getTenantId(request)
|
|
706
|
+
expect(tenantId).toBe('tenant1')
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('should support header-based tenancy mode: X-Tenant-ID', async () => {
|
|
710
|
+
const router = createTenantRouter({
|
|
711
|
+
doNamespace: mockNamespace,
|
|
712
|
+
extractTenant: 'header',
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
const request = createMockRequest('https://app.com/api', {
|
|
716
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
717
|
+
})
|
|
718
|
+
const tenantId = await router.getTenantId(request)
|
|
719
|
+
expect(tenantId).toBe('tenant1')
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
it('should support custom tenant resolver function', async () => {
|
|
723
|
+
const customResolver: SimpleTenantExtractor = (request: Request) => {
|
|
724
|
+
const url = new URL(request.url)
|
|
725
|
+
return url.searchParams.get('org')
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const router = createTenantRouter({
|
|
729
|
+
doNamespace: mockNamespace,
|
|
730
|
+
extractTenant: customResolver,
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
const request = createMockRequest('https://app.com/api?org=myorg')
|
|
734
|
+
const tenantId = await router.getTenantId(request)
|
|
735
|
+
expect(tenantId).toBe('myorg')
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
it('should allow combining multiple extraction strategies with priority', async () => {
|
|
739
|
+
const combinedExtractor = extractors.combined([
|
|
740
|
+
extractors.header(),
|
|
741
|
+
extractors.subdomain('app.com'),
|
|
742
|
+
extractors.path(),
|
|
743
|
+
])
|
|
744
|
+
|
|
745
|
+
const router = createTenantRouter({
|
|
746
|
+
doNamespace: mockNamespace,
|
|
747
|
+
extractTenant: combinedExtractor,
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
// Header takes priority
|
|
751
|
+
const req1 = createMockRequest('https://tenant2.app.com/tenant3/api', {
|
|
752
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
753
|
+
})
|
|
754
|
+
expect(await router.getTenantId(req1)).toBe('tenant1')
|
|
755
|
+
|
|
756
|
+
// Subdomain is next
|
|
757
|
+
const req2 = createMockRequest('https://tenant2.app.com/tenant3/api')
|
|
758
|
+
expect(await router.getTenantId(req2)).toBe('tenant2')
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
it('should support tenant ID transformation function', async () => {
|
|
762
|
+
const router = createTenantRouter({
|
|
763
|
+
doNamespace: mockNamespace,
|
|
764
|
+
extractTenant: 'header',
|
|
765
|
+
transformTenant: (id) => `prefix_${id.toLowerCase()}`,
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
const request = createMockRequest('https://app.com/api', {
|
|
769
|
+
headers: { 'X-Tenant-ID': 'MyTenant' },
|
|
770
|
+
})
|
|
771
|
+
const tenantId = await router.getTenantId(request)
|
|
772
|
+
expect(tenantId).toBe('prefix_mytenant')
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
it('should support configurable DO namespace', async () => {
|
|
776
|
+
const namespace1 = createMockDONamespace()
|
|
777
|
+
const namespace2 = createMockDONamespace()
|
|
778
|
+
|
|
779
|
+
const router1 = createTenantRouter({
|
|
780
|
+
doNamespace: namespace1,
|
|
781
|
+
extractTenant: 'header',
|
|
782
|
+
})
|
|
783
|
+
const router2 = createTenantRouter({
|
|
784
|
+
doNamespace: namespace2,
|
|
785
|
+
extractTenant: 'header',
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
const request = createMockRequest('https://app.com/api', {
|
|
789
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
await router1.route(request)
|
|
793
|
+
await router2.route(request)
|
|
794
|
+
|
|
795
|
+
expect(namespace1.idFromName).toHaveBeenCalledWith('tenant1')
|
|
796
|
+
expect(namespace2.idFromName).toHaveBeenCalledWith('tenant1')
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
it('should support request timeout configuration', () => {
|
|
800
|
+
// This is validated by the timeout test in DO routing section
|
|
801
|
+
const router = createTenantRouter({
|
|
802
|
+
doNamespace: mockNamespace,
|
|
803
|
+
extractTenant: 'header',
|
|
804
|
+
requestTimeoutMs: 5000,
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
expect(router).toBeDefined()
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it('should support base path stripping for path-based tenancy', async () => {
|
|
811
|
+
const router = createTenantRouter({
|
|
812
|
+
doNamespace: mockNamespace,
|
|
813
|
+
extractTenant: 'path',
|
|
814
|
+
pathPrefix: '/v1/tenants',
|
|
815
|
+
stripTenantFromPath: true,
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
const request = createMockRequest('https://app.com/v1/tenants/tenant1/users/123')
|
|
819
|
+
const result = await router.extractTenant(request)
|
|
820
|
+
|
|
821
|
+
expect(result.tenantId).toBe('tenant1')
|
|
822
|
+
expect(result.modifiedPath).toBe('/users/123')
|
|
823
|
+
})
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
describe('blocked tenants', () => {
|
|
827
|
+
let mockNamespace: DurableObjectNamespace
|
|
828
|
+
|
|
829
|
+
beforeEach(() => {
|
|
830
|
+
mockNamespace = createMockDONamespace()
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
it('should return 404 for blocked tenants', async () => {
|
|
834
|
+
const router = createTenantRouter({
|
|
835
|
+
doNamespace: mockNamespace,
|
|
836
|
+
extractTenant: 'header',
|
|
837
|
+
blockedTenants: ['blocked'],
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
const request = createMockRequest('https://app.com/api', {
|
|
841
|
+
headers: { 'X-Tenant-ID': 'blocked' },
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
const response = await router.route(request)
|
|
845
|
+
expect(response.status).toBe(404)
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
it('should check blocked list before routing', async () => {
|
|
849
|
+
const router = createTenantRouter({
|
|
850
|
+
doNamespace: mockNamespace,
|
|
851
|
+
extractTenant: 'header',
|
|
852
|
+
blockedTenants: ['blocked'],
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
const request = createMockRequest('https://app.com/api', {
|
|
856
|
+
headers: { 'X-Tenant-ID': 'blocked' },
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
await router.route(request)
|
|
860
|
+
|
|
861
|
+
// Should not have routed to DO
|
|
862
|
+
expect(mockNamespace.get).not.toHaveBeenCalled()
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
it('should support wildcard patterns in blocked list: admin*', async () => {
|
|
866
|
+
const router = createTenantRouter({
|
|
867
|
+
doNamespace: mockNamespace,
|
|
868
|
+
extractTenant: 'header',
|
|
869
|
+
blockedTenants: ['admin*'],
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
const request1 = createMockRequest('https://app.com/api', {
|
|
873
|
+
headers: { 'X-Tenant-ID': 'admin' },
|
|
874
|
+
})
|
|
875
|
+
const request2 = createMockRequest('https://app.com/api', {
|
|
876
|
+
headers: { 'X-Tenant-ID': 'admin123' },
|
|
877
|
+
})
|
|
878
|
+
const request3 = createMockRequest('https://app.com/api', {
|
|
879
|
+
headers: { 'X-Tenant-ID': 'administrator' },
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
expect((await router.route(request1)).status).toBe(404)
|
|
883
|
+
expect((await router.route(request2)).status).toBe(404)
|
|
884
|
+
expect((await router.route(request3)).status).toBe(404)
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
it('should support blocked tenant callback for dynamic checks', async () => {
|
|
888
|
+
const blockedCallback = vi.fn().mockResolvedValue(true)
|
|
889
|
+
|
|
890
|
+
const router = createTenantRouter({
|
|
891
|
+
doNamespace: mockNamespace,
|
|
892
|
+
extractTenant: 'header',
|
|
893
|
+
isBlocked: blockedCallback,
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
const request = createMockRequest('https://app.com/api', {
|
|
897
|
+
headers: { 'X-Tenant-ID': 'dynamically-blocked' },
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
const response = await router.route(request)
|
|
901
|
+
expect(response.status).toBe(404)
|
|
902
|
+
expect(blockedCallback).toHaveBeenCalledWith('dynamically-blocked', expect.any(Request))
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
it('should log blocked tenant access attempts', async () => {
|
|
906
|
+
// The implementation uses error formatter for blocked tenants
|
|
907
|
+
// We can verify through the response that includes correlation ID
|
|
908
|
+
const router = createTenantRouter({
|
|
909
|
+
doNamespace: mockNamespace,
|
|
910
|
+
extractTenant: 'header',
|
|
911
|
+
blockedTenants: ['blocked'],
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
const request = createMockRequest('https://app.com/api', {
|
|
915
|
+
headers: { 'X-Tenant-ID': 'blocked' },
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
const response = await router.route(request)
|
|
919
|
+
expect(response.headers.get('X-Correlation-ID')).toBeTruthy()
|
|
920
|
+
})
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
describe('error handling', () => {
|
|
924
|
+
let mockNamespace: DurableObjectNamespace
|
|
925
|
+
let mockStub: { fetch: ReturnType<typeof vi.fn> }
|
|
926
|
+
|
|
927
|
+
beforeEach(() => {
|
|
928
|
+
mockStub = {
|
|
929
|
+
fetch: vi.fn().mockResolvedValue(new Response('OK', { status: 200 })),
|
|
930
|
+
}
|
|
931
|
+
mockNamespace = {
|
|
932
|
+
idFromName: vi.fn((name: string) => ({ name, toString: () => `id-${name}` })),
|
|
933
|
+
idFromString: vi.fn((id: string) => ({ id, toString: () => id })),
|
|
934
|
+
newUniqueId: vi.fn(() => ({ toString: () => 'unique-id' })),
|
|
935
|
+
get: vi.fn(() => mockStub),
|
|
936
|
+
jurisdiction: vi.fn(),
|
|
937
|
+
} as unknown as DurableObjectNamespace
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
it('should return 400 for missing tenant identifier', async () => {
|
|
941
|
+
const router = createTenantRouter({
|
|
942
|
+
doNamespace: mockNamespace,
|
|
943
|
+
extractTenant: 'header',
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
const request = createMockRequest('https://app.com/api')
|
|
947
|
+
const response = await router.route(request)
|
|
948
|
+
expect(response.status).toBe(400)
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
it('should return 400 for invalid tenant identifier format', async () => {
|
|
952
|
+
const router = createTenantRouter({
|
|
953
|
+
doNamespace: mockNamespace,
|
|
954
|
+
extractTenant: 'header',
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
const request = createMockRequest('https://app.com/api', {
|
|
958
|
+
headers: { 'X-Tenant-ID': 'tenant/../hack' },
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
const response = await router.route(request)
|
|
962
|
+
expect(response.status).toBe(400)
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
it('should return 404 for blocked tenants', async () => {
|
|
966
|
+
const router = createTenantRouter({
|
|
967
|
+
doNamespace: mockNamespace,
|
|
968
|
+
extractTenant: 'header',
|
|
969
|
+
blockedTenants: ['blocked'],
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
const request = createMockRequest('https://app.com/api', {
|
|
973
|
+
headers: { 'X-Tenant-ID': 'blocked' },
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
const response = await router.route(request)
|
|
977
|
+
expect(response.status).toBe(404)
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
it('should return 502 when DO is unreachable', async () => {
|
|
981
|
+
mockStub.fetch.mockRejectedValue(new Error('Network error'))
|
|
982
|
+
|
|
983
|
+
const router = createTenantRouter({
|
|
984
|
+
doNamespace: mockNamespace,
|
|
985
|
+
extractTenant: 'header',
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
const request = createMockRequest('https://app.com/api', {
|
|
989
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
const response = await router.route(request)
|
|
993
|
+
expect(response.status).toBe(502)
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
it('should return 504 when DO request times out', async () => {
|
|
997
|
+
// Simulate AbortError which is what happens when timeout triggers
|
|
998
|
+
const abortError = new Error('Aborted')
|
|
999
|
+
abortError.name = 'AbortError'
|
|
1000
|
+
mockStub.fetch.mockRejectedValue(abortError)
|
|
1001
|
+
|
|
1002
|
+
const router = createTenantRouter({
|
|
1003
|
+
doNamespace: mockNamespace,
|
|
1004
|
+
extractTenant: 'header',
|
|
1005
|
+
requestTimeoutMs: 50,
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1009
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
const response = await router.route(request)
|
|
1013
|
+
expect(response.status).toBe(504)
|
|
1014
|
+
})
|
|
1015
|
+
|
|
1016
|
+
it('should handle DO errors gracefully with proper status codes', async () => {
|
|
1017
|
+
mockStub.fetch.mockResolvedValue(new Response('Service Error', { status: 500 }))
|
|
1018
|
+
|
|
1019
|
+
const router = createTenantRouter({
|
|
1020
|
+
doNamespace: mockNamespace,
|
|
1021
|
+
extractTenant: 'header',
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1024
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1025
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
// DO errors should pass through
|
|
1029
|
+
const response = await router.route(request)
|
|
1030
|
+
expect(response.status).toBe(500)
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('should include correlation ID in error responses', async () => {
|
|
1034
|
+
const router = createTenantRouter({
|
|
1035
|
+
doNamespace: mockNamespace,
|
|
1036
|
+
extractTenant: 'header',
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
const request = createMockRequest('https://app.com/api')
|
|
1040
|
+
const response = await router.route(request)
|
|
1041
|
+
|
|
1042
|
+
expect(response.headers.get('X-Correlation-ID')).toBeTruthy()
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
it('should not expose internal error details to client', async () => {
|
|
1046
|
+
mockStub.fetch.mockRejectedValue(new Error('Internal database connection failed'))
|
|
1047
|
+
|
|
1048
|
+
const router = createTenantRouter({
|
|
1049
|
+
doNamespace: mockNamespace,
|
|
1050
|
+
extractTenant: 'header',
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1054
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
const response = await router.route(request)
|
|
1058
|
+
const body = await response.json() as { error: string }
|
|
1059
|
+
|
|
1060
|
+
expect(body.error).not.toContain('database')
|
|
1061
|
+
expect(body.error).toBe('Bad Gateway')
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
it('should support custom error response formatter', async () => {
|
|
1065
|
+
const customFormatter = vi.fn().mockReturnValue(
|
|
1066
|
+
new Response('Custom Error', { status: 418 })
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
const router = createTenantRouter({
|
|
1070
|
+
doNamespace: mockNamespace,
|
|
1071
|
+
extractTenant: 'header',
|
|
1072
|
+
formatError: customFormatter,
|
|
1073
|
+
})
|
|
1074
|
+
|
|
1075
|
+
const request = createMockRequest('https://app.com/api')
|
|
1076
|
+
const response = await router.route(request)
|
|
1077
|
+
|
|
1078
|
+
expect(response.status).toBe(418)
|
|
1079
|
+
expect(customFormatter).toHaveBeenCalled()
|
|
1080
|
+
})
|
|
1081
|
+
})
|
|
1082
|
+
|
|
1083
|
+
describe('observability', () => {
|
|
1084
|
+
let mockNamespace: DurableObjectNamespace
|
|
1085
|
+
let mockStub: { fetch: ReturnType<typeof vi.fn> }
|
|
1086
|
+
|
|
1087
|
+
beforeEach(() => {
|
|
1088
|
+
mockStub = {
|
|
1089
|
+
fetch: vi.fn().mockResolvedValue(new Response('OK', { status: 200 })),
|
|
1090
|
+
}
|
|
1091
|
+
mockNamespace = {
|
|
1092
|
+
idFromName: vi.fn((name: string) => ({ name, toString: () => `id-${name}` })),
|
|
1093
|
+
idFromString: vi.fn((id: string) => ({ id, toString: () => id })),
|
|
1094
|
+
newUniqueId: vi.fn(() => ({ toString: () => 'unique-id' })),
|
|
1095
|
+
get: vi.fn(() => mockStub),
|
|
1096
|
+
jurisdiction: vi.fn(),
|
|
1097
|
+
} as unknown as DurableObjectNamespace
|
|
1098
|
+
})
|
|
1099
|
+
|
|
1100
|
+
it('should emit metrics for each routed request', async () => {
|
|
1101
|
+
const recordedMetrics: RequestMetrics[] = []
|
|
1102
|
+
const metricsCollector: MetricsCollector = {
|
|
1103
|
+
recordRequest: vi.fn((metrics: RequestMetrics) => {
|
|
1104
|
+
recordedMetrics.push(metrics)
|
|
1105
|
+
}),
|
|
1106
|
+
incrementRequestCount: vi.fn(),
|
|
1107
|
+
recordLatency: vi.fn(),
|
|
1108
|
+
recordDoResponseTime: vi.fn(),
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const router = createTenantRouter({
|
|
1112
|
+
doNamespace: mockNamespace,
|
|
1113
|
+
extractTenant: 'header',
|
|
1114
|
+
metricsCollector,
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
const request = createMockRequest('https://app.com/api/users', {
|
|
1118
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
await router.route(request)
|
|
1122
|
+
|
|
1123
|
+
expect(metricsCollector.recordRequest).toHaveBeenCalledTimes(1)
|
|
1124
|
+
expect(recordedMetrics[0].tenantId).toBe('tenant1')
|
|
1125
|
+
expect(recordedMetrics[0].method).toBe('GET')
|
|
1126
|
+
expect(recordedMetrics[0].path).toBe('/api/users')
|
|
1127
|
+
expect(recordedMetrics[0].status).toBe(200)
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
it('should track request latency', async () => {
|
|
1131
|
+
const latencies: Array<{ tenantId: string; latencyMs: number }> = []
|
|
1132
|
+
const metricsCollector: MetricsCollector = {
|
|
1133
|
+
recordRequest: vi.fn(),
|
|
1134
|
+
incrementRequestCount: vi.fn(),
|
|
1135
|
+
recordLatency: vi.fn((tenantId: string, latencyMs: number) => {
|
|
1136
|
+
latencies.push({ tenantId, latencyMs })
|
|
1137
|
+
}),
|
|
1138
|
+
recordDoResponseTime: vi.fn(),
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const router = createTenantRouter({
|
|
1142
|
+
doNamespace: mockNamespace,
|
|
1143
|
+
extractTenant: 'header',
|
|
1144
|
+
metricsCollector,
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1148
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
await router.route(request)
|
|
1152
|
+
|
|
1153
|
+
expect(metricsCollector.recordLatency).toHaveBeenCalledTimes(1)
|
|
1154
|
+
expect(latencies[0].tenantId).toBe('tenant1')
|
|
1155
|
+
expect(latencies[0].latencyMs).toBeGreaterThanOrEqual(0)
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
it('should count requests per tenant', async () => {
|
|
1159
|
+
const requestCounts: string[] = []
|
|
1160
|
+
const metricsCollector: MetricsCollector = {
|
|
1161
|
+
recordRequest: vi.fn(),
|
|
1162
|
+
incrementRequestCount: vi.fn((tenantId: string) => {
|
|
1163
|
+
requestCounts.push(tenantId)
|
|
1164
|
+
}),
|
|
1165
|
+
recordLatency: vi.fn(),
|
|
1166
|
+
recordDoResponseTime: vi.fn(),
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const router = createTenantRouter({
|
|
1170
|
+
doNamespace: mockNamespace,
|
|
1171
|
+
extractTenant: 'header',
|
|
1172
|
+
metricsCollector,
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
// Make requests for different tenants
|
|
1176
|
+
const request1 = createMockRequest('https://app.com/api', {
|
|
1177
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1178
|
+
})
|
|
1179
|
+
const request2 = createMockRequest('https://app.com/api', {
|
|
1180
|
+
headers: { 'X-Tenant-ID': 'tenant2' },
|
|
1181
|
+
})
|
|
1182
|
+
const request3 = createMockRequest('https://app.com/api', {
|
|
1183
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
await router.route(request1)
|
|
1187
|
+
await router.route(request2)
|
|
1188
|
+
await router.route(request3)
|
|
1189
|
+
|
|
1190
|
+
expect(metricsCollector.incrementRequestCount).toHaveBeenCalledTimes(3)
|
|
1191
|
+
expect(requestCounts).toEqual(['tenant1', 'tenant2', 'tenant1'])
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
it('should support custom metrics collector', async () => {
|
|
1195
|
+
const doResponseTimes: Array<{ tenantId: string; responseTimeMs: number }> = []
|
|
1196
|
+
const customCollector: MetricsCollector = {
|
|
1197
|
+
recordRequest: vi.fn(),
|
|
1198
|
+
incrementRequestCount: vi.fn(),
|
|
1199
|
+
recordLatency: vi.fn(),
|
|
1200
|
+
recordDoResponseTime: vi.fn((tenantId: string, responseTimeMs: number) => {
|
|
1201
|
+
doResponseTimes.push({ tenantId, responseTimeMs })
|
|
1202
|
+
}),
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const router = createTenantRouter({
|
|
1206
|
+
doNamespace: mockNamespace,
|
|
1207
|
+
extractTenant: 'header',
|
|
1208
|
+
metricsCollector: customCollector,
|
|
1209
|
+
})
|
|
1210
|
+
|
|
1211
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1212
|
+
headers: { 'X-Tenant-ID': 'custom-tenant' },
|
|
1213
|
+
})
|
|
1214
|
+
|
|
1215
|
+
await router.route(request)
|
|
1216
|
+
|
|
1217
|
+
expect(customCollector.recordDoResponseTime).toHaveBeenCalledTimes(1)
|
|
1218
|
+
expect(doResponseTimes[0].tenantId).toBe('custom-tenant')
|
|
1219
|
+
expect(doResponseTimes[0].responseTimeMs).toBeGreaterThanOrEqual(0)
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
it('should include trace context in forwarded requests', async () => {
|
|
1223
|
+
const traceContextExtractor: TraceContextExtractor = (request: Request): TraceContext | null => {
|
|
1224
|
+
const traceId = request.headers.get('X-Trace-ID')
|
|
1225
|
+
const spanId = request.headers.get('X-Span-ID')
|
|
1226
|
+
if (traceId && spanId) {
|
|
1227
|
+
return {
|
|
1228
|
+
traceId,
|
|
1229
|
+
spanId,
|
|
1230
|
+
parentSpanId: request.headers.get('X-Parent-Span-ID') || undefined,
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return null
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const router = createTenantRouter({
|
|
1237
|
+
doNamespace: mockNamespace,
|
|
1238
|
+
extractTenant: 'header',
|
|
1239
|
+
traceContextExtractor,
|
|
1240
|
+
propagateTraceContext: true,
|
|
1241
|
+
})
|
|
1242
|
+
|
|
1243
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1244
|
+
headers: {
|
|
1245
|
+
'X-Tenant-ID': 'tenant1',
|
|
1246
|
+
'X-Trace-ID': 'trace-abc123',
|
|
1247
|
+
'X-Span-ID': 'span-def456',
|
|
1248
|
+
'X-Parent-Span-ID': 'parent-ghi789',
|
|
1249
|
+
},
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
await router.route(request)
|
|
1253
|
+
|
|
1254
|
+
const forwardedRequest = mockStub.fetch.mock.calls[0][0] as Request
|
|
1255
|
+
expect(forwardedRequest.headers.get('X-Trace-ID')).toBe('trace-abc123')
|
|
1256
|
+
expect(forwardedRequest.headers.get('X-Span-ID')).toBe('span-def456')
|
|
1257
|
+
expect(forwardedRequest.headers.get('X-Parent-Span-ID')).toBe('parent-ghi789')
|
|
1258
|
+
})
|
|
1259
|
+
|
|
1260
|
+
it('should log tenant extraction failures', async () => {
|
|
1261
|
+
const logEntries: Array<{ level: string; message: string; data?: Record<string, unknown> }> = []
|
|
1262
|
+
const mockLogger: TenantRouterLogger = {
|
|
1263
|
+
log: vi.fn(),
|
|
1264
|
+
debug: vi.fn((message: string, data?: Record<string, unknown>) => {
|
|
1265
|
+
logEntries.push({ level: 'debug', message, data })
|
|
1266
|
+
}),
|
|
1267
|
+
info: vi.fn(),
|
|
1268
|
+
warn: vi.fn((message: string, data?: Record<string, unknown>) => {
|
|
1269
|
+
logEntries.push({ level: 'warn', message, data })
|
|
1270
|
+
}),
|
|
1271
|
+
error: vi.fn(),
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const router = createTenantRouter({
|
|
1275
|
+
doNamespace: mockNamespace,
|
|
1276
|
+
extractTenant: 'header',
|
|
1277
|
+
logger: mockLogger,
|
|
1278
|
+
})
|
|
1279
|
+
|
|
1280
|
+
// Request without tenant header - should fail extraction
|
|
1281
|
+
const request = createMockRequest('https://app.com/api')
|
|
1282
|
+
|
|
1283
|
+
await router.route(request)
|
|
1284
|
+
|
|
1285
|
+
expect(mockLogger.warn).toHaveBeenCalled()
|
|
1286
|
+
const failureLog = logEntries.find(e => e.level === 'warn' && e.message.includes('extraction failed'))
|
|
1287
|
+
expect(failureLog).toBeDefined()
|
|
1288
|
+
expect(failureLog?.data?.path).toBe('/api')
|
|
1289
|
+
})
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
describe('caching', () => {
|
|
1293
|
+
it('should cache custom domain to tenant mappings', () => {
|
|
1294
|
+
const cache = createDomainMappingCache({ ttlMs: 300000 }) // 5 minute TTL
|
|
1295
|
+
|
|
1296
|
+
// Set some domain mappings
|
|
1297
|
+
cache.set('custom.example.org', 'tenant1')
|
|
1298
|
+
cache.set('another.example.org', 'tenant2')
|
|
1299
|
+
|
|
1300
|
+
// Verify they are cached
|
|
1301
|
+
const entry1 = cache.get('custom.example.org')
|
|
1302
|
+
const entry2 = cache.get('another.example.org')
|
|
1303
|
+
|
|
1304
|
+
expect(entry1).not.toBeNull()
|
|
1305
|
+
expect(entry1!.tenantId).toBe('tenant1')
|
|
1306
|
+
expect(entry2).not.toBeNull()
|
|
1307
|
+
expect(entry2!.tenantId).toBe('tenant2')
|
|
1308
|
+
|
|
1309
|
+
// Verify cache stats
|
|
1310
|
+
const stats = cache.stats()
|
|
1311
|
+
expect(stats.hits).toBe(2)
|
|
1312
|
+
expect(stats.size).toBe(2)
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
it('should invalidate cache on TTL expiry', async () => {
|
|
1316
|
+
// Use a very short TTL for testing
|
|
1317
|
+
const cache = createDomainMappingCache({ ttlMs: 50 }) // 50ms TTL
|
|
1318
|
+
|
|
1319
|
+
cache.set('expires.example.org', 'tenant1')
|
|
1320
|
+
|
|
1321
|
+
// Verify entry exists immediately
|
|
1322
|
+
expect(cache.get('expires.example.org')).not.toBeNull()
|
|
1323
|
+
|
|
1324
|
+
// Wait for TTL to expire
|
|
1325
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
1326
|
+
|
|
1327
|
+
// Entry should now be expired and return null
|
|
1328
|
+
const expiredEntry = cache.get('expires.example.org')
|
|
1329
|
+
expect(expiredEntry).toBeNull()
|
|
1330
|
+
|
|
1331
|
+
// Stats should show a miss for the expired entry
|
|
1332
|
+
const stats = cache.stats()
|
|
1333
|
+
expect(stats.misses).toBeGreaterThanOrEqual(1)
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
it('should support cache warming for known tenants', () => {
|
|
1337
|
+
const cache = createDomainMappingCache({ ttlMs: 300000 })
|
|
1338
|
+
|
|
1339
|
+
// Warm cache with known domain mappings
|
|
1340
|
+
cache.warm({
|
|
1341
|
+
'acme.com': 'acme-inc',
|
|
1342
|
+
'bigcorp.io': 'bigcorp',
|
|
1343
|
+
'startup.dev': 'startup-tenant',
|
|
1344
|
+
})
|
|
1345
|
+
|
|
1346
|
+
// Verify all mappings are available
|
|
1347
|
+
expect(cache.size()).toBe(3)
|
|
1348
|
+
expect(cache.get('acme.com')?.tenantId).toBe('acme-inc')
|
|
1349
|
+
expect(cache.get('bigcorp.io')?.tenantId).toBe('bigcorp')
|
|
1350
|
+
expect(cache.get('startup.dev')?.tenantId).toBe('startup-tenant')
|
|
1351
|
+
|
|
1352
|
+
// Verify cache has entries
|
|
1353
|
+
expect(cache.has('acme.com')).toBe(true)
|
|
1354
|
+
expect(cache.has('bigcorp.io')).toBe(true)
|
|
1355
|
+
})
|
|
1356
|
+
|
|
1357
|
+
it('should handle cache miss gracefully', () => {
|
|
1358
|
+
const cache = createDomainMappingCache({ ttlMs: 300000 })
|
|
1359
|
+
|
|
1360
|
+
// Get a non-existent domain
|
|
1361
|
+
const entry = cache.get('nonexistent.example.org')
|
|
1362
|
+
expect(entry).toBeNull()
|
|
1363
|
+
|
|
1364
|
+
// has() should return false
|
|
1365
|
+
expect(cache.has('nonexistent.example.org')).toBe(false)
|
|
1366
|
+
|
|
1367
|
+
// Stats should show a miss
|
|
1368
|
+
const stats = cache.stats()
|
|
1369
|
+
expect(stats.misses).toBeGreaterThanOrEqual(1)
|
|
1370
|
+
|
|
1371
|
+
// Invalidating non-existent entry should not throw
|
|
1372
|
+
cache.invalidate('nonexistent.example.org')
|
|
1373
|
+
expect(cache.stats().invalidations).toBe(0) // No actual invalidation happened
|
|
1374
|
+
|
|
1375
|
+
// invalidateAll should work even on empty cache
|
|
1376
|
+
cache.invalidateAll()
|
|
1377
|
+
expect(cache.size()).toBe(0)
|
|
1378
|
+
})
|
|
1379
|
+
})
|
|
1380
|
+
|
|
1381
|
+
describe('rate limiting', () => {
|
|
1382
|
+
it('should enforce per-tenant rate limits', async () => {
|
|
1383
|
+
const rateLimiter = new TenantRateLimiter({
|
|
1384
|
+
maxRequests: 3,
|
|
1385
|
+
windowMs: 60000,
|
|
1386
|
+
})
|
|
1387
|
+
|
|
1388
|
+
const request = createMockRequest('https://app.com/api')
|
|
1389
|
+
|
|
1390
|
+
// First 3 requests should be allowed
|
|
1391
|
+
const result1 = await rateLimiter.check('tenant1', request)
|
|
1392
|
+
const result2 = await rateLimiter.check('tenant1', request)
|
|
1393
|
+
const result3 = await rateLimiter.check('tenant1', request)
|
|
1394
|
+
|
|
1395
|
+
expect(result1.allowed).toBe(true)
|
|
1396
|
+
expect(result1.currentCount).toBe(1)
|
|
1397
|
+
expect(result2.allowed).toBe(true)
|
|
1398
|
+
expect(result2.currentCount).toBe(2)
|
|
1399
|
+
expect(result3.allowed).toBe(true)
|
|
1400
|
+
expect(result3.currentCount).toBe(3)
|
|
1401
|
+
|
|
1402
|
+
// 4th request should be denied
|
|
1403
|
+
const result4 = await rateLimiter.check('tenant1', request)
|
|
1404
|
+
expect(result4.allowed).toBe(false)
|
|
1405
|
+
expect(result4.currentCount).toBe(3)
|
|
1406
|
+
})
|
|
1407
|
+
|
|
1408
|
+
it('should return 429 when rate limit exceeded', async () => {
|
|
1409
|
+
const rateLimiter = new TenantRateLimiter({
|
|
1410
|
+
maxRequests: 2,
|
|
1411
|
+
windowMs: 60000,
|
|
1412
|
+
})
|
|
1413
|
+
|
|
1414
|
+
const request = createMockRequest('https://app.com/api')
|
|
1415
|
+
|
|
1416
|
+
// Exhaust the rate limit
|
|
1417
|
+
await rateLimiter.check('tenant1', request)
|
|
1418
|
+
await rateLimiter.check('tenant1', request)
|
|
1419
|
+
|
|
1420
|
+
// Third request should be rate limited
|
|
1421
|
+
const result = await rateLimiter.check('tenant1', request)
|
|
1422
|
+
expect(result.allowed).toBe(false)
|
|
1423
|
+
expect(result.maxRequests).toBe(2)
|
|
1424
|
+
expect(result.retryAfterSeconds).toBeGreaterThan(0)
|
|
1425
|
+
|
|
1426
|
+
// Simulate 429 response
|
|
1427
|
+
if (!result.allowed) {
|
|
1428
|
+
const response = new Response(
|
|
1429
|
+
JSON.stringify({ error: 'Rate limit exceeded' }),
|
|
1430
|
+
{
|
|
1431
|
+
status: 429,
|
|
1432
|
+
headers: {
|
|
1433
|
+
'Content-Type': 'application/json',
|
|
1434
|
+
'Retry-After': String(result.retryAfterSeconds),
|
|
1435
|
+
},
|
|
1436
|
+
}
|
|
1437
|
+
)
|
|
1438
|
+
expect(response.status).toBe(429)
|
|
1439
|
+
expect(response.headers.get('Retry-After')).toBeTruthy()
|
|
1440
|
+
}
|
|
1441
|
+
})
|
|
1442
|
+
|
|
1443
|
+
it('should support configurable rate limit windows', async () => {
|
|
1444
|
+
// Create rate limiter with a very short window
|
|
1445
|
+
const rateLimiter = new TenantRateLimiter({
|
|
1446
|
+
maxRequests: 2,
|
|
1447
|
+
windowMs: 100, // 100ms window
|
|
1448
|
+
})
|
|
1449
|
+
|
|
1450
|
+
const request = createMockRequest('https://app.com/api')
|
|
1451
|
+
|
|
1452
|
+
// Exhaust the rate limit
|
|
1453
|
+
await rateLimiter.check('tenant1', request)
|
|
1454
|
+
await rateLimiter.check('tenant1', request)
|
|
1455
|
+
|
|
1456
|
+
// Should be rate limited
|
|
1457
|
+
const resultBefore = await rateLimiter.check('tenant1', request)
|
|
1458
|
+
expect(resultBefore.allowed).toBe(false)
|
|
1459
|
+
|
|
1460
|
+
// Wait for window to expire
|
|
1461
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
1462
|
+
|
|
1463
|
+
// Should be allowed again after window expires
|
|
1464
|
+
const resultAfter = await rateLimiter.check('tenant1', request)
|
|
1465
|
+
expect(resultAfter.allowed).toBe(true)
|
|
1466
|
+
expect(resultAfter.currentCount).toBe(1)
|
|
1467
|
+
})
|
|
1468
|
+
|
|
1469
|
+
it('should allow rate limit bypass for specific tenants', async () => {
|
|
1470
|
+
const rateLimiter = new TenantRateLimiter({
|
|
1471
|
+
maxRequests: 1,
|
|
1472
|
+
windowMs: 60000,
|
|
1473
|
+
bypassTenants: ['premium-tenant', 'admin-tenant'],
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1476
|
+
const request = createMockRequest('https://app.com/api')
|
|
1477
|
+
|
|
1478
|
+
// Regular tenant should be limited after 1 request
|
|
1479
|
+
const normalResult1 = await rateLimiter.check('regular-tenant', request)
|
|
1480
|
+
const normalResult2 = await rateLimiter.check('regular-tenant', request)
|
|
1481
|
+
expect(normalResult1.allowed).toBe(true)
|
|
1482
|
+
expect(normalResult2.allowed).toBe(false)
|
|
1483
|
+
|
|
1484
|
+
// Bypass tenant should never be limited
|
|
1485
|
+
for (let i = 0; i < 10; i++) {
|
|
1486
|
+
const result = await rateLimiter.check('premium-tenant', request)
|
|
1487
|
+
expect(result.allowed).toBe(true)
|
|
1488
|
+
expect(result.bypassed).toBe(true)
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// Another bypass tenant
|
|
1492
|
+
for (let i = 0; i < 10; i++) {
|
|
1493
|
+
const result = await rateLimiter.check('admin-tenant', request)
|
|
1494
|
+
expect(result.allowed).toBe(true)
|
|
1495
|
+
expect(result.bypassed).toBe(true)
|
|
1496
|
+
}
|
|
1497
|
+
})
|
|
1498
|
+
})
|
|
1499
|
+
|
|
1500
|
+
describe('preset extractors', () => {
|
|
1501
|
+
describe('extractors.subdomain', () => {
|
|
1502
|
+
it('should extract first subdomain segment', () => {
|
|
1503
|
+
const extractor = extractors.subdomain('example.com')
|
|
1504
|
+
const request = createMockRequest('https://tenant1.example.com/api')
|
|
1505
|
+
expect(extractor(request)).toBe('tenant1')
|
|
1506
|
+
})
|
|
1507
|
+
|
|
1508
|
+
it('should handle two-part TLDs: tenant1.app.co.uk -> tenant1', () => {
|
|
1509
|
+
const extractor = extractors.subdomain('app.co.uk')
|
|
1510
|
+
const request = createMockRequest('https://tenant1.app.co.uk/api')
|
|
1511
|
+
expect(extractor(request)).toBe('tenant1')
|
|
1512
|
+
})
|
|
1513
|
+
|
|
1514
|
+
it('should return null for apex domains', () => {
|
|
1515
|
+
const extractor = extractors.subdomain('example.com')
|
|
1516
|
+
const request = createMockRequest('https://example.com/api')
|
|
1517
|
+
expect(extractor(request)).toBe(null)
|
|
1518
|
+
})
|
|
1519
|
+
})
|
|
1520
|
+
|
|
1521
|
+
describe('extractors.path', () => {
|
|
1522
|
+
it('should extract first path segment by default', () => {
|
|
1523
|
+
const extractor = extractors.path()
|
|
1524
|
+
const request = createMockRequest('https://app.com/tenant1/api/users')
|
|
1525
|
+
expect(extractor(request)).toBe('tenant1')
|
|
1526
|
+
})
|
|
1527
|
+
|
|
1528
|
+
it('should support configurable path prefix', () => {
|
|
1529
|
+
const extractor = extractors.path('/v1/orgs')
|
|
1530
|
+
const request = createMockRequest('https://app.com/v1/orgs/myorg/users')
|
|
1531
|
+
expect(extractor(request)).toBe('myorg')
|
|
1532
|
+
})
|
|
1533
|
+
|
|
1534
|
+
it('should decode URL-encoded segments', () => {
|
|
1535
|
+
const extractor = extractors.path()
|
|
1536
|
+
const request = createMockRequest('https://app.com/my%20org/users')
|
|
1537
|
+
expect(extractor(request)).toBe('my org')
|
|
1538
|
+
})
|
|
1539
|
+
})
|
|
1540
|
+
|
|
1541
|
+
describe('extractors.header', () => {
|
|
1542
|
+
it('should return extractor function for given header name', () => {
|
|
1543
|
+
const extractor = extractors.header('X-Custom-Tenant')
|
|
1544
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1545
|
+
headers: { 'X-Custom-Tenant': 'custom-tenant' },
|
|
1546
|
+
})
|
|
1547
|
+
expect(extractor(request)).toBe('custom-tenant')
|
|
1548
|
+
})
|
|
1549
|
+
|
|
1550
|
+
it('should use X-Tenant-ID as default header name', () => {
|
|
1551
|
+
const extractor = extractors.header()
|
|
1552
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1553
|
+
headers: { 'X-Tenant-ID': 'default-header' },
|
|
1554
|
+
})
|
|
1555
|
+
expect(extractor(request)).toBe('default-header')
|
|
1556
|
+
})
|
|
1557
|
+
})
|
|
1558
|
+
|
|
1559
|
+
describe('extractors.combined', () => {
|
|
1560
|
+
it('should try extractors in order until one succeeds', async () => {
|
|
1561
|
+
const combined = extractors.combined([
|
|
1562
|
+
extractors.header('X-Missing'),
|
|
1563
|
+
extractors.header('X-Tenant-ID'),
|
|
1564
|
+
extractors.path(),
|
|
1565
|
+
])
|
|
1566
|
+
|
|
1567
|
+
const request = createMockRequest('https://app.com/path-tenant/api', {
|
|
1568
|
+
headers: { 'X-Tenant-ID': 'header-tenant' },
|
|
1569
|
+
})
|
|
1570
|
+
|
|
1571
|
+
expect(await combined(request)).toBe('header-tenant')
|
|
1572
|
+
})
|
|
1573
|
+
|
|
1574
|
+
it('should return null if all extractors fail', async () => {
|
|
1575
|
+
const combined = extractors.combined([
|
|
1576
|
+
extractors.header('X-Missing'),
|
|
1577
|
+
extractors.subdomain('example.com'),
|
|
1578
|
+
])
|
|
1579
|
+
|
|
1580
|
+
const request = createMockRequest('https://example.com/')
|
|
1581
|
+
expect(await combined(request)).toBe(null)
|
|
1582
|
+
})
|
|
1583
|
+
})
|
|
1584
|
+
})
|
|
1585
|
+
})
|
|
1586
|
+
|
|
1587
|
+
describe('TenantExtractionResult', () => {
|
|
1588
|
+
let mockNamespace: DurableObjectNamespace
|
|
1589
|
+
|
|
1590
|
+
beforeEach(() => {
|
|
1591
|
+
mockNamespace = createMockDONamespace()
|
|
1592
|
+
})
|
|
1593
|
+
|
|
1594
|
+
it('should include tenantId when extraction succeeds', async () => {
|
|
1595
|
+
const router = createTenantRouter({
|
|
1596
|
+
doNamespace: mockNamespace,
|
|
1597
|
+
extractTenant: 'header',
|
|
1598
|
+
})
|
|
1599
|
+
|
|
1600
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1601
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1602
|
+
})
|
|
1603
|
+
|
|
1604
|
+
const result = await router.extractTenant(request)
|
|
1605
|
+
expect(result.tenantId).toBe('tenant1')
|
|
1606
|
+
})
|
|
1607
|
+
|
|
1608
|
+
it('should include extraction source (subdomain, path, header, custom)', async () => {
|
|
1609
|
+
const headerRouter = createTenantRouter({
|
|
1610
|
+
doNamespace: mockNamespace,
|
|
1611
|
+
extractTenant: 'header',
|
|
1612
|
+
})
|
|
1613
|
+
const pathRouter = createTenantRouter({
|
|
1614
|
+
doNamespace: mockNamespace,
|
|
1615
|
+
extractTenant: 'path',
|
|
1616
|
+
})
|
|
1617
|
+
const subdomainRouter = createTenantRouter({
|
|
1618
|
+
doNamespace: mockNamespace,
|
|
1619
|
+
extractTenant: 'subdomain',
|
|
1620
|
+
baseDomain: 'app.com',
|
|
1621
|
+
})
|
|
1622
|
+
|
|
1623
|
+
const headerRequest = createMockRequest('https://app.com/api', {
|
|
1624
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1625
|
+
})
|
|
1626
|
+
const pathRequest = createMockRequest('https://app.com/tenant1/api')
|
|
1627
|
+
const subdomainRequest = createMockRequest('https://tenant1.app.com/api')
|
|
1628
|
+
|
|
1629
|
+
expect((await headerRouter.extractTenant(headerRequest)).source).toBe('header')
|
|
1630
|
+
expect((await pathRouter.extractTenant(pathRequest)).source).toBe('path')
|
|
1631
|
+
expect((await subdomainRouter.extractTenant(subdomainRequest)).source).toBe('subdomain')
|
|
1632
|
+
})
|
|
1633
|
+
|
|
1634
|
+
it('should include original hostname', async () => {
|
|
1635
|
+
const router = createTenantRouter({
|
|
1636
|
+
doNamespace: mockNamespace,
|
|
1637
|
+
extractTenant: 'header',
|
|
1638
|
+
})
|
|
1639
|
+
|
|
1640
|
+
const request = createMockRequest('https://my-app.example.com/api', {
|
|
1641
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1642
|
+
})
|
|
1643
|
+
|
|
1644
|
+
const result = await router.extractTenant(request)
|
|
1645
|
+
expect(result.originalHostname).toBe('my-app.example.com')
|
|
1646
|
+
})
|
|
1647
|
+
|
|
1648
|
+
it('should include modified path for path-based extraction', async () => {
|
|
1649
|
+
const router = createTenantRouter({
|
|
1650
|
+
doNamespace: mockNamespace,
|
|
1651
|
+
extractTenant: 'path',
|
|
1652
|
+
stripTenantFromPath: true,
|
|
1653
|
+
})
|
|
1654
|
+
|
|
1655
|
+
const request = createMockRequest('https://app.com/tenant1/api/users/123')
|
|
1656
|
+
|
|
1657
|
+
const result = await router.extractTenant(request)
|
|
1658
|
+
expect(result.modifiedPath).toBe('/api/users/123')
|
|
1659
|
+
})
|
|
1660
|
+
|
|
1661
|
+
it('should include metadata from custom extractors', async () => {
|
|
1662
|
+
const customExtractor: TenantExtractor = (request: Request) => {
|
|
1663
|
+
const url = new URL(request.url)
|
|
1664
|
+
return {
|
|
1665
|
+
tenantId: 'tenant1',
|
|
1666
|
+
source: 'custom' as const,
|
|
1667
|
+
originalHostname: url.hostname,
|
|
1668
|
+
metadata: {
|
|
1669
|
+
customField: 'customValue',
|
|
1670
|
+
tier: 'premium',
|
|
1671
|
+
},
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const router = createTenantRouter({
|
|
1676
|
+
doNamespace: mockNamespace,
|
|
1677
|
+
extractTenant: customExtractor,
|
|
1678
|
+
})
|
|
1679
|
+
|
|
1680
|
+
const request = createMockRequest('https://app.com/api')
|
|
1681
|
+
const result = await router.extractTenant(request)
|
|
1682
|
+
|
|
1683
|
+
expect(result.metadata).toEqual({
|
|
1684
|
+
customField: 'customValue',
|
|
1685
|
+
tier: 'premium',
|
|
1686
|
+
})
|
|
1687
|
+
})
|
|
1688
|
+
})
|
|
1689
|
+
|
|
1690
|
+
describe('TenantRoutingResult', () => {
|
|
1691
|
+
let mockNamespace: DurableObjectNamespace
|
|
1692
|
+
let mockStub: { fetch: ReturnType<typeof vi.fn> }
|
|
1693
|
+
|
|
1694
|
+
beforeEach(() => {
|
|
1695
|
+
mockStub = {
|
|
1696
|
+
fetch: vi.fn().mockResolvedValue(new Response('DO Response', { status: 200 })),
|
|
1697
|
+
}
|
|
1698
|
+
mockNamespace = {
|
|
1699
|
+
idFromName: vi.fn((name: string) => ({ name, toString: () => `id-${name}` })),
|
|
1700
|
+
idFromString: vi.fn((id: string) => ({ id, toString: () => id })),
|
|
1701
|
+
newUniqueId: vi.fn(() => ({ toString: () => 'unique-id' })),
|
|
1702
|
+
get: vi.fn(() => mockStub),
|
|
1703
|
+
jurisdiction: vi.fn(),
|
|
1704
|
+
} as unknown as DurableObjectNamespace
|
|
1705
|
+
})
|
|
1706
|
+
|
|
1707
|
+
it('should include response from DO', async () => {
|
|
1708
|
+
mockStub.fetch.mockResolvedValue(
|
|
1709
|
+
new Response(JSON.stringify({ data: 'test-data' }), {
|
|
1710
|
+
status: 200,
|
|
1711
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1712
|
+
})
|
|
1713
|
+
)
|
|
1714
|
+
|
|
1715
|
+
const router = createTenantRouter({
|
|
1716
|
+
doNamespace: mockNamespace,
|
|
1717
|
+
extractTenant: 'header',
|
|
1718
|
+
})
|
|
1719
|
+
|
|
1720
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1721
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1722
|
+
})
|
|
1723
|
+
|
|
1724
|
+
const result = await router.routeWithResult(request)
|
|
1725
|
+
|
|
1726
|
+
expect(result.response).toBeInstanceOf(Response)
|
|
1727
|
+
expect(result.response.status).toBe(200)
|
|
1728
|
+
const body = await result.response.json()
|
|
1729
|
+
expect(body).toEqual({ data: 'test-data' })
|
|
1730
|
+
})
|
|
1731
|
+
|
|
1732
|
+
it('should include routing metadata', async () => {
|
|
1733
|
+
const router = createTenantRouter({
|
|
1734
|
+
doNamespace: mockNamespace,
|
|
1735
|
+
extractTenant: 'header',
|
|
1736
|
+
})
|
|
1737
|
+
|
|
1738
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1739
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1740
|
+
})
|
|
1741
|
+
|
|
1742
|
+
const result = await router.routeWithResult(request)
|
|
1743
|
+
|
|
1744
|
+
expect(result.routingMetadata).toBeDefined()
|
|
1745
|
+
expect(result.routingMetadata.extractorType).toBe('header')
|
|
1746
|
+
expect(result.routingMetadata.doId).toBe('id-tenant1')
|
|
1747
|
+
})
|
|
1748
|
+
|
|
1749
|
+
it('should include timing information', async () => {
|
|
1750
|
+
const router = createTenantRouter({
|
|
1751
|
+
doNamespace: mockNamespace,
|
|
1752
|
+
extractTenant: 'header',
|
|
1753
|
+
})
|
|
1754
|
+
|
|
1755
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1756
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1757
|
+
})
|
|
1758
|
+
|
|
1759
|
+
const beforeTime = Date.now()
|
|
1760
|
+
const result = await router.routeWithResult(request)
|
|
1761
|
+
const afterTime = Date.now()
|
|
1762
|
+
|
|
1763
|
+
expect(result.timing).toBeDefined()
|
|
1764
|
+
expect(result.timing.extractionTimeMs).toBeGreaterThanOrEqual(0)
|
|
1765
|
+
expect(result.timing.routingTimeMs).toBeGreaterThanOrEqual(0)
|
|
1766
|
+
expect(result.timing.totalTimeMs).toBeGreaterThanOrEqual(0)
|
|
1767
|
+
expect(result.timing.startTimestamp).toBeGreaterThanOrEqual(beforeTime)
|
|
1768
|
+
expect(result.timing.startTimestamp).toBeLessThanOrEqual(afterTime)
|
|
1769
|
+
})
|
|
1770
|
+
|
|
1771
|
+
it('should include tenant context', async () => {
|
|
1772
|
+
const customExtractor: TenantExtractor = (request: Request) => {
|
|
1773
|
+
const url = new URL(request.url)
|
|
1774
|
+
return {
|
|
1775
|
+
tenantId: url.searchParams.get('tenant'),
|
|
1776
|
+
source: 'custom' as const,
|
|
1777
|
+
originalHostname: url.hostname,
|
|
1778
|
+
metadata: {
|
|
1779
|
+
plan: 'enterprise',
|
|
1780
|
+
region: 'us-west-2',
|
|
1781
|
+
},
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
const router = createTenantRouter({
|
|
1786
|
+
doNamespace: mockNamespace,
|
|
1787
|
+
extractTenant: customExtractor,
|
|
1788
|
+
})
|
|
1789
|
+
|
|
1790
|
+
const request = createMockRequest('https://app.com/api?tenant=tenant1')
|
|
1791
|
+
|
|
1792
|
+
const result = await router.routeWithResult(request)
|
|
1793
|
+
|
|
1794
|
+
expect(result.tenantContext).toBeDefined()
|
|
1795
|
+
expect(result.tenantContext.tenantId).toBe('tenant1')
|
|
1796
|
+
expect(result.tenantContext.source).toBe('custom')
|
|
1797
|
+
expect(result.tenantContext.metadata).toEqual({
|
|
1798
|
+
plan: 'enterprise',
|
|
1799
|
+
region: 'us-west-2',
|
|
1800
|
+
})
|
|
1801
|
+
})
|
|
1802
|
+
})
|
|
1803
|
+
|
|
1804
|
+
describe('createTenantRouter', () => {
|
|
1805
|
+
let mockNamespace: DurableObjectNamespace
|
|
1806
|
+
|
|
1807
|
+
beforeEach(() => {
|
|
1808
|
+
mockNamespace = createMockDONamespace()
|
|
1809
|
+
})
|
|
1810
|
+
|
|
1811
|
+
it('should create router with subdomain extraction when extractTenant is "subdomain"', async () => {
|
|
1812
|
+
const router = createTenantRouter({
|
|
1813
|
+
doNamespace: mockNamespace,
|
|
1814
|
+
extractTenant: 'subdomain',
|
|
1815
|
+
baseDomain: 'myapp.com',
|
|
1816
|
+
})
|
|
1817
|
+
|
|
1818
|
+
const request = createMockRequest('https://acme.myapp.com/api')
|
|
1819
|
+
const tenantId = await router.getTenantId(request)
|
|
1820
|
+
expect(tenantId).toBe('acme')
|
|
1821
|
+
})
|
|
1822
|
+
|
|
1823
|
+
it('should create router with path extraction when extractTenant is "path"', async () => {
|
|
1824
|
+
const router = createTenantRouter({
|
|
1825
|
+
doNamespace: mockNamespace,
|
|
1826
|
+
extractTenant: 'path',
|
|
1827
|
+
})
|
|
1828
|
+
|
|
1829
|
+
const request = createMockRequest('https://app.com/acme/api')
|
|
1830
|
+
const tenantId = await router.getTenantId(request)
|
|
1831
|
+
expect(tenantId).toBe('acme')
|
|
1832
|
+
})
|
|
1833
|
+
|
|
1834
|
+
it('should create router with header extraction when extractTenant is "header"', async () => {
|
|
1835
|
+
const router = createTenantRouter({
|
|
1836
|
+
doNamespace: mockNamespace,
|
|
1837
|
+
extractTenant: 'header',
|
|
1838
|
+
})
|
|
1839
|
+
|
|
1840
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1841
|
+
headers: { 'X-Tenant-ID': 'acme' },
|
|
1842
|
+
})
|
|
1843
|
+
const tenantId = await router.getTenantId(request)
|
|
1844
|
+
expect(tenantId).toBe('acme')
|
|
1845
|
+
})
|
|
1846
|
+
|
|
1847
|
+
it('should create router with custom function when extractTenant is a function', async () => {
|
|
1848
|
+
const customFn: SimpleTenantExtractor = (req) => {
|
|
1849
|
+
const url = new URL(req.url)
|
|
1850
|
+
return url.searchParams.get('t')
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
const router = createTenantRouter({
|
|
1854
|
+
doNamespace: mockNamespace,
|
|
1855
|
+
extractTenant: customFn,
|
|
1856
|
+
})
|
|
1857
|
+
|
|
1858
|
+
const request = createMockRequest('https://app.com/api?t=custom-tenant')
|
|
1859
|
+
const tenantId = await router.getTenantId(request)
|
|
1860
|
+
expect(tenantId).toBe('custom-tenant')
|
|
1861
|
+
})
|
|
1862
|
+
|
|
1863
|
+
it('should throw error for invalid extractTenant value', () => {
|
|
1864
|
+
expect(() => {
|
|
1865
|
+
createTenantRouter({
|
|
1866
|
+
doNamespace: mockNamespace,
|
|
1867
|
+
// @ts-expect-error - intentionally passing invalid value
|
|
1868
|
+
extractTenant: 'invalid',
|
|
1869
|
+
})
|
|
1870
|
+
}).toThrow('Invalid extractTenant value: invalid')
|
|
1871
|
+
})
|
|
1872
|
+
|
|
1873
|
+
it('should apply default configuration values', async () => {
|
|
1874
|
+
// Default header name should be X-Tenant-ID
|
|
1875
|
+
const router = createTenantRouter({
|
|
1876
|
+
doNamespace: mockNamespace,
|
|
1877
|
+
extractTenant: 'header',
|
|
1878
|
+
})
|
|
1879
|
+
|
|
1880
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1881
|
+
headers: { 'X-Tenant-ID': 'default-tenant' },
|
|
1882
|
+
})
|
|
1883
|
+
|
|
1884
|
+
const tenantId = await router.getTenantId(request)
|
|
1885
|
+
expect(tenantId).toBe('default-tenant')
|
|
1886
|
+
})
|
|
1887
|
+
})
|
|
1888
|
+
|
|
1889
|
+
describe('nested sub-config support', () => {
|
|
1890
|
+
let mockNamespace: DurableObjectNamespace
|
|
1891
|
+
let mockStub: { fetch: ReturnType<typeof vi.fn> }
|
|
1892
|
+
|
|
1893
|
+
beforeEach(() => {
|
|
1894
|
+
mockStub = {
|
|
1895
|
+
fetch: vi.fn().mockResolvedValue(new Response('OK', { status: 200 })),
|
|
1896
|
+
}
|
|
1897
|
+
mockNamespace = {
|
|
1898
|
+
idFromName: vi.fn((name: string) => ({ name, toString: () => `id-${name}` })),
|
|
1899
|
+
idFromString: vi.fn((id: string) => ({ id, toString: () => id })),
|
|
1900
|
+
newUniqueId: vi.fn(() => ({ toString: () => 'unique-id' })),
|
|
1901
|
+
get: vi.fn(() => mockStub),
|
|
1902
|
+
jurisdiction: vi.fn(),
|
|
1903
|
+
} as unknown as DurableObjectNamespace
|
|
1904
|
+
})
|
|
1905
|
+
|
|
1906
|
+
describe('observability config', () => {
|
|
1907
|
+
it('should use nested observability.metricsCollector', async () => {
|
|
1908
|
+
const recordedMetrics: RequestMetrics[] = []
|
|
1909
|
+
const metricsCollector: MetricsCollector = {
|
|
1910
|
+
recordRequest: vi.fn((metrics: RequestMetrics) => {
|
|
1911
|
+
recordedMetrics.push(metrics)
|
|
1912
|
+
}),
|
|
1913
|
+
incrementRequestCount: vi.fn(),
|
|
1914
|
+
recordLatency: vi.fn(),
|
|
1915
|
+
recordDoResponseTime: vi.fn(),
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
const router = createTenantRouter({
|
|
1919
|
+
doNamespace: mockNamespace,
|
|
1920
|
+
extractTenant: 'header',
|
|
1921
|
+
observability: {
|
|
1922
|
+
metricsCollector,
|
|
1923
|
+
},
|
|
1924
|
+
})
|
|
1925
|
+
|
|
1926
|
+
const request = createMockRequest('https://app.com/api/users', {
|
|
1927
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
1928
|
+
})
|
|
1929
|
+
|
|
1930
|
+
await router.route(request)
|
|
1931
|
+
|
|
1932
|
+
expect(metricsCollector.recordRequest).toHaveBeenCalledTimes(1)
|
|
1933
|
+
expect(recordedMetrics[0].tenantId).toBe('tenant1')
|
|
1934
|
+
})
|
|
1935
|
+
|
|
1936
|
+
it('should use nested observability.logger', async () => {
|
|
1937
|
+
const logEntries: Array<{ level: string; message: string; data?: Record<string, unknown> }> = []
|
|
1938
|
+
const mockLogger: TenantRouterLogger = {
|
|
1939
|
+
log: vi.fn(),
|
|
1940
|
+
debug: vi.fn((message: string, data?: Record<string, unknown>) => {
|
|
1941
|
+
logEntries.push({ level: 'debug', message, data })
|
|
1942
|
+
}),
|
|
1943
|
+
info: vi.fn(),
|
|
1944
|
+
warn: vi.fn((message: string, data?: Record<string, unknown>) => {
|
|
1945
|
+
logEntries.push({ level: 'warn', message, data })
|
|
1946
|
+
}),
|
|
1947
|
+
error: vi.fn(),
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const router = createTenantRouter({
|
|
1951
|
+
doNamespace: mockNamespace,
|
|
1952
|
+
extractTenant: 'header',
|
|
1953
|
+
observability: {
|
|
1954
|
+
logger: mockLogger,
|
|
1955
|
+
},
|
|
1956
|
+
})
|
|
1957
|
+
|
|
1958
|
+
const request = createMockRequest('https://app.com/api')
|
|
1959
|
+
await router.route(request)
|
|
1960
|
+
|
|
1961
|
+
expect(mockLogger.warn).toHaveBeenCalled()
|
|
1962
|
+
})
|
|
1963
|
+
|
|
1964
|
+
it('should use nested observability.traceContextExtractor and propagateTraceContext', async () => {
|
|
1965
|
+
const traceContextExtractor: TraceContextExtractor = (request: Request): TraceContext | null => {
|
|
1966
|
+
const traceId = request.headers.get('X-Trace-ID')
|
|
1967
|
+
const spanId = request.headers.get('X-Span-ID')
|
|
1968
|
+
if (traceId && spanId) {
|
|
1969
|
+
return { traceId, spanId }
|
|
1970
|
+
}
|
|
1971
|
+
return null
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
const router = createTenantRouter({
|
|
1975
|
+
doNamespace: mockNamespace,
|
|
1976
|
+
extractTenant: 'header',
|
|
1977
|
+
observability: {
|
|
1978
|
+
traceContextExtractor,
|
|
1979
|
+
propagateTraceContext: true,
|
|
1980
|
+
},
|
|
1981
|
+
})
|
|
1982
|
+
|
|
1983
|
+
const request = createMockRequest('https://app.com/api', {
|
|
1984
|
+
headers: {
|
|
1985
|
+
'X-Tenant-ID': 'tenant1',
|
|
1986
|
+
'X-Trace-ID': 'nested-trace-123',
|
|
1987
|
+
'X-Span-ID': 'nested-span-456',
|
|
1988
|
+
},
|
|
1989
|
+
})
|
|
1990
|
+
|
|
1991
|
+
await router.route(request)
|
|
1992
|
+
|
|
1993
|
+
const forwardedRequest = mockStub.fetch.mock.calls[0][0] as Request
|
|
1994
|
+
expect(forwardedRequest.headers.get('X-Trace-ID')).toBe('nested-trace-123')
|
|
1995
|
+
expect(forwardedRequest.headers.get('X-Span-ID')).toBe('nested-span-456')
|
|
1996
|
+
})
|
|
1997
|
+
|
|
1998
|
+
it('should disable trace propagation when observability.propagateTraceContext is false', async () => {
|
|
1999
|
+
const traceContextExtractor: TraceContextExtractor = (request: Request): TraceContext | null => {
|
|
2000
|
+
const traceId = request.headers.get('X-Trace-ID')
|
|
2001
|
+
const spanId = request.headers.get('X-Span-ID')
|
|
2002
|
+
if (traceId && spanId) {
|
|
2003
|
+
return { traceId, spanId }
|
|
2004
|
+
}
|
|
2005
|
+
return null
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
const router = createTenantRouter({
|
|
2009
|
+
doNamespace: mockNamespace,
|
|
2010
|
+
extractTenant: 'header',
|
|
2011
|
+
observability: {
|
|
2012
|
+
traceContextExtractor,
|
|
2013
|
+
propagateTraceContext: false,
|
|
2014
|
+
},
|
|
2015
|
+
})
|
|
2016
|
+
|
|
2017
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2018
|
+
headers: {
|
|
2019
|
+
'X-Tenant-ID': 'tenant1',
|
|
2020
|
+
'X-Trace-ID': 'trace-abc',
|
|
2021
|
+
'X-Span-ID': 'span-def',
|
|
2022
|
+
},
|
|
2023
|
+
})
|
|
2024
|
+
|
|
2025
|
+
await router.route(request)
|
|
2026
|
+
|
|
2027
|
+
const forwardedRequest = mockStub.fetch.mock.calls[0][0] as Request
|
|
2028
|
+
// Trace headers should NOT be propagated
|
|
2029
|
+
expect(forwardedRequest.headers.get('X-Trace-ID')).toBeNull()
|
|
2030
|
+
expect(forwardedRequest.headers.get('X-Span-ID')).toBeNull()
|
|
2031
|
+
})
|
|
2032
|
+
})
|
|
2033
|
+
|
|
2034
|
+
describe('backward compatibility with legacy flat config', () => {
|
|
2035
|
+
it('should still support legacy flat metricsCollector option', async () => {
|
|
2036
|
+
const recordedMetrics: RequestMetrics[] = []
|
|
2037
|
+
const metricsCollector: MetricsCollector = {
|
|
2038
|
+
recordRequest: vi.fn((metrics: RequestMetrics) => {
|
|
2039
|
+
recordedMetrics.push(metrics)
|
|
2040
|
+
}),
|
|
2041
|
+
incrementRequestCount: vi.fn(),
|
|
2042
|
+
recordLatency: vi.fn(),
|
|
2043
|
+
recordDoResponseTime: vi.fn(),
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
const router = createTenantRouter({
|
|
2047
|
+
doNamespace: mockNamespace,
|
|
2048
|
+
extractTenant: 'header',
|
|
2049
|
+
metricsCollector, // legacy flat option
|
|
2050
|
+
})
|
|
2051
|
+
|
|
2052
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2053
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
2054
|
+
})
|
|
2055
|
+
|
|
2056
|
+
await router.route(request)
|
|
2057
|
+
|
|
2058
|
+
expect(metricsCollector.recordRequest).toHaveBeenCalledTimes(1)
|
|
2059
|
+
})
|
|
2060
|
+
|
|
2061
|
+
it('should still support legacy flat logger option', async () => {
|
|
2062
|
+
const mockLogger: TenantRouterLogger = {
|
|
2063
|
+
log: vi.fn(),
|
|
2064
|
+
debug: vi.fn(),
|
|
2065
|
+
info: vi.fn(),
|
|
2066
|
+
warn: vi.fn(),
|
|
2067
|
+
error: vi.fn(),
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
const router = createTenantRouter({
|
|
2071
|
+
doNamespace: mockNamespace,
|
|
2072
|
+
extractTenant: 'header',
|
|
2073
|
+
logger: mockLogger, // legacy flat option
|
|
2074
|
+
})
|
|
2075
|
+
|
|
2076
|
+
const request = createMockRequest('https://app.com/api')
|
|
2077
|
+
await router.route(request)
|
|
2078
|
+
|
|
2079
|
+
expect(mockLogger.warn).toHaveBeenCalled()
|
|
2080
|
+
})
|
|
2081
|
+
|
|
2082
|
+
it('should still support legacy flat traceContextExtractor option', async () => {
|
|
2083
|
+
const traceContextExtractor: TraceContextExtractor = (request: Request): TraceContext | null => {
|
|
2084
|
+
const traceId = request.headers.get('X-Trace-ID')
|
|
2085
|
+
const spanId = request.headers.get('X-Span-ID')
|
|
2086
|
+
if (traceId && spanId) {
|
|
2087
|
+
return { traceId, spanId }
|
|
2088
|
+
}
|
|
2089
|
+
return null
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
const router = createTenantRouter({
|
|
2093
|
+
doNamespace: mockNamespace,
|
|
2094
|
+
extractTenant: 'header',
|
|
2095
|
+
traceContextExtractor, // legacy flat option
|
|
2096
|
+
propagateTraceContext: true, // legacy flat option
|
|
2097
|
+
})
|
|
2098
|
+
|
|
2099
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2100
|
+
headers: {
|
|
2101
|
+
'X-Tenant-ID': 'tenant1',
|
|
2102
|
+
'X-Trace-ID': 'legacy-trace-123',
|
|
2103
|
+
'X-Span-ID': 'legacy-span-456',
|
|
2104
|
+
},
|
|
2105
|
+
})
|
|
2106
|
+
|
|
2107
|
+
await router.route(request)
|
|
2108
|
+
|
|
2109
|
+
const forwardedRequest = mockStub.fetch.mock.calls[0][0] as Request
|
|
2110
|
+
expect(forwardedRequest.headers.get('X-Trace-ID')).toBe('legacy-trace-123')
|
|
2111
|
+
})
|
|
2112
|
+
|
|
2113
|
+
it('should prefer nested config over legacy flat config', async () => {
|
|
2114
|
+
const nestedMetrics: RequestMetrics[] = []
|
|
2115
|
+
const legacyMetrics: RequestMetrics[] = []
|
|
2116
|
+
|
|
2117
|
+
const nestedCollector: MetricsCollector = {
|
|
2118
|
+
recordRequest: vi.fn((metrics: RequestMetrics) => {
|
|
2119
|
+
nestedMetrics.push(metrics)
|
|
2120
|
+
}),
|
|
2121
|
+
incrementRequestCount: vi.fn(),
|
|
2122
|
+
recordLatency: vi.fn(),
|
|
2123
|
+
recordDoResponseTime: vi.fn(),
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
const legacyCollector: MetricsCollector = {
|
|
2127
|
+
recordRequest: vi.fn((metrics: RequestMetrics) => {
|
|
2128
|
+
legacyMetrics.push(metrics)
|
|
2129
|
+
}),
|
|
2130
|
+
incrementRequestCount: vi.fn(),
|
|
2131
|
+
recordLatency: vi.fn(),
|
|
2132
|
+
recordDoResponseTime: vi.fn(),
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
const router = createTenantRouter({
|
|
2136
|
+
doNamespace: mockNamespace,
|
|
2137
|
+
extractTenant: 'header',
|
|
2138
|
+
observability: {
|
|
2139
|
+
metricsCollector: nestedCollector, // this should take precedence
|
|
2140
|
+
},
|
|
2141
|
+
metricsCollector: legacyCollector, // this should be ignored
|
|
2142
|
+
})
|
|
2143
|
+
|
|
2144
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2145
|
+
headers: { 'X-Tenant-ID': 'tenant1' },
|
|
2146
|
+
})
|
|
2147
|
+
|
|
2148
|
+
await router.route(request)
|
|
2149
|
+
|
|
2150
|
+
// Nested config should be used
|
|
2151
|
+
expect(nestedCollector.recordRequest).toHaveBeenCalledTimes(1)
|
|
2152
|
+
expect(nestedMetrics.length).toBe(1)
|
|
2153
|
+
|
|
2154
|
+
// Legacy config should NOT be used
|
|
2155
|
+
expect(legacyCollector.recordRequest).not.toHaveBeenCalled()
|
|
2156
|
+
expect(legacyMetrics.length).toBe(0)
|
|
2157
|
+
})
|
|
2158
|
+
})
|
|
2159
|
+
|
|
2160
|
+
describe('blockedTenantCallback config', () => {
|
|
2161
|
+
it('should use blockedTenantCallback for dynamic blocking', async () => {
|
|
2162
|
+
const blockedCallback = vi.fn().mockResolvedValue(true)
|
|
2163
|
+
|
|
2164
|
+
const router = createTenantRouter({
|
|
2165
|
+
doNamespace: mockNamespace,
|
|
2166
|
+
extractTenant: 'header',
|
|
2167
|
+
blockedTenantCallback: blockedCallback,
|
|
2168
|
+
})
|
|
2169
|
+
|
|
2170
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2171
|
+
headers: { 'X-Tenant-ID': 'dynamically-blocked' },
|
|
2172
|
+
})
|
|
2173
|
+
|
|
2174
|
+
const response = await router.route(request)
|
|
2175
|
+
expect(response.status).toBe(404)
|
|
2176
|
+
expect(blockedCallback).toHaveBeenCalledWith('dynamically-blocked', expect.any(Request))
|
|
2177
|
+
})
|
|
2178
|
+
|
|
2179
|
+
it('should prefer blockedTenantCallback over legacy isBlocked', async () => {
|
|
2180
|
+
const newCallback = vi.fn().mockResolvedValue(false)
|
|
2181
|
+
const legacyCallback = vi.fn().mockResolvedValue(true)
|
|
2182
|
+
|
|
2183
|
+
const router = createTenantRouter({
|
|
2184
|
+
doNamespace: mockNamespace,
|
|
2185
|
+
extractTenant: 'header',
|
|
2186
|
+
blockedTenantCallback: newCallback, // should be used
|
|
2187
|
+
isBlocked: legacyCallback, // should be ignored
|
|
2188
|
+
})
|
|
2189
|
+
|
|
2190
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2191
|
+
headers: { 'X-Tenant-ID': 'test-tenant' },
|
|
2192
|
+
})
|
|
2193
|
+
|
|
2194
|
+
await router.route(request)
|
|
2195
|
+
|
|
2196
|
+
// New callback should be used
|
|
2197
|
+
expect(newCallback).toHaveBeenCalled()
|
|
2198
|
+
// Legacy callback should NOT be used
|
|
2199
|
+
expect(legacyCallback).not.toHaveBeenCalled()
|
|
2200
|
+
})
|
|
2201
|
+
})
|
|
2202
|
+
|
|
2203
|
+
describe('errorResponseFormatter config', () => {
|
|
2204
|
+
it('should use errorResponseFormatter for custom error responses', async () => {
|
|
2205
|
+
const customFormatter = vi.fn().mockReturnValue(
|
|
2206
|
+
new Response('Custom Error', { status: 418 })
|
|
2207
|
+
)
|
|
2208
|
+
|
|
2209
|
+
const router = createTenantRouter({
|
|
2210
|
+
doNamespace: mockNamespace,
|
|
2211
|
+
extractTenant: 'header',
|
|
2212
|
+
errorResponseFormatter: customFormatter,
|
|
2213
|
+
})
|
|
2214
|
+
|
|
2215
|
+
const request = createMockRequest('https://app.com/api')
|
|
2216
|
+
const response = await router.route(request)
|
|
2217
|
+
|
|
2218
|
+
expect(response.status).toBe(418)
|
|
2219
|
+
expect(customFormatter).toHaveBeenCalled()
|
|
2220
|
+
})
|
|
2221
|
+
|
|
2222
|
+
it('should prefer errorResponseFormatter over legacy formatError', async () => {
|
|
2223
|
+
const newFormatter = vi.fn().mockReturnValue(
|
|
2224
|
+
new Response('New Formatter', { status: 418 })
|
|
2225
|
+
)
|
|
2226
|
+
const legacyFormatter = vi.fn().mockReturnValue(
|
|
2227
|
+
new Response('Legacy Formatter', { status: 500 })
|
|
2228
|
+
)
|
|
2229
|
+
|
|
2230
|
+
const router = createTenantRouter({
|
|
2231
|
+
doNamespace: mockNamespace,
|
|
2232
|
+
extractTenant: 'header',
|
|
2233
|
+
errorResponseFormatter: newFormatter, // should be used
|
|
2234
|
+
formatError: legacyFormatter, // should be ignored
|
|
2235
|
+
})
|
|
2236
|
+
|
|
2237
|
+
const request = createMockRequest('https://app.com/api')
|
|
2238
|
+
const response = await router.route(request)
|
|
2239
|
+
|
|
2240
|
+
expect(response.status).toBe(418)
|
|
2241
|
+
expect(newFormatter).toHaveBeenCalled()
|
|
2242
|
+
expect(legacyFormatter).not.toHaveBeenCalled()
|
|
2243
|
+
})
|
|
2244
|
+
})
|
|
2245
|
+
})
|
|
2246
|
+
|
|
2247
|
+
describe('integration scenarios', () => {
|
|
2248
|
+
let mockNamespace: DurableObjectNamespace
|
|
2249
|
+
let mockStub: { fetch: ReturnType<typeof vi.fn> }
|
|
2250
|
+
|
|
2251
|
+
beforeEach(() => {
|
|
2252
|
+
mockStub = {
|
|
2253
|
+
fetch: vi.fn().mockResolvedValue(new Response('OK', { status: 200 })),
|
|
2254
|
+
}
|
|
2255
|
+
mockNamespace = {
|
|
2256
|
+
idFromName: vi.fn((name: string) => ({ name, toString: () => `id-${name}` })),
|
|
2257
|
+
idFromString: vi.fn((id: string) => ({ id, toString: () => id })),
|
|
2258
|
+
newUniqueId: vi.fn(() => ({ toString: () => 'unique-id' })),
|
|
2259
|
+
get: vi.fn(() => mockStub),
|
|
2260
|
+
jurisdiction: vi.fn(),
|
|
2261
|
+
} as unknown as DurableObjectNamespace
|
|
2262
|
+
})
|
|
2263
|
+
|
|
2264
|
+
describe('SaaS multi-tenant application', () => {
|
|
2265
|
+
it('should route customer1.myapp.com to customer1 DO', async () => {
|
|
2266
|
+
const router = createTenantRouter({
|
|
2267
|
+
doNamespace: mockNamespace,
|
|
2268
|
+
extractTenant: 'subdomain',
|
|
2269
|
+
baseDomain: 'myapp.com',
|
|
2270
|
+
})
|
|
2271
|
+
|
|
2272
|
+
const request = createMockRequest('https://customer1.myapp.com/dashboard')
|
|
2273
|
+
await router.route(request)
|
|
2274
|
+
|
|
2275
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith('customer1')
|
|
2276
|
+
})
|
|
2277
|
+
|
|
2278
|
+
it('should route customer2.myapp.com to customer2 DO', async () => {
|
|
2279
|
+
const router = createTenantRouter({
|
|
2280
|
+
doNamespace: mockNamespace,
|
|
2281
|
+
extractTenant: 'subdomain',
|
|
2282
|
+
baseDomain: 'myapp.com',
|
|
2283
|
+
})
|
|
2284
|
+
|
|
2285
|
+
const request = createMockRequest('https://customer2.myapp.com/dashboard')
|
|
2286
|
+
await router.route(request)
|
|
2287
|
+
|
|
2288
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith('customer2')
|
|
2289
|
+
})
|
|
2290
|
+
|
|
2291
|
+
it('should route custom domain mappings correctly', async () => {
|
|
2292
|
+
const domainToTenant: Record<string, string> = {
|
|
2293
|
+
'acme.com': 'acme-inc',
|
|
2294
|
+
'bigcorp.io': 'bigcorp',
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
const customExtractor: SimpleTenantExtractor = (request: Request) => {
|
|
2298
|
+
const url = new URL(request.url)
|
|
2299
|
+
return domainToTenant[url.hostname] || null
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const router = createTenantRouter({
|
|
2303
|
+
doNamespace: mockNamespace,
|
|
2304
|
+
extractTenant: customExtractor,
|
|
2305
|
+
})
|
|
2306
|
+
|
|
2307
|
+
const request1 = createMockRequest('https://acme.com/dashboard')
|
|
2308
|
+
const request2 = createMockRequest('https://bigcorp.io/dashboard')
|
|
2309
|
+
|
|
2310
|
+
await router.route(request1)
|
|
2311
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith('acme-inc')
|
|
2312
|
+
|
|
2313
|
+
await router.route(request2)
|
|
2314
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith('bigcorp')
|
|
2315
|
+
})
|
|
2316
|
+
})
|
|
2317
|
+
|
|
2318
|
+
describe('API gateway with path-based routing', () => {
|
|
2319
|
+
it('should route /v1/tenants/acme/users to acme DO', async () => {
|
|
2320
|
+
const router = createTenantRouter({
|
|
2321
|
+
doNamespace: mockNamespace,
|
|
2322
|
+
extractTenant: 'path',
|
|
2323
|
+
pathPrefix: '/v1/tenants',
|
|
2324
|
+
})
|
|
2325
|
+
|
|
2326
|
+
const request = createMockRequest('https://api.example.com/v1/tenants/acme/users')
|
|
2327
|
+
await router.route(request)
|
|
2328
|
+
|
|
2329
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith('acme')
|
|
2330
|
+
})
|
|
2331
|
+
|
|
2332
|
+
it('should strip tenant prefix from forwarded path', async () => {
|
|
2333
|
+
const router = createTenantRouter({
|
|
2334
|
+
doNamespace: mockNamespace,
|
|
2335
|
+
extractTenant: 'path',
|
|
2336
|
+
pathPrefix: '/v1/tenants',
|
|
2337
|
+
stripTenantFromPath: true,
|
|
2338
|
+
})
|
|
2339
|
+
|
|
2340
|
+
const request = createMockRequest('https://api.example.com/v1/tenants/acme/users/123')
|
|
2341
|
+
await router.route(request)
|
|
2342
|
+
|
|
2343
|
+
const forwardedRequest = mockStub.fetch.mock.calls[0][0] as Request
|
|
2344
|
+
const url = new URL(forwardedRequest.url)
|
|
2345
|
+
expect(url.pathname).toBe('/users/123')
|
|
2346
|
+
})
|
|
2347
|
+
|
|
2348
|
+
it('should preserve API versioning in path', async () => {
|
|
2349
|
+
// Using header-based extraction so path is not modified
|
|
2350
|
+
const router = createTenantRouter({
|
|
2351
|
+
doNamespace: mockNamespace,
|
|
2352
|
+
extractTenant: 'header',
|
|
2353
|
+
})
|
|
2354
|
+
|
|
2355
|
+
const request = createMockRequest('https://api.example.com/v2/users/123', {
|
|
2356
|
+
headers: { 'X-Tenant-ID': 'acme' },
|
|
2357
|
+
})
|
|
2358
|
+
|
|
2359
|
+
await router.route(request)
|
|
2360
|
+
|
|
2361
|
+
const forwardedRequest = mockStub.fetch.mock.calls[0][0] as Request
|
|
2362
|
+
const url = new URL(forwardedRequest.url)
|
|
2363
|
+
expect(url.pathname).toBe('/v2/users/123')
|
|
2364
|
+
})
|
|
2365
|
+
})
|
|
2366
|
+
|
|
2367
|
+
describe('internal service with header-based routing', () => {
|
|
2368
|
+
it('should route based on X-Tenant-ID from upstream proxy', async () => {
|
|
2369
|
+
const router = createTenantRouter({
|
|
2370
|
+
doNamespace: mockNamespace,
|
|
2371
|
+
extractTenant: 'header',
|
|
2372
|
+
headerName: 'X-Tenant-ID',
|
|
2373
|
+
})
|
|
2374
|
+
|
|
2375
|
+
const request = createMockRequest('https://internal-service/api/data', {
|
|
2376
|
+
headers: {
|
|
2377
|
+
'X-Tenant-ID': 'internal-tenant',
|
|
2378
|
+
'X-Forwarded-For': '10.0.0.1',
|
|
2379
|
+
},
|
|
2380
|
+
})
|
|
2381
|
+
|
|
2382
|
+
await router.route(request)
|
|
2383
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith('internal-tenant')
|
|
2384
|
+
})
|
|
2385
|
+
|
|
2386
|
+
it('should reject requests without tenant header', async () => {
|
|
2387
|
+
const router = createTenantRouter({
|
|
2388
|
+
doNamespace: mockNamespace,
|
|
2389
|
+
extractTenant: 'header',
|
|
2390
|
+
})
|
|
2391
|
+
|
|
2392
|
+
const request = createMockRequest('https://internal-service/api/data')
|
|
2393
|
+
const response = await router.route(request)
|
|
2394
|
+
|
|
2395
|
+
expect(response.status).toBe(400)
|
|
2396
|
+
})
|
|
2397
|
+
})
|
|
2398
|
+
|
|
2399
|
+
describe('tenant ID boundary cases', () => {
|
|
2400
|
+
it('should handle very long tenant ID at 128 character boundary', async () => {
|
|
2401
|
+
const router = createTenantRouter({
|
|
2402
|
+
doNamespace: mockNamespace,
|
|
2403
|
+
extractTenant: 'header',
|
|
2404
|
+
})
|
|
2405
|
+
|
|
2406
|
+
// Exactly 128 characters - typical maximum for identifiers
|
|
2407
|
+
const tenantId128 = 'a'.repeat(128)
|
|
2408
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2409
|
+
headers: { 'X-Tenant-ID': tenantId128 },
|
|
2410
|
+
})
|
|
2411
|
+
|
|
2412
|
+
await router.route(request)
|
|
2413
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith(tenantId128)
|
|
2414
|
+
})
|
|
2415
|
+
|
|
2416
|
+
it('should handle tenant ID just under 128 character boundary', async () => {
|
|
2417
|
+
const router = createTenantRouter({
|
|
2418
|
+
doNamespace: mockNamespace,
|
|
2419
|
+
extractTenant: 'header',
|
|
2420
|
+
})
|
|
2421
|
+
|
|
2422
|
+
const tenantId127 = 'b'.repeat(127)
|
|
2423
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2424
|
+
headers: { 'X-Tenant-ID': tenantId127 },
|
|
2425
|
+
})
|
|
2426
|
+
|
|
2427
|
+
await router.route(request)
|
|
2428
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith(tenantId127)
|
|
2429
|
+
})
|
|
2430
|
+
|
|
2431
|
+
it('should reject tenant ID exceeding 128 character boundary', async () => {
|
|
2432
|
+
const router = createTenantRouter({
|
|
2433
|
+
doNamespace: mockNamespace,
|
|
2434
|
+
extractTenant: 'header',
|
|
2435
|
+
maxTenantIdLength: 128, // Explicit limit
|
|
2436
|
+
})
|
|
2437
|
+
|
|
2438
|
+
const tenantId129 = 'c'.repeat(129)
|
|
2439
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2440
|
+
headers: { 'X-Tenant-ID': tenantId129 },
|
|
2441
|
+
})
|
|
2442
|
+
|
|
2443
|
+
const response = await router.route(request)
|
|
2444
|
+
expect(response.status).toBe(400)
|
|
2445
|
+
})
|
|
2446
|
+
|
|
2447
|
+
it('should handle empty tenant ID as missing tenant', async () => {
|
|
2448
|
+
const router = createTenantRouter({
|
|
2449
|
+
doNamespace: mockNamespace,
|
|
2450
|
+
extractTenant: 'header',
|
|
2451
|
+
})
|
|
2452
|
+
|
|
2453
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2454
|
+
headers: { 'X-Tenant-ID': '' },
|
|
2455
|
+
})
|
|
2456
|
+
|
|
2457
|
+
const response = await router.route(request)
|
|
2458
|
+
expect(response.status).toBe(400) // Should fail validation
|
|
2459
|
+
expect(mockNamespace.get).not.toHaveBeenCalled()
|
|
2460
|
+
})
|
|
2461
|
+
|
|
2462
|
+
it('should handle whitespace-only tenant ID as empty', async () => {
|
|
2463
|
+
const router = createTenantRouter({
|
|
2464
|
+
doNamespace: mockNamespace,
|
|
2465
|
+
extractTenant: 'header',
|
|
2466
|
+
})
|
|
2467
|
+
|
|
2468
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2469
|
+
headers: { 'X-Tenant-ID': ' ' },
|
|
2470
|
+
})
|
|
2471
|
+
|
|
2472
|
+
const response = await router.route(request)
|
|
2473
|
+
// After trimming, empty string should be treated as missing tenant
|
|
2474
|
+
expect(response.status).toBe(400)
|
|
2475
|
+
})
|
|
2476
|
+
|
|
2477
|
+
it('should handle tenant ID with special characters within 128 char limit', async () => {
|
|
2478
|
+
const router = createTenantRouter({
|
|
2479
|
+
doNamespace: mockNamespace,
|
|
2480
|
+
extractTenant: 'header',
|
|
2481
|
+
})
|
|
2482
|
+
|
|
2483
|
+
// Valid tenant ID with allowed special characters (hyphens, underscores)
|
|
2484
|
+
const validTenantId = 'tenant-name_with-special_chars-123'
|
|
2485
|
+
const request = createMockRequest('https://app.com/api', {
|
|
2486
|
+
headers: { 'X-Tenant-ID': validTenantId },
|
|
2487
|
+
})
|
|
2488
|
+
|
|
2489
|
+
await router.route(request)
|
|
2490
|
+
expect(mockNamespace.idFromName).toHaveBeenCalledWith(validTenantId)
|
|
2491
|
+
})
|
|
2492
|
+
})
|
|
2493
|
+
})
|