@malloy-publisher/server 0.0.235 → 0.0.237
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/dist/app/api-doc.yaml +83 -27
- package/dist/app/assets/{EnvironmentPage-BsAnavYN.js → EnvironmentPage-DaP6CpgK.js} +1 -1
- package/dist/app/assets/{HomePage-CADE138j.js → HomePage-C9yNw3iu.js} +1 -1
- package/dist/app/assets/{LightMode-Cfh7KzN8.js → LightMode-Be4-G-dX.js} +1 -1
- package/dist/app/assets/{MainPage-CO3pRlnV.js → MainPage-J_rU2SgI.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-p9YjkRXZ.js → MaterializationsPage-DHgPI4MO.js} +1 -1
- package/dist/app/assets/ModelPage-BzXfjTu5.js +1 -0
- package/dist/app/assets/{PackagePage-e4kN75YR.js → PackagePage-ByoSTLJ4.js} +1 -1
- package/dist/app/assets/RouteError-CrSeKN2r.js +1 -0
- package/dist/app/assets/{ThemeEditorPage-CciagFTq.js → ThemeEditorPage-Btxnmhtk.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DNWmkCXa.js → WorkbookPage-D6JLPnWO.js} +1 -1
- package/dist/app/assets/{core-Rj_4rRnA.es-BZyvITuO.js → core-BSRgt1mG.es-Bc_D2Lq4.js} +1 -1
- package/dist/app/assets/{index-CH2AcDzc.js → index-BRijWj6K.js} +15 -15
- package/dist/app/assets/{index-DQa463gC.js → index-BX4D94xw.js} +1 -1
- package/dist/app/assets/{index-VBbcc8s6.js → index-Cvy0xtb7.js} +1 -1
- package/dist/app/assets/{index-DQpV7MyA.js → index-DMAhIJNC.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +59 -20
- package/dist/server.mjs +1342 -616
- package/package.json +8 -2
- package/.eslintrc.json +0 -44
- package/.prettierignore +0 -1
- package/.prettierrc +0 -4
- package/build.ts +0 -97
- package/dist/app/assets/ModelPage-C1OSTv-x.js +0 -1
- package/dist/app/assets/RouteError-CzbfOkng.js +0 -1
- package/eslint.config.mjs +0 -76
- package/k6-tests/README.md +0 -144
- package/k6-tests/bun.lock +0 -797
- package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
- package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
- package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
- package/k6-tests/load-test/load-test-crud.ts +0 -142
- package/k6-tests/load-test/load-test.ts +0 -784
- package/k6-tests/package.json +0 -27
- package/k6-tests/scripts/run-with-otel.sh +0 -50
- package/k6-tests/smoke-test/smoke-test.ts +0 -158
- package/k6-tests/tsconfig.json +0 -29
- package/k6-tests/utils/client_factory.ts +0 -128
- package/k6-tests/utils/common.ts +0 -489
- package/k6-tests/utils/logger.ts +0 -62
- package/k6-tests/utils/types.d.ts +0 -35
- package/malloy_mcp.dxt +0 -0
- package/publisher.config.example.bigquery.json +0 -33
- package/publisher.config.example.duckdb.json +0 -23
- package/publisher.config.json +0 -23
- package/scripts/bake-duckdb-extensions.js +0 -110
- package/src/config.spec.ts +0 -1535
- package/src/config.theme.spec.ts +0 -178
- package/src/config.ts +0 -1142
- package/src/constants.ts +0 -105
- package/src/controller/compile.controller.ts +0 -40
- package/src/controller/connection.controller.spec.ts +0 -849
- package/src/controller/connection.controller.ts +0 -901
- package/src/controller/database.controller.ts +0 -24
- package/src/controller/materialization.controller.spec.ts +0 -293
- package/src/controller/materialization.controller.ts +0 -319
- package/src/controller/model.controller.ts +0 -164
- package/src/controller/package.controller.spec.ts +0 -273
- package/src/controller/package.controller.ts +0 -306
- package/src/controller/query.controller.ts +0 -152
- package/src/controller/theme.controller.ts +0 -83
- package/src/controller/watch-mode.controller.ts +0 -243
- package/src/data_styles.ts +0 -71
- package/src/default-publisher.config.json +0 -23
- package/src/ducklake_version.spec.ts +0 -163
- package/src/ducklake_version.ts +0 -153
- package/src/errors.spec.ts +0 -119
- package/src/errors.ts +0 -237
- package/src/filter_deprecation.spec.ts +0 -64
- package/src/filter_deprecation.ts +0 -42
- package/src/health.spec.ts +0 -123
- package/src/health.ts +0 -233
- package/src/heap_check.spec.ts +0 -144
- package/src/heap_check.ts +0 -144
- package/src/instrumentation.ts +0 -214
- package/src/json_utils.spec.ts +0 -51
- package/src/json_utils.ts +0 -33
- package/src/logger.spec.ts +0 -210
- package/src/logger.ts +0 -251
- package/src/malloy_pin_prereqs.spec.ts +0 -25
- package/src/materialization_metrics.spec.ts +0 -169
- package/src/materialization_metrics.ts +0 -346
- package/src/mcp/error_messages.spec.ts +0 -35
- package/src/mcp/error_messages.ts +0 -183
- package/src/mcp/handler_utils.spec.ts +0 -108
- package/src/mcp/handler_utils.ts +0 -251
- package/src/mcp/mcp_constants.ts +0 -6
- package/src/mcp/query_envelope.spec.ts +0 -229
- package/src/mcp/query_envelope.ts +0 -240
- package/src/mcp/server.protocol.spec.ts +0 -250
- package/src/mcp/server.ts +0 -87
- package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
- package/src/mcp/skills/build_skills_bundle.ts +0 -176
- package/src/mcp/skills/skills_bundle.json +0 -1
- package/src/mcp/skills/skills_bundle.spec.ts +0 -171
- package/src/mcp/tool_response.spec.ts +0 -108
- package/src/mcp/tool_response.ts +0 -138
- package/src/mcp/tools/compile_tool.spec.ts +0 -315
- package/src/mcp/tools/compile_tool.ts +0 -208
- package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
- package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
- package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
- package/src/mcp/tools/docs_search_tool.ts +0 -128
- package/src/mcp/tools/embedding_index.spec.ts +0 -1236
- package/src/mcp/tools/embedding_index.ts +0 -808
- package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
- package/src/mcp/tools/execute_query_tool.ts +0 -316
- package/src/mcp/tools/get_context_eval.ts +0 -275
- package/src/mcp/tools/get_context_tool.spec.ts +0 -592
- package/src/mcp/tools/get_context_tool.ts +0 -585
- package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
- package/src/mcp/tools/reload_package_tool.ts +0 -132
- package/src/mcp_config.spec.ts +0 -919
- package/src/mcp_config.ts +0 -425
- package/src/oom_guards.integration.spec.ts +0 -269
- package/src/package_load/package_load_pool.spec.ts +0 -252
- package/src/package_load/package_load_pool.ts +0 -931
- package/src/package_load/package_load_worker.ts +0 -1034
- package/src/package_load/protocol.ts +0 -364
- package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
- package/src/package_load/rpc_wait_accountant.ts +0 -76
- package/src/package_load_metrics.spec.ts +0 -114
- package/src/package_load_metrics.ts +0 -127
- package/src/path_safety.spec.ts +0 -158
- package/src/path_safety.ts +0 -146
- package/src/pg_helpers.spec.ts +0 -223
- package/src/pg_helpers.ts +0 -52
- package/src/query_cap_metrics.spec.ts +0 -89
- package/src/query_cap_metrics.ts +0 -116
- package/src/query_concurrency.spec.ts +0 -247
- package/src/query_concurrency.ts +0 -237
- package/src/query_metadata_metrics.ts +0 -49
- package/src/query_param_utils.ts +0 -29
- package/src/query_timeout.spec.ts +0 -224
- package/src/query_timeout.ts +0 -179
- package/src/runtime/publisher.js +0 -328
- package/src/server-old.ts +0 -988
- package/src/server.ts +0 -2059
- package/src/service/annotations.spec.ts +0 -118
- package/src/service/annotations.ts +0 -91
- package/src/service/authorize.spec.ts +0 -154
- package/src/service/authorize.ts +0 -498
- package/src/service/authorize_integration.spec.ts +0 -2041
- package/src/service/build_plan.spec.ts +0 -685
- package/src/service/build_plan.ts +0 -797
- package/src/service/compile_authorize.spec.ts +0 -85
- package/src/service/compile_fragment_techniques.spec.ts +0 -156
- package/src/service/connection.spec.ts +0 -2360
- package/src/service/connection.ts +0 -2187
- package/src/service/connection_config.spec.ts +0 -734
- package/src/service/connection_config.ts +0 -899
- package/src/service/connection_federation.spec.ts +0 -184
- package/src/service/connection_fingerprint.spec.ts +0 -102
- package/src/service/connection_service.spec.ts +0 -711
- package/src/service/connection_service.ts +0 -291
- package/src/service/cron_evaluator.spec.ts +0 -79
- package/src/service/cron_evaluator.ts +0 -105
- package/src/service/db_utils.spec.ts +0 -1085
- package/src/service/db_utils.ts +0 -1502
- package/src/service/duckdb_instance_isolation.spec.ts +0 -137
- package/src/service/ducklake_lazy_attach.spec.ts +0 -110
- package/src/service/embedding_provider.spec.ts +0 -329
- package/src/service/embedding_provider.ts +0 -236
- package/src/service/environment.ts +0 -2191
- package/src/service/environment_admission.spec.ts +0 -344
- package/src/service/environment_compile.spec.ts +0 -197
- package/src/service/environment_store.spec.ts +0 -2274
- package/src/service/environment_store.ts +0 -2434
- package/src/service/environment_store_anchoring.spec.ts +0 -107
- package/src/service/environment_store_clone.spec.ts +0 -350
- package/src/service/explore_visibility.spec.ts +0 -434
- package/src/service/exports_probe.spec.ts +0 -107
- package/src/service/extension_fetch_policy.spec.ts +0 -256
- package/src/service/filter.spec.ts +0 -458
- package/src/service/filter.ts +0 -341
- package/src/service/filter_bypass.spec.ts +0 -418
- package/src/service/filter_integration.spec.ts +0 -1004
- package/src/service/freshness.spec.ts +0 -183
- package/src/service/freshness.ts +0 -112
- package/src/service/gcs_s3_utils.ts +0 -427
- package/src/service/given.ts +0 -105
- package/src/service/givens_integration.spec.ts +0 -219
- package/src/service/manifest_loader.spec.ts +0 -189
- package/src/service/manifest_loader.ts +0 -156
- package/src/service/materialization_build_session.spec.ts +0 -435
- package/src/service/materialization_build_session.ts +0 -681
- package/src/service/materialization_config_validation.spec.ts +0 -99
- package/src/service/materialization_config_validation.ts +0 -120
- package/src/service/materialization_eligibility.spec.ts +0 -158
- package/src/service/materialization_eligibility.ts +0 -305
- package/src/service/materialization_schedule_surface.spec.ts +0 -256
- package/src/service/materialization_scheduler.spec.ts +0 -289
- package/src/service/materialization_scheduler.ts +0 -304
- package/src/service/materialization_serve_transform.spec.ts +0 -1003
- package/src/service/materialization_serve_transform.ts +0 -779
- package/src/service/materialization_service.spec.ts +0 -2373
- package/src/service/materialization_service.ts +0 -2342
- package/src/service/materialization_test_fixtures.ts +0 -249
- package/src/service/model.spec.ts +0 -1157
- package/src/service/model.ts +0 -3370
- package/src/service/model_limits.spec.ts +0 -209
- package/src/service/model_limits.ts +0 -131
- package/src/service/model_storage_serve.spec.ts +0 -193
- package/src/service/model_storage_serve_joins.spec.ts +0 -193
- package/src/service/package.spec.ts +0 -612
- package/src/service/package.ts +0 -1799
- package/src/service/package_manifest.spec.ts +0 -230
- package/src/service/package_manifest.ts +0 -241
- package/src/service/package_memory_governor.spec.ts +0 -173
- package/src/service/package_memory_governor.ts +0 -233
- package/src/service/package_quote_bound_tables.spec.ts +0 -171
- package/src/service/package_race.spec.ts +0 -208
- package/src/service/package_reload_safety.spec.ts +0 -193
- package/src/service/package_rollback.spec.ts +0 -190
- package/src/service/package_worker_path.spec.ts +0 -645
- package/src/service/path_injection.spec.ts +0 -39
- package/src/service/persist_annotation_validation.spec.ts +0 -89
- package/src/service/persist_annotation_validation.ts +0 -59
- package/src/service/persistence_policy.spec.ts +0 -374
- package/src/service/proxy.spec.ts +0 -414
- package/src/service/proxy.ts +0 -248
- package/src/service/query_boundary.spec.ts +0 -470
- package/src/service/query_metadata.spec.ts +0 -408
- package/src/service/query_metadata.ts +0 -492
- package/src/service/query_metadata_identity.spec.ts +0 -149
- package/src/service/query_text.spec.ts +0 -79
- package/src/service/query_text.ts +0 -43
- package/src/service/quoting.spec.ts +0 -135
- package/src/service/quoting.ts +0 -78
- package/src/service/resolve_environment.ts +0 -15
- package/src/service/restricted_mode.spec.ts +0 -299
- package/src/service/source_extraction.ts +0 -225
- package/src/service/theme_store.ts +0 -199
- package/src/storage/DatabaseInterface.ts +0 -198
- package/src/storage/StorageManager.ts +0 -113
- package/src/storage/duckdb/ConnectionRepository.ts +0 -143
- package/src/storage/duckdb/DuckDBConnection.ts +0 -123
- package/src/storage/duckdb/DuckDBRepository.ts +0 -208
- package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
- package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
- package/src/storage/duckdb/MaterializationRepository.ts +0 -336
- package/src/storage/duckdb/PackageRepository.ts +0 -135
- package/src/storage/duckdb/schema.ts +0 -229
- package/src/stream_helpers.spec.ts +0 -280
- package/src/stream_helpers.ts +0 -162
- package/src/telemetry.ts +0 -20
- package/src/test_helpers/metrics_harness.ts +0 -166
- package/src/theme_key_parity.spec.ts +0 -57
- package/src/utils.ts +0 -29
- package/tests/fixtures/authorize-compile/model.malloy +0 -9
- package/tests/fixtures/authorize-compile/publisher.json +0 -4
- package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
- package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/data.csv +0 -3
- package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
- package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
- package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
- package/tests/fixtures/html-pages-test/public/data.json +0 -1
- package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
- package/tests/fixtures/html-pages-test/public/index.html +0 -9
- package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
- package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
- package/tests/fixtures/html-pages-test/public/slides.html +0 -12
- package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
- package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
- package/tests/fixtures/html-pages-test/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/report.malloy +0 -1
- package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
- package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
- package/tests/fixtures/persist-multi-level/publisher.json +0 -5
- package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
- package/tests/fixtures/persist-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-test/persist_test.malloy +0 -11
- package/tests/fixtures/persist-test/publisher.json +0 -5
- package/tests/fixtures/publisher.config.json +0 -15
- package/tests/fixtures/query-givens/data/orders.csv +0 -7
- package/tests/fixtures/query-givens/model.malloy +0 -34
- package/tests/fixtures/query-givens/publisher.json +0 -5
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/harness/mcp_test_setup.ts +0 -332
- package/tests/harness/rest_e2e.ts +0 -68
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
- package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
- package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
- package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
- package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
- package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
- package/tests/unit/controller/theme.controller.spec.ts +0 -141
- package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
- package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
- package/tests/unit/duckdb/repositories.test.ts +0 -208
- package/tests/unit/ducklake/ducklake.test.ts +0 -952
- package/tests/unit/service/theme_store.spec.ts +0 -139
- package/tsconfig.json +0 -32
|
@@ -1,2187 +0,0 @@
|
|
|
1
|
-
import "@malloydata/db-bigquery";
|
|
2
|
-
import type { BigQueryConnection } from "@malloydata/db-bigquery";
|
|
3
|
-
import "@malloydata/db-databricks";
|
|
4
|
-
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
5
|
-
import "@malloydata/db-duckdb/native";
|
|
6
|
-
import "@malloydata/db-mysql";
|
|
7
|
-
import type { MySQLConnection } from "@malloydata/db-mysql";
|
|
8
|
-
import "@malloydata/db-postgres";
|
|
9
|
-
import {
|
|
10
|
-
PooledPostgresConnection,
|
|
11
|
-
type PostgresConnection,
|
|
12
|
-
type PostgresSSLConfig,
|
|
13
|
-
} from "@malloydata/db-postgres";
|
|
14
|
-
// Registers the "publisher" connection type (proxies SQL to a remote Publisher
|
|
15
|
-
// dataplane). No live-class branch is needed in lookupConnection — the default
|
|
16
|
-
// path resolves it through the registry like any other registered type.
|
|
17
|
-
import "@malloydata/db-publisher";
|
|
18
|
-
import {
|
|
19
|
-
buildPoolOptions,
|
|
20
|
-
SnowflakeConnection,
|
|
21
|
-
} from "@malloydata/db-snowflake";
|
|
22
|
-
import "@malloydata/db-trino";
|
|
23
|
-
import type { TrinoConnection } from "@malloydata/db-trino";
|
|
24
|
-
import {
|
|
25
|
-
Connection,
|
|
26
|
-
contextOverlay,
|
|
27
|
-
MalloyConfig,
|
|
28
|
-
TableSourceDef,
|
|
29
|
-
} from "@malloydata/malloy";
|
|
30
|
-
import type { LookupConnection } from "@malloydata/malloy/connection";
|
|
31
|
-
import { AxiosError } from "axios";
|
|
32
|
-
import fs from "fs/promises";
|
|
33
|
-
import { components } from "../api";
|
|
34
|
-
import { getExtensionFetchPolicy } from "../config";
|
|
35
|
-
import {
|
|
36
|
-
catalogFormatRangeForEngine,
|
|
37
|
-
isCatalogFormatInRange,
|
|
38
|
-
} from "../ducklake_version";
|
|
39
|
-
import { UnsupportedCatalogFormatError } from "../errors";
|
|
40
|
-
import { logAxiosError, logger } from "../logger";
|
|
41
|
-
import { redactPgSecrets } from "../pg_helpers";
|
|
42
|
-
import {
|
|
43
|
-
assertSafeEnvironmentPath,
|
|
44
|
-
assertSafePackageName,
|
|
45
|
-
safeJoinUnderRoot,
|
|
46
|
-
} from "../path_safety";
|
|
47
|
-
import {
|
|
48
|
-
assembleEnvironmentConnections,
|
|
49
|
-
CoreConnectionEntry,
|
|
50
|
-
EnvironmentConnectionMetadata,
|
|
51
|
-
normalizeSnowflakePrivateKey,
|
|
52
|
-
} from "./connection_config";
|
|
53
|
-
import { CloudStorageCredentials } from "./gcs_s3_utils";
|
|
54
|
-
import { openProxy, type ProxyEndpoint } from "./proxy";
|
|
55
|
-
import { quoteIdentifier } from "./quoting";
|
|
56
|
-
|
|
57
|
-
type AttachedDatabase = components["schemas"]["AttachedDatabase"];
|
|
58
|
-
type ApiConnection = components["schemas"]["Connection"];
|
|
59
|
-
type ApiConnectionAttributes = components["schemas"]["ConnectionAttributes"];
|
|
60
|
-
type ApiConnectionStatus = components["schemas"]["ConnectionStatus"];
|
|
61
|
-
type PublisherDuckDBOptions = {
|
|
62
|
-
name: string;
|
|
63
|
-
databasePath?: string;
|
|
64
|
-
workingDirectory?: string;
|
|
65
|
-
motherDuckToken?: string;
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
// Extends the public API connection with the internal connection objects
|
|
69
|
-
// which contains passwords and connection strings.
|
|
70
|
-
export type InternalConnection = ApiConnection & {
|
|
71
|
-
postgresConnection?: components["schemas"]["PostgresConnection"];
|
|
72
|
-
bigqueryConnection?: components["schemas"]["BigqueryConnection"];
|
|
73
|
-
snowflakeConnection?: components["schemas"]["SnowflakeConnection"];
|
|
74
|
-
trinoConnection?: components["schemas"]["TrinoConnection"];
|
|
75
|
-
databricksConnection?: components["schemas"]["DatabricksConnection"];
|
|
76
|
-
mysqlConnection?: components["schemas"]["MysqlConnection"];
|
|
77
|
-
duckdbConnection?: components["schemas"]["DuckdbConnection"];
|
|
78
|
-
ducklakeConnection?: components["schemas"]["DucklakeConnection"];
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
// Shared utilities
|
|
82
|
-
|
|
83
|
-
// Publisher-owned DuckDB sessions whose extension PRAGMAs have already been
|
|
84
|
-
// pinned. The lookup funnel runs on every resolve, and the attach paths pin
|
|
85
|
-
// too, so this WeakSet keeps the SETs to once per connection object. The
|
|
86
|
-
// DuckLake tier pins with `alwaysDisableAutoinstall` inside its attach, which
|
|
87
|
-
// runs before the funnel ever sees the connection, so the stronger always-off
|
|
88
|
-
// setting is recorded first and never re-evaluated down to policy here.
|
|
89
|
-
const extensionSessionPinned = new WeakSet<Connection>();
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Pin the extension-management PRAGMAs on a Publisher-owned DuckDB session.
|
|
93
|
-
* Publisher installs the extensions it needs explicitly (see
|
|
94
|
-
* {@link installAndLoadExtension}), so DuckDB's own IMPLICIT auto-install
|
|
95
|
-
* (fetching a known extension a query happens to reference) is never
|
|
96
|
-
* load-bearing.
|
|
97
|
-
*
|
|
98
|
-
* Auto-LOAD is left ON (DuckDB's default) so any locally-present extension
|
|
99
|
-
* still lazy-loads — the local-first UX every deployment relies on. Implicit
|
|
100
|
-
* auto-INSTALL is turned OFF when EITHER:
|
|
101
|
-
* - `alwaysDisableAutoinstall` is set — the DuckLake tier's build/serve
|
|
102
|
-
* sessions only ever need the curated extension set, so they never
|
|
103
|
-
* implicitly fetch, regardless of the deployment policy; OR
|
|
104
|
-
* - `EXTENSION_FETCH_POLICY=local-only` — the air-gapped / pinned-image
|
|
105
|
-
* posture, where no session may reach the network.
|
|
106
|
-
*
|
|
107
|
-
* Under the default `on-demand` policy a generic session is left untouched
|
|
108
|
-
* (DuckDB's default), so existing standalone/OSS users see no behaviour change.
|
|
109
|
-
*
|
|
110
|
-
* Called for EVERY Publisher-owned DuckDB session — the environment lookup
|
|
111
|
-
* funnel, the per-package sandbox, and the attach entry points — so the pin has
|
|
112
|
-
* no gap regardless of whether a session has attached databases. On a failed
|
|
113
|
-
* SET: under `local-only` we FAIL CLOSED (throw), because the whole promise of
|
|
114
|
-
* that mode is that no session can reach the network; under the tier's
|
|
115
|
-
* on-demand always-off case the pin is defense-in-depth (the install path
|
|
116
|
-
* already avoids implicit fetches), so we log and continue rather than fail an
|
|
117
|
-
* otherwise-valid attach.
|
|
118
|
-
*/
|
|
119
|
-
export async function applyExtensionSessionSettings(
|
|
120
|
-
connection: DuckDBConnection,
|
|
121
|
-
{
|
|
122
|
-
alwaysDisableAutoinstall = false,
|
|
123
|
-
}: { alwaysDisableAutoinstall?: boolean } = {},
|
|
124
|
-
): Promise<void> {
|
|
125
|
-
const policy = getExtensionFetchPolicy();
|
|
126
|
-
const disableAutoinstall =
|
|
127
|
-
alwaysDisableAutoinstall || policy === "local-only";
|
|
128
|
-
if (!disableAutoinstall || extensionSessionPinned.has(connection)) {
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
try {
|
|
132
|
-
await connection.runSQL("SET autoinstall_known_extensions=false;");
|
|
133
|
-
// Keep autoload on: it is DuckDB's default, but set it explicitly next to
|
|
134
|
-
// an install lockdown so a locally-present/baked extension still loads.
|
|
135
|
-
await connection.runSQL("SET autoload_known_extensions=true;");
|
|
136
|
-
extensionSessionPinned.add(connection);
|
|
137
|
-
} catch (error) {
|
|
138
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
139
|
-
if (policy === "local-only") {
|
|
140
|
-
throw new Error(
|
|
141
|
-
`Failed to disable DuckDB implicit extension auto-install under ` +
|
|
142
|
-
`EXTENSION_FETCH_POLICY=local-only; refusing to open a session that ` +
|
|
143
|
-
`could still fetch from the network. Underlying error: ${detail}`,
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
logger.warn("Failed to pin DuckDB extension session settings", {
|
|
147
|
-
error: detail,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export async function installAndLoadExtension(
|
|
153
|
-
connection: DuckDBConnection,
|
|
154
|
-
extensionName: string,
|
|
155
|
-
fromCommunity = false,
|
|
156
|
-
): Promise<void> {
|
|
157
|
-
const policy = getExtensionFetchPolicy();
|
|
158
|
-
|
|
159
|
-
// `local-only` never fetches: skip INSTALL entirely and rely on the
|
|
160
|
-
// extension already being present on disk (baked into the image). Auto-LOAD
|
|
161
|
-
// stays on (see applyExtensionSessionSettings), so a present extension still
|
|
162
|
-
// lazy-loads; a missing one fails the LOAD below and is reported loudly.
|
|
163
|
-
if (policy === "on-demand") {
|
|
164
|
-
try {
|
|
165
|
-
// Plain INSTALL is local-first: it no-ops when the extension is already
|
|
166
|
-
// present and only fetches when it is missing. We dropped FORCE, which
|
|
167
|
-
// re-fetched from the community CDN whenever the network was up — even
|
|
168
|
-
// for a baked extension — silently drifting the running binary past the
|
|
169
|
-
// offline-verified build.
|
|
170
|
-
const installCommand = fromCommunity
|
|
171
|
-
? `INSTALL ${extensionName} FROM community;`
|
|
172
|
-
: `INSTALL ${extensionName};`;
|
|
173
|
-
await connection.runSQL(installCommand);
|
|
174
|
-
logger.info(`${extensionName} extension installed`);
|
|
175
|
-
} catch (error) {
|
|
176
|
-
logger.info(
|
|
177
|
-
`${extensionName} extension already installed or install skipped`,
|
|
178
|
-
{ error },
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
await connection.runSQL(`LOAD ${extensionName};`);
|
|
185
|
-
logger.info(`${extensionName} extension loaded`);
|
|
186
|
-
} catch (error) {
|
|
187
|
-
if (policy === "local-only") {
|
|
188
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
189
|
-
logger.warn("DuckDB extension unavailable under local-only policy", {
|
|
190
|
-
extension: extensionName,
|
|
191
|
-
policy,
|
|
192
|
-
error: detail,
|
|
193
|
-
});
|
|
194
|
-
throw new Error(
|
|
195
|
-
`DuckDB extension '${extensionName}' is not available locally and ` +
|
|
196
|
-
`EXTENSION_FETCH_POLICY=local-only forbids fetching it. Bake the ` +
|
|
197
|
-
`'${extensionName}' extension into the image (or a pre-populated ` +
|
|
198
|
-
`~/.duckdb/extensions directory), or set ` +
|
|
199
|
-
`EXTENSION_FETCH_POLICY=on-demand to allow fetching missing ` +
|
|
200
|
-
`extensions on first use. Underlying LOAD error: ${detail}`,
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
throw error;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async function isDatabaseAttached(
|
|
208
|
-
connection: DuckDBConnection,
|
|
209
|
-
dbName: string,
|
|
210
|
-
): Promise<boolean> {
|
|
211
|
-
try {
|
|
212
|
-
const existingDatabases = await connection.runSQL("SHOW DATABASES");
|
|
213
|
-
const rows = Array.isArray(existingDatabases)
|
|
214
|
-
? existingDatabases
|
|
215
|
-
: existingDatabases.rows || [];
|
|
216
|
-
|
|
217
|
-
logger.debug("connection.duckdb.databases.queried", {
|
|
218
|
-
count: rows.length,
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
return rows.some((row: Record<string, unknown>) =>
|
|
222
|
-
Object.values(row).some(
|
|
223
|
-
(value) => typeof value === "string" && value === dbName,
|
|
224
|
-
),
|
|
225
|
-
);
|
|
226
|
-
} catch (error) {
|
|
227
|
-
logger.warn(`Failed to check existing databases:`, error);
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function sanitizeSecretName(name: string): string {
|
|
233
|
-
return `secret_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export function escapeSQL(value: string): string {
|
|
237
|
-
return value.replace(/'/g, "''");
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function handleAlreadyAttachedError(error: unknown, dbName: string): void {
|
|
241
|
-
if (error instanceof Error && error.message.includes("already exists")) {
|
|
242
|
-
logger.info(`Database ${dbName} is already attached, skipping`);
|
|
243
|
-
} else {
|
|
244
|
-
throw error;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Database-specific attachment handlers
|
|
249
|
-
async function attachBigQuery(
|
|
250
|
-
connection: DuckDBConnection,
|
|
251
|
-
attachedDb: AttachedDatabase,
|
|
252
|
-
): Promise<void> {
|
|
253
|
-
if (!attachedDb.bigqueryConnection) {
|
|
254
|
-
throw new Error(
|
|
255
|
-
`BigQuery connection configuration missing for: ${attachedDb.name}`,
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const config = attachedDb.bigqueryConnection;
|
|
260
|
-
let projectId = config.defaultProjectId;
|
|
261
|
-
let serviceAccountJson: string | undefined;
|
|
262
|
-
|
|
263
|
-
// Parse and validate service account key
|
|
264
|
-
if (config.serviceAccountKeyJson) {
|
|
265
|
-
const keyData = JSON.parse(config.serviceAccountKeyJson as string);
|
|
266
|
-
|
|
267
|
-
const requiredFields = [
|
|
268
|
-
"type",
|
|
269
|
-
"project_id",
|
|
270
|
-
"private_key",
|
|
271
|
-
"client_email",
|
|
272
|
-
];
|
|
273
|
-
for (const field of requiredFields) {
|
|
274
|
-
if (!keyData[field]) {
|
|
275
|
-
throw new Error(
|
|
276
|
-
`Invalid service account key: missing "${field}" field`,
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (keyData.type !== "service_account") {
|
|
282
|
-
throw new Error('Invalid service account key: incorrect "type" field');
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
projectId = keyData.project_id || config.defaultProjectId;
|
|
286
|
-
serviceAccountJson = config.serviceAccountKeyJson as string;
|
|
287
|
-
logger.info(`Using service account: ${keyData.client_email}`);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
if (!projectId || !serviceAccountJson) {
|
|
291
|
-
throw new Error(
|
|
292
|
-
`BigQuery project_id and service account key required for: ${attachedDb.name}`,
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
await installAndLoadExtension(connection, "bigquery", true);
|
|
297
|
-
|
|
298
|
-
const secretName = sanitizeSecretName(`bigquery_${attachedDb.name}`);
|
|
299
|
-
const escapedJson = escapeSQL(serviceAccountJson);
|
|
300
|
-
|
|
301
|
-
const createSecretCommand = `
|
|
302
|
-
CREATE OR REPLACE SECRET ${secretName} (
|
|
303
|
-
TYPE BIGQUERY,
|
|
304
|
-
SCOPE 'bq://${projectId}',
|
|
305
|
-
SERVICE_ACCOUNT_JSON '${escapedJson}'
|
|
306
|
-
);
|
|
307
|
-
`;
|
|
308
|
-
|
|
309
|
-
await connection.runSQL(createSecretCommand);
|
|
310
|
-
logger.info(
|
|
311
|
-
`Created BigQuery secret: ${secretName} for project: ${projectId}`,
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
const attachCommand = `ATTACH 'project=${projectId}' AS ${attachedDb.name} (TYPE bigquery, READ_ONLY);`;
|
|
315
|
-
await connection.runSQL(attachCommand);
|
|
316
|
-
logger.info(`Successfully attached BigQuery database: ${attachedDb.name}`);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
async function attachSnowflake(
|
|
320
|
-
connection: DuckDBConnection,
|
|
321
|
-
attachedDb: AttachedDatabase,
|
|
322
|
-
): Promise<void> {
|
|
323
|
-
if (!attachedDb.snowflakeConnection) {
|
|
324
|
-
throw new Error(
|
|
325
|
-
`Snowflake connection configuration missing for: ${attachedDb.name}`,
|
|
326
|
-
);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const config = attachedDb.snowflakeConnection;
|
|
330
|
-
|
|
331
|
-
// Validate required fields
|
|
332
|
-
const requiredFields = {
|
|
333
|
-
account: config.account,
|
|
334
|
-
username: config.username,
|
|
335
|
-
password: config.password,
|
|
336
|
-
};
|
|
337
|
-
for (const [field, value] of Object.entries(requiredFields)) {
|
|
338
|
-
if (!value) {
|
|
339
|
-
throw new Error(
|
|
340
|
-
`Snowflake ${field} is required for: ${attachedDb.name}`,
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
await installAndLoadExtension(connection, "snowflake", true);
|
|
346
|
-
|
|
347
|
-
// Verify ADBC driver
|
|
348
|
-
try {
|
|
349
|
-
const version = await connection.runSQL("SELECT snowflake_version();");
|
|
350
|
-
logger.info(`Snowflake ADBC driver verified with version:`, version.rows);
|
|
351
|
-
} catch (error) {
|
|
352
|
-
throw new Error(
|
|
353
|
-
`Snowflake ADBC driver verification failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Build connection parameters
|
|
358
|
-
const params = {
|
|
359
|
-
account: escapeSQL(config.account || ""),
|
|
360
|
-
user: escapeSQL(config.username || ""),
|
|
361
|
-
password: escapeSQL(config.password || ""),
|
|
362
|
-
database: config.database ? escapeSQL(config.database) : undefined,
|
|
363
|
-
warehouse: config.warehouse ? escapeSQL(config.warehouse) : undefined,
|
|
364
|
-
schema: config.schema ? escapeSQL(config.schema) : undefined,
|
|
365
|
-
role: config.role ? escapeSQL(config.role) : undefined,
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
// Create attach string
|
|
369
|
-
const attachParts = [
|
|
370
|
-
`account=${params.account}`,
|
|
371
|
-
`user=${params.user}`,
|
|
372
|
-
`password=${params.password}`,
|
|
373
|
-
];
|
|
374
|
-
|
|
375
|
-
if (params.database) attachParts.push(`database=${params.database}`);
|
|
376
|
-
if (params.warehouse) attachParts.push(`warehouse=${params.warehouse}`);
|
|
377
|
-
|
|
378
|
-
const secretString = `CREATE OR REPLACE SECRET ${attachedDb.name}_secret (
|
|
379
|
-
TYPE snowflake,
|
|
380
|
-
ACCOUNT '${params.account}',
|
|
381
|
-
USER '${params.user}',
|
|
382
|
-
PASSWORD '${params.password}',
|
|
383
|
-
DATABASE '${params.database}',
|
|
384
|
-
WAREHOUSE '${params.warehouse}'
|
|
385
|
-
);`;
|
|
386
|
-
|
|
387
|
-
await connection.runSQL(secretString);
|
|
388
|
-
|
|
389
|
-
const testresult = await connection.runSQL(
|
|
390
|
-
`SELECT * FROM snowflake_query('SELECT 1', '${attachedDb.name}_secret');`,
|
|
391
|
-
);
|
|
392
|
-
logger.info(`Testing Snowflake connection:`, testresult.rows);
|
|
393
|
-
|
|
394
|
-
const attachCommand = `ATTACH '${attachedDb.name}' AS ${attachedDb.name} (TYPE snowflake, SECRET ${attachedDb.name}_secret, READ_ONLY);`;
|
|
395
|
-
await connection.runSQL(attachCommand);
|
|
396
|
-
logger.info(`Successfully attached Snowflake database: ${attachedDb.name}`);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function buildPgConnectionString(
|
|
400
|
-
pg: components["schemas"]["PostgresConnection"],
|
|
401
|
-
): string {
|
|
402
|
-
if (pg.connectionString) {
|
|
403
|
-
return pg.connectionString;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
const parts: string[] = [];
|
|
407
|
-
if (pg.host) parts.push(`host=${pg.host}`);
|
|
408
|
-
if (pg.port) parts.push(`port=${pg.port}`);
|
|
409
|
-
if (pg.databaseName) parts.push(`dbname=${pg.databaseName}`);
|
|
410
|
-
if (pg.userName) parts.push(`user=${pg.userName}`);
|
|
411
|
-
if (pg.password) parts.push(`password=${pg.password}`);
|
|
412
|
-
|
|
413
|
-
const pgSSLMode = process.env.PGSSLMODE;
|
|
414
|
-
|
|
415
|
-
if (pgSSLMode) {
|
|
416
|
-
const mapping: Record<string, string> = {
|
|
417
|
-
"no-verify": "disable",
|
|
418
|
-
disable: "disable",
|
|
419
|
-
allow: "allow",
|
|
420
|
-
prefer: "prefer",
|
|
421
|
-
require: "require",
|
|
422
|
-
"verify-ca": "verify-ca",
|
|
423
|
-
"verify-full": "verify-full",
|
|
424
|
-
};
|
|
425
|
-
|
|
426
|
-
const sslmode = mapping[pgSSLMode.toLowerCase()];
|
|
427
|
-
if (sslmode) parts.push(`sslmode=${sslmode}`);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
return parts.join(" ");
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
async function attachPostgres(
|
|
434
|
-
connection: DuckDBConnection,
|
|
435
|
-
attachedDb: AttachedDatabase,
|
|
436
|
-
): Promise<void> {
|
|
437
|
-
if (!attachedDb.postgresConnection) {
|
|
438
|
-
throw new Error(
|
|
439
|
-
`PostgreSQL connection configuration missing for: ${attachedDb.name}`,
|
|
440
|
-
);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
await installAndLoadExtension(connection, "postgres");
|
|
444
|
-
|
|
445
|
-
const config = attachedDb.postgresConnection;
|
|
446
|
-
const attachString: string = buildPgConnectionString(config);
|
|
447
|
-
|
|
448
|
-
const attachCommand = `ATTACH '${escapeSQL(attachString)}' AS ${attachedDb.name} (TYPE postgres, READ_ONLY);`;
|
|
449
|
-
await connection.runSQL(attachCommand);
|
|
450
|
-
logger.info(`Successfully attached PostgreSQL database: ${attachedDb.name}`);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/** Rows out of a DuckDBConnection.runSQL result, in either shape it returns. */
|
|
454
|
-
function runSQLRows(result: unknown): Record<string, unknown>[] {
|
|
455
|
-
if (Array.isArray(result)) return result as Record<string, unknown>[];
|
|
456
|
-
const rows = (result as { rows?: unknown }).rows;
|
|
457
|
-
return Array.isArray(rows) ? (rows as Record<string, unknown>[]) : [];
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* Runtime range-preflight for a DuckLake ATTACH. Reads the catalog's recorded
|
|
462
|
-
* format version from its `ducklake_metadata` table via a plain postgres ATTACH
|
|
463
|
-
* (this does NOT invoke the DuckLake extension on the catalog), and checks it
|
|
464
|
-
* against the format range the running DuckDB engine supports (see
|
|
465
|
-
* `ducklake_version.ts`). On a mismatch it throws an
|
|
466
|
-
* {@link UnsupportedCatalogFormatError} (→ HTTP 422) naming the format found,
|
|
467
|
-
* the supported range, and where to migrate — instead of letting the real
|
|
468
|
-
* DuckLake ATTACH fail deep inside DuckDB as an opaque 500.
|
|
469
|
-
*
|
|
470
|
-
* Non-load-bearing on its own read: any failure of the preflight itself
|
|
471
|
-
* (missing `ducklake_metadata` table, non-postgres catalog, query timeout,
|
|
472
|
-
* connect failure, an engine with no matrix row) logs and returns so the real
|
|
473
|
-
* ATTACH below stays the source of truth for unrelated errors. Only a
|
|
474
|
-
* successfully-read, definitively-out-of-range format raises the clean 422.
|
|
475
|
-
*
|
|
476
|
-
* The temp attach uses a per-call unique name so a failed DETACH can't wedge
|
|
477
|
-
* the fixed name (which would make every later preflight for this connection
|
|
478
|
-
* fail its ATTACH with "already exists" and silently skip), and so two
|
|
479
|
-
* concurrent first-touch lookups can't cross-DETACH each other.
|
|
480
|
-
*
|
|
481
|
-
* {@link metadataSchema} must be the catalog's configured
|
|
482
|
-
* `catalog.metadataSchema`, because `ducklake_metadata` lives in that schema
|
|
483
|
-
* rather than the catalog connection's default one. Getting this wrong is not
|
|
484
|
-
* loud: the read would simply miss, the catch below would log and return, and the
|
|
485
|
-
* range check would stop protecting precisely the catalogs that set the option.
|
|
486
|
-
*/
|
|
487
|
-
let ducklakePreflightSeq = 0;
|
|
488
|
-
async function preflightDuckLakeCatalogFormat(
|
|
489
|
-
connection: DuckDBConnection,
|
|
490
|
-
dbName: string,
|
|
491
|
-
pgConnString: string,
|
|
492
|
-
metadataSchema?: string,
|
|
493
|
-
): Promise<void> {
|
|
494
|
-
const tempDb = `${dbName}_fmt_preflight_${++ducklakePreflightSeq}`;
|
|
495
|
-
// Identifier position, not a string literal, so the schema is double-quoted.
|
|
496
|
-
// Measured: with today's validator this is belt-and-braces rather than a fix —
|
|
497
|
-
// every name the regex admits resolves correctly unquoted, including reserved
|
|
498
|
-
// words (DuckDB parses them fine here) and mixed case (matching is
|
|
499
|
-
// case-insensitive). It is quoted anyway because this preflight fails SOFT: a
|
|
500
|
-
// read that misses logs and returns, silently disabling format range-checking
|
|
501
|
-
// for that connection, so the cost of ever getting this wrong is invisible.
|
|
502
|
-
// Quoting makes correctness local to this line instead of contingent on the
|
|
503
|
-
// validator's accept-set, so widening that regex later cannot break it. Safe
|
|
504
|
-
// by construction: the regex admits no quote character to break out with.
|
|
505
|
-
const metadataRef = metadataSchema
|
|
506
|
-
? `${tempDb}."${metadataSchema}".ducklake_metadata`
|
|
507
|
-
: `${tempDb}.ducklake_metadata`;
|
|
508
|
-
let catalogFormat: string | undefined;
|
|
509
|
-
try {
|
|
510
|
-
await connection.runSQL(
|
|
511
|
-
`ATTACH '${escapeSQL(pgConnString)}' AS ${tempDb} (TYPE postgres, READ_ONLY);`,
|
|
512
|
-
);
|
|
513
|
-
const result = await connection.runSQL(
|
|
514
|
-
`SELECT value FROM ${metadataRef} WHERE key = 'version' LIMIT 1;`,
|
|
515
|
-
);
|
|
516
|
-
const value = runSQLRows(result)[0]?.value;
|
|
517
|
-
catalogFormat = typeof value === "string" ? value : undefined;
|
|
518
|
-
} catch (error) {
|
|
519
|
-
const message = redactPgSecrets(
|
|
520
|
-
error instanceof Error ? error.message : String(error),
|
|
521
|
-
);
|
|
522
|
-
// A named metadata schema holding no catalog has two very different causes,
|
|
523
|
-
// and the preflight cannot tell them apart: it is the NORMAL state before the
|
|
524
|
-
// first read-write attach creates the catalog, and it is also what a typo (or
|
|
525
|
-
// adding `metadataSchema` to a catalog whose metadata lives elsewhere) looks
|
|
526
|
-
// like. Neither is loud on its own — a read-write attach CREATES an empty
|
|
527
|
-
// catalog there and materializes into it, and a read-only attach fails with an
|
|
528
|
-
// error that says nothing about the schema — so name the schema and say both,
|
|
529
|
-
// rather than implying a mistake on a path that is expected to hit this once
|
|
530
|
-
// per catalog. The message also has to hedge on the cause: `does not exist`
|
|
531
|
-
// matches a missing database or role too, not only a missing table.
|
|
532
|
-
if (metadataSchema !== undefined && /does not exist/i.test(message)) {
|
|
533
|
-
logger.warn(
|
|
534
|
-
"No DuckLake catalog found in the configured metadata schema. This is " +
|
|
535
|
-
"expected the first time a catalog is created there: a read-write " +
|
|
536
|
-
"attach will create it, and a read-only attach will fail until it " +
|
|
537
|
-
"exists. Otherwise check the schema name, and the catalog database " +
|
|
538
|
-
"and role, against the error.",
|
|
539
|
-
{ dbName, metadataSchema, error: message },
|
|
540
|
-
);
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
logger.warn(
|
|
544
|
-
"DuckLake catalog-format preflight read failed; falling back to ATTACH",
|
|
545
|
-
{ dbName, error: message },
|
|
546
|
-
);
|
|
547
|
-
return;
|
|
548
|
-
} finally {
|
|
549
|
-
try {
|
|
550
|
-
await connection.runSQL(`DETACH ${tempDb};`);
|
|
551
|
-
} catch {
|
|
552
|
-
// The ATTACH may have failed, so there may be nothing to detach.
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if (!catalogFormat) {
|
|
557
|
-
// Table present but no version row, or unreadable value: let ATTACH decide.
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Derive the supported range from the engine DuckDB actually reports. An
|
|
562
|
-
// unknown engine (no matrix row) yields null → skip (the CI version-contract
|
|
563
|
-
// check is what guarantees the pinned engine is always covered).
|
|
564
|
-
let engineVersion = "";
|
|
565
|
-
try {
|
|
566
|
-
const versionResult = await connection.runSQL("SELECT version() AS v;");
|
|
567
|
-
engineVersion = String(runSQLRows(versionResult)[0]?.v ?? "");
|
|
568
|
-
} catch (error) {
|
|
569
|
-
logger.warn(
|
|
570
|
-
"DuckLake catalog-format preflight: could not read engine version; skipping",
|
|
571
|
-
{
|
|
572
|
-
dbName,
|
|
573
|
-
error: error instanceof Error ? error.message : String(error),
|
|
574
|
-
},
|
|
575
|
-
);
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
const range = catalogFormatRangeForEngine(engineVersion);
|
|
580
|
-
if (!range) {
|
|
581
|
-
logger.warn(
|
|
582
|
-
"DuckLake catalog-format preflight: engine not covered by the format matrix; skipping",
|
|
583
|
-
{ dbName, engineVersion },
|
|
584
|
-
);
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
if (!isCatalogFormatInRange(catalogFormat, range)) {
|
|
589
|
-
logger.warn(
|
|
590
|
-
"DuckLake catalog format outside supported range; rejecting",
|
|
591
|
-
{
|
|
592
|
-
dbName,
|
|
593
|
-
catalogFormat,
|
|
594
|
-
supportedMin: range.min,
|
|
595
|
-
supportedMax: range.max,
|
|
596
|
-
engineVersion: range.engineVersion,
|
|
597
|
-
},
|
|
598
|
-
);
|
|
599
|
-
throw new UnsupportedCatalogFormatError(
|
|
600
|
-
`DuckLake catalog '${dbName}' has format version ${catalogFormat}, ` +
|
|
601
|
-
`which is outside the range this Publisher supports ` +
|
|
602
|
-
`(${range.min} ≤ format ≤ ${range.max}). The DuckLake ` +
|
|
603
|
-
`extension bundled with DuckDB v${range.engineVersion} attaches only ` +
|
|
604
|
-
`catalogs in that range without migration. Migrate the catalog to a ` +
|
|
605
|
-
`supported format (see the DuckLake catalog-migration docs at ` +
|
|
606
|
-
`https://ducklake.select/docs), or run a Publisher built against a ` +
|
|
607
|
-
`DuckDB engine whose supported range includes ${catalogFormat}.`,
|
|
608
|
-
);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
logger.info(
|
|
612
|
-
`DuckLake catalog '${dbName}' format ${catalogFormat} is within supported range ` +
|
|
613
|
-
`${range.min}..${range.max} (engine v${range.engineVersion})`,
|
|
614
|
-
);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/**
|
|
618
|
-
* Attach a DuckLake catalog in a given access mode. `readOnly: true` (the live
|
|
619
|
-
* user-facing connection path) attaches with `READ_ONLY true`; `readOnly: false`
|
|
620
|
-
* produces the read-WRITE build variant used only on a transient, build-scoped
|
|
621
|
-
* session so a materialization can `CREATE TABLE` into the catalog. Both keep
|
|
622
|
-
* `OVERRIDE_DATA_PATH true` and NEVER pass `AUTOMATIC_MIGRATION` — catalog-format
|
|
623
|
-
* migration is a coordinated fleet cutover, never an implicit side effect of a
|
|
624
|
-
* build attach. Both range-preflight the catalog format first (see
|
|
625
|
-
* {@link preflightDuckLakeCatalogFormat}).
|
|
626
|
-
*/
|
|
627
|
-
async function attachDuckLakeWithMode(
|
|
628
|
-
connection: DuckDBConnection,
|
|
629
|
-
dbName: string,
|
|
630
|
-
ducklakeConfig: components["schemas"]["DucklakeConnection"],
|
|
631
|
-
options: { readOnly: boolean },
|
|
632
|
-
): Promise<void> {
|
|
633
|
-
// Tier build/serve DuckLake sessions never implicitly auto-install (Publisher
|
|
634
|
-
// installs the DuckLake stack explicitly below) — regardless of the policy.
|
|
635
|
-
await applyExtensionSessionSettings(connection, {
|
|
636
|
-
alwaysDisableAutoinstall: true,
|
|
637
|
-
});
|
|
638
|
-
await installAndLoadExtension(connection, "ducklake");
|
|
639
|
-
await installAndLoadExtension(connection, "postgres");
|
|
640
|
-
await installAndLoadExtension(connection, "aws");
|
|
641
|
-
await installAndLoadExtension(connection, "httpfs");
|
|
642
|
-
if (!ducklakeConfig.catalog?.postgresConnection) {
|
|
643
|
-
throw new Error(
|
|
644
|
-
`PostgreSQL connection configuration is required for DuckLake catalog: ${dbName}`,
|
|
645
|
-
);
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
if (!ducklakeConfig.storage?.bucketUrl) {
|
|
649
|
-
throw new Error(`Storage bucketUrl is required for DuckLake: ${dbName}`);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// Set up cloud storage secret so DuckDB can access S3/GCS
|
|
653
|
-
const hasS3 = !!ducklakeConfig.storage.s3Connection;
|
|
654
|
-
const hasGCS = !!ducklakeConfig.storage.gcsConnection;
|
|
655
|
-
|
|
656
|
-
if (hasS3) {
|
|
657
|
-
await attachCloudStorage(connection, {
|
|
658
|
-
name: `${dbName}_storage`,
|
|
659
|
-
type: "s3",
|
|
660
|
-
s3Connection: ducklakeConfig.storage.s3Connection,
|
|
661
|
-
});
|
|
662
|
-
} else if (hasGCS) {
|
|
663
|
-
await attachCloudStorage(connection, {
|
|
664
|
-
name: `${dbName}_storage`,
|
|
665
|
-
type: "gcs",
|
|
666
|
-
gcsConnection: ducklakeConfig.storage.gcsConnection,
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const pg = ducklakeConfig.catalog.postgresConnection;
|
|
671
|
-
const pgConnString: string = buildPgConnectionString(pg);
|
|
672
|
-
const mode = options.readOnly ? "READ_ONLY" : "READ_WRITE";
|
|
673
|
-
// READ_ONLY: the client manages metadata, we only read the catalog.
|
|
674
|
-
// READ_WRITE (build only): a build-scoped session materializes into it.
|
|
675
|
-
// Debug, not info. These three lines — this one, the escaped form below, and the
|
|
676
|
-
// assembled ATTACH — are per-attach diagnostics that between them print the catalog
|
|
677
|
-
// host and database twice and the storage path once. `redactPgSecrets` removes the
|
|
678
|
-
// password and any URI userinfo, but a DATA_PATH is not a secret it knows about, so
|
|
679
|
-
// logging the assembled command at info discloses the bucket and whatever the prefix
|
|
680
|
-
// encodes to every reader of the logs. What an operator watching a healthy fleet
|
|
681
|
-
// needs is the mode and the outcome, which stay at info below.
|
|
682
|
-
logger.debug(`pgConnString: ${redactPgSecrets(pgConnString)}`);
|
|
683
|
-
const escapedPgConnString = escapeSQL(pgConnString);
|
|
684
|
-
logger.debug(
|
|
685
|
-
`Final escaped connection string: ${redactPgSecrets(escapedPgConnString)}`,
|
|
686
|
-
);
|
|
687
|
-
const escapedBucketUrl = escapeSQL(ducklakeConfig.storage.bucketUrl);
|
|
688
|
-
// Optional metadata schema: which schema in the catalog database holds this
|
|
689
|
-
// DuckLake's `ducklake_*` tables. Absent keeps DuckLake's default (the catalog
|
|
690
|
-
// connection's default schema), so the emitted command is unchanged for every
|
|
691
|
-
// existing config. Validated to a plain identifier at config load, because it
|
|
692
|
-
// reaches a quoted literal here and an identifier position in the preflight.
|
|
693
|
-
const metadataSchema = ducklakeConfig.catalog.metadataSchema;
|
|
694
|
-
// Range-preflight the catalog's recorded format version so an unsupported
|
|
695
|
-
// catalog fails as a clean, actionable 422 rather than a deep DuckDB 500. The
|
|
696
|
-
// schema must be threaded through: the preflight reads `ducklake_metadata`, so
|
|
697
|
-
// when the metadata does not live in the catalog's default schema an unqualified
|
|
698
|
-
// read misses it — and the preflight fails SOFT (logs and returns), so the range
|
|
699
|
-
// check would silently stop protecting exactly the catalogs using this option.
|
|
700
|
-
await preflightDuckLakeCatalogFormat(
|
|
701
|
-
connection,
|
|
702
|
-
dbName,
|
|
703
|
-
pgConnString,
|
|
704
|
-
metadataSchema,
|
|
705
|
-
);
|
|
706
|
-
// READ_ONLY is stated explicitly; read-write omits the flag (DuckLake's
|
|
707
|
-
// default is writable). AUTOMATIC_MIGRATION is never set in either mode.
|
|
708
|
-
const readOnlyClause = options.readOnly ? ", READ_ONLY true" : "";
|
|
709
|
-
const metadataSchemaClause = metadataSchema
|
|
710
|
-
? `, METADATA_SCHEMA '${escapeSQL(metadataSchema)}'`
|
|
711
|
-
: "";
|
|
712
|
-
const attachCommand = `ATTACH OR REPLACE 'ducklake:postgres:${escapedPgConnString}' AS ${dbName} (DATA_PATH '${escapedBucketUrl}', OVERRIDE_DATA_PATH true${readOnlyClause}${metadataSchemaClause});`;
|
|
713
|
-
logger.debug(
|
|
714
|
-
`Attaching DuckLake database using command: ${redactPgSecrets(attachCommand)}`,
|
|
715
|
-
);
|
|
716
|
-
try {
|
|
717
|
-
await connection.runSQL(attachCommand);
|
|
718
|
-
logger.info(
|
|
719
|
-
`Successfully attached DuckLake database in ${mode} mode: ${dbName}`,
|
|
720
|
-
);
|
|
721
|
-
} catch (error) {
|
|
722
|
-
// Handle case where DuckLake database is already attached
|
|
723
|
-
if (
|
|
724
|
-
error instanceof Error &&
|
|
725
|
-
(error.message.includes("already exists") ||
|
|
726
|
-
error.message.includes("already attached"))
|
|
727
|
-
) {
|
|
728
|
-
logger.info(
|
|
729
|
-
`DuckLake database ${dbName} is already attached, skipping`,
|
|
730
|
-
);
|
|
731
|
-
} else {
|
|
732
|
-
throw error;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
async function attachDuckLake(
|
|
738
|
-
connection: DuckDBConnection,
|
|
739
|
-
dbName: string,
|
|
740
|
-
ducklakeConfig: components["schemas"]["DucklakeConnection"],
|
|
741
|
-
): Promise<void> {
|
|
742
|
-
await attachDuckLakeWithMode(connection, dbName, ducklakeConfig, {
|
|
743
|
-
readOnly: true,
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
/**
|
|
748
|
-
* Read-WRITE DuckLake attach for a materialization build. Identical to the
|
|
749
|
-
* read-only user-facing attach except the catalog is writable, so a build-scoped
|
|
750
|
-
* session can `CREATE TABLE ... AS` into it. Least privilege: this variant is
|
|
751
|
-
* only ever used on a transient build session (never the serve connection), for
|
|
752
|
-
* the duration of one build. Like the read-only attach it is lazy and never on
|
|
753
|
-
* the worker boot path.
|
|
754
|
-
*/
|
|
755
|
-
export async function attachDuckLakeReadWrite(
|
|
756
|
-
connection: DuckDBConnection,
|
|
757
|
-
dbName: string,
|
|
758
|
-
ducklakeConfig: components["schemas"]["DucklakeConnection"],
|
|
759
|
-
): Promise<void> {
|
|
760
|
-
await attachDuckLakeWithMode(connection, dbName, ducklakeConfig, {
|
|
761
|
-
readOnly: false,
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/** A source warehouse type that exposes a native DuckDB query-passthrough. */
|
|
766
|
-
export type FederatedSourceType = "bigquery" | "snowflake" | "postgres";
|
|
767
|
-
|
|
768
|
-
/**
|
|
769
|
-
* The result of federating a source connection onto a build session: the
|
|
770
|
-
* `handle` a passthrough table function needs to reach it —
|
|
771
|
-
* - bigquery: the resolved project id (`bigquery_query('<project>', '<sql>')`),
|
|
772
|
-
* - snowflake: the created secret name (`snowflake_query('<sql>', '<secret>')`),
|
|
773
|
-
* - postgres: the ATTACH alias (`postgres_query('<alias>', '<sql>')`).
|
|
774
|
-
*/
|
|
775
|
-
export interface FederatedHandle {
|
|
776
|
-
handle: string;
|
|
777
|
-
sourceType: FederatedSourceType;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
/**
|
|
781
|
-
* The source connection's configuration needed to federate it onto a build
|
|
782
|
-
* session. `name` is a caller-supplied, already-safe base for the secret/alias
|
|
783
|
-
* identifiers; the type-specific config carries the credentials.
|
|
784
|
-
*/
|
|
785
|
-
export interface FederationConfig {
|
|
786
|
-
name: string;
|
|
787
|
-
bigqueryConnection?: components["schemas"]["BigqueryConnection"];
|
|
788
|
-
snowflakeConnection?: components["schemas"]["SnowflakeConnection"];
|
|
789
|
-
postgresConnection?: components["schemas"]["PostgresConnection"];
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/**
|
|
793
|
-
* Establish a SOURCE warehouse's credentials on a build-scoped DuckDB session so
|
|
794
|
-
* a native query-passthrough (`bigquery_query`/`snowflake_query`/`postgres_query`)
|
|
795
|
-
* can push the compiled SELECT to that warehouse and stream back result rows.
|
|
796
|
-
* This is the credential-federation half of the build path: it runs ONLY on the
|
|
797
|
-
* transient build session, on demand, for the build's duration — the source
|
|
798
|
-
* creds are never established on the serve connection and never persisted into a
|
|
799
|
-
* connection config. It mirrors the existing read-only attach handlers but
|
|
800
|
-
* targets the passthrough functions (bigquery/snowflake need only a SECRET, no
|
|
801
|
-
* ATTACH; postgres needs an ATTACH whose alias the function references).
|
|
802
|
-
*
|
|
803
|
-
* @returns the {@link FederatedHandle} the caller passes to `wrapPassthrough`.
|
|
804
|
-
* @throws for a source type without a native passthrough, or missing/invalid
|
|
805
|
-
* credentials, so a build fails loud rather than producing a wrong table.
|
|
806
|
-
*/
|
|
807
|
-
export async function federateSourceForPassthrough(
|
|
808
|
-
connection: DuckDBConnection,
|
|
809
|
-
sourceType: FederatedSourceType,
|
|
810
|
-
config: FederationConfig,
|
|
811
|
-
): Promise<FederatedHandle> {
|
|
812
|
-
switch (sourceType) {
|
|
813
|
-
case "bigquery":
|
|
814
|
-
return federateBigQuery(connection, config);
|
|
815
|
-
case "snowflake":
|
|
816
|
-
return federateSnowflake(connection, config);
|
|
817
|
-
case "postgres":
|
|
818
|
-
return federatePostgres(connection, config);
|
|
819
|
-
default: {
|
|
820
|
-
// Exhaustiveness guard: a new FederatedSourceType must add a branch.
|
|
821
|
-
const exhaustive: never = sourceType;
|
|
822
|
-
throw new Error(
|
|
823
|
-
`No native query-passthrough for source type '${String(exhaustive)}'`,
|
|
824
|
-
);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
async function federateBigQuery(
|
|
830
|
-
connection: DuckDBConnection,
|
|
831
|
-
config: FederationConfig,
|
|
832
|
-
): Promise<FederatedHandle> {
|
|
833
|
-
const bq = config.bigqueryConnection;
|
|
834
|
-
if (!bq) {
|
|
835
|
-
throw new Error(
|
|
836
|
-
`BigQuery connection configuration missing for: ${config.name}`,
|
|
837
|
-
);
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
let projectId = bq.defaultProjectId;
|
|
841
|
-
let serviceAccountJson: string | undefined;
|
|
842
|
-
if (bq.serviceAccountKeyJson) {
|
|
843
|
-
const keyData = JSON.parse(bq.serviceAccountKeyJson as string);
|
|
844
|
-
const requiredFields = [
|
|
845
|
-
"type",
|
|
846
|
-
"project_id",
|
|
847
|
-
"private_key",
|
|
848
|
-
"client_email",
|
|
849
|
-
];
|
|
850
|
-
for (const field of requiredFields) {
|
|
851
|
-
if (!keyData[field]) {
|
|
852
|
-
throw new Error(
|
|
853
|
-
`Invalid service account key: missing "${field}" field`,
|
|
854
|
-
);
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
if (keyData.type !== "service_account") {
|
|
858
|
-
throw new Error('Invalid service account key: incorrect "type" field');
|
|
859
|
-
}
|
|
860
|
-
projectId = keyData.project_id || bq.defaultProjectId;
|
|
861
|
-
serviceAccountJson = bq.serviceAccountKeyJson as string;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
if (!projectId || !serviceAccountJson) {
|
|
865
|
-
throw new Error(
|
|
866
|
-
`BigQuery project_id and service account key required for: ${config.name}`,
|
|
867
|
-
);
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
await installAndLoadExtension(connection, "bigquery", true);
|
|
871
|
-
|
|
872
|
-
const secretName = sanitizeSecretName(`bigquery_${config.name}`);
|
|
873
|
-
const escapedJson = escapeSQL(serviceAccountJson);
|
|
874
|
-
await connection.runSQL(`
|
|
875
|
-
CREATE OR REPLACE SECRET ${secretName} (
|
|
876
|
-
TYPE BIGQUERY,
|
|
877
|
-
SCOPE 'bq://${projectId}',
|
|
878
|
-
SERVICE_ACCOUNT_JSON '${escapedJson}'
|
|
879
|
-
);
|
|
880
|
-
`);
|
|
881
|
-
logger.info(
|
|
882
|
-
`Federated BigQuery source for passthrough: project ${projectId}`,
|
|
883
|
-
);
|
|
884
|
-
// bigquery_query resolves credentials from the secret scoped to bq://<project>
|
|
885
|
-
// — the project id is the passthrough handle; no ATTACH is needed.
|
|
886
|
-
return { handle: projectId, sourceType: "bigquery" };
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
async function federateSnowflake(
|
|
890
|
-
connection: DuckDBConnection,
|
|
891
|
-
config: FederationConfig,
|
|
892
|
-
): Promise<FederatedHandle> {
|
|
893
|
-
const sf = config.snowflakeConnection;
|
|
894
|
-
if (!sf) {
|
|
895
|
-
throw new Error(
|
|
896
|
-
`Snowflake connection configuration missing for: ${config.name}`,
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
const required = {
|
|
900
|
-
account: sf.account,
|
|
901
|
-
username: sf.username,
|
|
902
|
-
password: sf.password,
|
|
903
|
-
};
|
|
904
|
-
for (const [field, value] of Object.entries(required)) {
|
|
905
|
-
if (!value) {
|
|
906
|
-
throw new Error(`Snowflake ${field} is required for: ${config.name}`);
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
await installAndLoadExtension(connection, "snowflake", true);
|
|
911
|
-
|
|
912
|
-
const params = {
|
|
913
|
-
account: escapeSQL(sf.account || ""),
|
|
914
|
-
user: escapeSQL(sf.username || ""),
|
|
915
|
-
password: escapeSQL(sf.password || ""),
|
|
916
|
-
database: sf.database ? escapeSQL(sf.database) : undefined,
|
|
917
|
-
warehouse: sf.warehouse ? escapeSQL(sf.warehouse) : undefined,
|
|
918
|
-
};
|
|
919
|
-
const secretName = sanitizeSecretName(`snowflake_${config.name}`);
|
|
920
|
-
// DATABASE/WAREHOUSE are optional — emit them only when supplied, so an
|
|
921
|
-
// absent one doesn't interpolate the literal string 'undefined' into the
|
|
922
|
-
// secret (which Snowflake would then try to use as a real db/warehouse name).
|
|
923
|
-
const secretLines = [
|
|
924
|
-
` TYPE snowflake`,
|
|
925
|
-
` ACCOUNT '${params.account}'`,
|
|
926
|
-
` USER '${params.user}'`,
|
|
927
|
-
` PASSWORD '${params.password}'`,
|
|
928
|
-
...(params.database ? [` DATABASE '${params.database}'`] : []),
|
|
929
|
-
...(params.warehouse ? [` WAREHOUSE '${params.warehouse}'`] : []),
|
|
930
|
-
];
|
|
931
|
-
await connection.runSQL(
|
|
932
|
-
`CREATE OR REPLACE SECRET ${secretName} (\n${secretLines.join(",\n")}\n);`,
|
|
933
|
-
);
|
|
934
|
-
logger.info(`Federated Snowflake source for passthrough: ${secretName}`);
|
|
935
|
-
// snowflake_query takes the SQL first and the secret name second — the secret
|
|
936
|
-
// name is the passthrough handle; no ATTACH is needed.
|
|
937
|
-
return { handle: secretName, sourceType: "snowflake" };
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
async function federatePostgres(
|
|
941
|
-
connection: DuckDBConnection,
|
|
942
|
-
config: FederationConfig,
|
|
943
|
-
): Promise<FederatedHandle> {
|
|
944
|
-
const pg = config.postgresConnection;
|
|
945
|
-
if (!pg) {
|
|
946
|
-
throw new Error(
|
|
947
|
-
`PostgreSQL connection configuration missing for: ${config.name}`,
|
|
948
|
-
);
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
await installAndLoadExtension(connection, "postgres");
|
|
952
|
-
|
|
953
|
-
const attachString = buildPgConnectionString(pg);
|
|
954
|
-
// `name` is the ATTACH alias AND (verbatim) the postgres_query handle, so the
|
|
955
|
-
// handle stays the raw name while the ATTACH identifier is dialect-quoted —
|
|
956
|
-
// a name needing quoting (e.g. a hyphen) would otherwise be a parser error.
|
|
957
|
-
const alias = config.name;
|
|
958
|
-
logger.info(
|
|
959
|
-
`Federating Postgres source for passthrough as alias '${alias}': ${redactPgSecrets(attachString)}`,
|
|
960
|
-
);
|
|
961
|
-
// OR REPLACE for within-session idempotency. The cross-build/cross-tenant
|
|
962
|
-
// alias-collision boundary is the build session's PRIVATE DuckDB instance
|
|
963
|
-
// (see createIsolatedBuildSession): each build runs on its own instance, so
|
|
964
|
-
// its postgres attach cannot collide with another build's or another tenant's
|
|
965
|
-
// on a shared instance. OR REPLACE is kept as belt-and-suspenders for a
|
|
966
|
-
// re-attach of the identical source within one session (the alias is the
|
|
967
|
-
// connection name, the config is that connection's, so replacing is a no-op
|
|
968
|
-
// rebind to the same source). (bigquery/snowflake federate via CREATE OR
|
|
969
|
-
// REPLACE SECRET with no ATTACH; secrets are instance-scoped, so isolation
|
|
970
|
-
// covers them too.)
|
|
971
|
-
await connection.runSQL(
|
|
972
|
-
`ATTACH OR REPLACE '${escapeSQL(attachString)}' AS ${quoteIdentifier(alias, "duckdb")} (TYPE postgres, READ_ONLY);`,
|
|
973
|
-
);
|
|
974
|
-
return { handle: alias, sourceType: "postgres" };
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
async function attachCloudStorage(
|
|
978
|
-
connection: DuckDBConnection,
|
|
979
|
-
attachedDb: AttachedDatabase,
|
|
980
|
-
): Promise<void> {
|
|
981
|
-
const isGCS = attachedDb.type === "gcs";
|
|
982
|
-
const isS3 = attachedDb.type === "s3";
|
|
983
|
-
|
|
984
|
-
if (!isGCS && !isS3) {
|
|
985
|
-
throw new Error(`Invalid cloud storage type: ${attachedDb.type}`);
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
const storageType = attachedDb.type?.toUpperCase() || "";
|
|
989
|
-
let credentials: CloudStorageCredentials;
|
|
990
|
-
|
|
991
|
-
if (isGCS) {
|
|
992
|
-
if (!attachedDb.gcsConnection) {
|
|
993
|
-
throw new Error(
|
|
994
|
-
`GCS connection configuration missing for: ${attachedDb.name}`,
|
|
995
|
-
);
|
|
996
|
-
}
|
|
997
|
-
if (!attachedDb.gcsConnection.keyId || !attachedDb.gcsConnection.secret) {
|
|
998
|
-
throw new Error(
|
|
999
|
-
`GCS keyId and secret are required for: ${attachedDb.name}`,
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1002
|
-
credentials = {
|
|
1003
|
-
type: "gcs",
|
|
1004
|
-
accessKeyId: attachedDb.gcsConnection.keyId,
|
|
1005
|
-
secretAccessKey: attachedDb.gcsConnection.secret,
|
|
1006
|
-
};
|
|
1007
|
-
} else {
|
|
1008
|
-
if (!attachedDb.s3Connection) {
|
|
1009
|
-
throw new Error(
|
|
1010
|
-
`S3 connection configuration missing for: ${attachedDb.name}`,
|
|
1011
|
-
);
|
|
1012
|
-
}
|
|
1013
|
-
if (
|
|
1014
|
-
!attachedDb.s3Connection.accessKeyId ||
|
|
1015
|
-
!attachedDb.s3Connection.secretAccessKey
|
|
1016
|
-
) {
|
|
1017
|
-
throw new Error(
|
|
1018
|
-
`S3 accessKeyId and secretAccessKey are required for: ${attachedDb.name}`,
|
|
1019
|
-
);
|
|
1020
|
-
}
|
|
1021
|
-
credentials = {
|
|
1022
|
-
type: "s3",
|
|
1023
|
-
accessKeyId: attachedDb.s3Connection.accessKeyId,
|
|
1024
|
-
secretAccessKey: attachedDb.s3Connection.secretAccessKey,
|
|
1025
|
-
region: attachedDb.s3Connection.region,
|
|
1026
|
-
endpoint: attachedDb.s3Connection.endpoint,
|
|
1027
|
-
sessionToken: attachedDb.s3Connection.sessionToken,
|
|
1028
|
-
};
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
await installAndLoadExtension(connection, "httpfs");
|
|
1032
|
-
|
|
1033
|
-
const secretName = sanitizeSecretName(
|
|
1034
|
-
`${attachedDb.type}_${attachedDb.name}`,
|
|
1035
|
-
);
|
|
1036
|
-
const escapedKeyId = escapeSQL(credentials.accessKeyId);
|
|
1037
|
-
const escapedSecret = escapeSQL(credentials.secretAccessKey);
|
|
1038
|
-
|
|
1039
|
-
let createSecretCommand: string;
|
|
1040
|
-
|
|
1041
|
-
if (isGCS) {
|
|
1042
|
-
createSecretCommand = `
|
|
1043
|
-
CREATE OR REPLACE SECRET ${secretName} (
|
|
1044
|
-
TYPE gcs,
|
|
1045
|
-
KEY_ID '${escapedKeyId}',
|
|
1046
|
-
SECRET '${escapedSecret}'
|
|
1047
|
-
);
|
|
1048
|
-
`;
|
|
1049
|
-
} else {
|
|
1050
|
-
const region = credentials.region || "us-east-1";
|
|
1051
|
-
|
|
1052
|
-
if (credentials.endpoint) {
|
|
1053
|
-
const escapedEndpoint = escapeSQL(credentials.endpoint);
|
|
1054
|
-
createSecretCommand = `
|
|
1055
|
-
CREATE OR REPLACE SECRET ${secretName} (
|
|
1056
|
-
TYPE s3,
|
|
1057
|
-
KEY_ID '${escapedKeyId}',
|
|
1058
|
-
SECRET '${escapedSecret}',
|
|
1059
|
-
REGION '${region}',
|
|
1060
|
-
ENDPOINT '${escapedEndpoint}',
|
|
1061
|
-
URL_STYLE 'path'
|
|
1062
|
-
);
|
|
1063
|
-
`;
|
|
1064
|
-
} else if (credentials.sessionToken) {
|
|
1065
|
-
const escapedToken = escapeSQL(credentials.sessionToken);
|
|
1066
|
-
createSecretCommand = `
|
|
1067
|
-
CREATE OR REPLACE SECRET ${secretName} (
|
|
1068
|
-
TYPE s3,
|
|
1069
|
-
KEY_ID '${escapedKeyId}',
|
|
1070
|
-
SECRET '${escapedSecret}',
|
|
1071
|
-
REGION '${region}',
|
|
1072
|
-
SESSION_TOKEN '${escapedToken}'
|
|
1073
|
-
);
|
|
1074
|
-
`;
|
|
1075
|
-
} else {
|
|
1076
|
-
createSecretCommand = `
|
|
1077
|
-
CREATE OR REPLACE SECRET ${secretName} (
|
|
1078
|
-
TYPE s3,
|
|
1079
|
-
KEY_ID '${escapedKeyId}',
|
|
1080
|
-
SECRET '${escapedSecret}',
|
|
1081
|
-
REGION '${region}'
|
|
1082
|
-
);
|
|
1083
|
-
`;
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
if (await doesSecretExistInDuckDB(connection, secretName)) {
|
|
1088
|
-
// Force refresh attachments using this storage
|
|
1089
|
-
await connection.runSQL(`DETACH ${attachedDb.name};`).catch(() => {});
|
|
1090
|
-
}
|
|
1091
|
-
await connection.runSQL(createSecretCommand);
|
|
1092
|
-
|
|
1093
|
-
logger.info(`Created ${storageType} secret: ${secretName}`);
|
|
1094
|
-
logger.info(`${storageType} connection configured for: ${attachedDb.name}`);
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
async function attachAzureStorage(
|
|
1098
|
-
connection: DuckDBConnection,
|
|
1099
|
-
attachedDb: AttachedDatabase,
|
|
1100
|
-
): Promise<void> {
|
|
1101
|
-
if (!attachedDb.azureConnection) {
|
|
1102
|
-
throw new Error(
|
|
1103
|
-
`Azure connection configuration missing for: ${attachedDb.name}`,
|
|
1104
|
-
);
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
const config = attachedDb.azureConnection;
|
|
1108
|
-
|
|
1109
|
-
// Extensions are loaded once in attachDatabasesToDuckDB before the loop
|
|
1110
|
-
const secretName = sanitizeSecretName(`azure_${attachedDb.name}`);
|
|
1111
|
-
|
|
1112
|
-
let createSecretCommand: string;
|
|
1113
|
-
|
|
1114
|
-
if (config.authType === "service_principal") {
|
|
1115
|
-
if (
|
|
1116
|
-
!config.tenantId ||
|
|
1117
|
-
!config.clientId ||
|
|
1118
|
-
!config.clientSecret ||
|
|
1119
|
-
!config.accountName
|
|
1120
|
-
) {
|
|
1121
|
-
throw new Error(
|
|
1122
|
-
`Azure SPN auth requires tenantId, clientId, clientSecret, and accountName for: ${attachedDb.name}`,
|
|
1123
|
-
);
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
const escapedTenantId = escapeSQL(config.tenantId);
|
|
1127
|
-
const escapedClientId = escapeSQL(config.clientId);
|
|
1128
|
-
const escapedClientSecret = escapeSQL(config.clientSecret);
|
|
1129
|
-
const escapedAccountName = escapeSQL(config.accountName);
|
|
1130
|
-
|
|
1131
|
-
createSecretCommand = `
|
|
1132
|
-
CREATE OR REPLACE SECRET ${secretName} (
|
|
1133
|
-
TYPE azure,
|
|
1134
|
-
PROVIDER service_principal,
|
|
1135
|
-
TENANT_ID '${escapedTenantId}',
|
|
1136
|
-
CLIENT_ID '${escapedClientId}',
|
|
1137
|
-
CLIENT_SECRET '${escapedClientSecret}',
|
|
1138
|
-
ACCOUNT_NAME '${escapedAccountName}'
|
|
1139
|
-
);
|
|
1140
|
-
`;
|
|
1141
|
-
} else if (config.authType === "sas_token") {
|
|
1142
|
-
if (!config.sasUrl) {
|
|
1143
|
-
throw new Error(
|
|
1144
|
-
`Azure SAS token auth requires sasUrl for: ${attachedDb.name}`,
|
|
1145
|
-
);
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// For SAS token auth, DuckDB can read the HTTPS SAS URL directly via httpfs.
|
|
1149
|
-
// No Azure secret is needed — just ensure httpfs is loaded.
|
|
1150
|
-
logger.info(
|
|
1151
|
-
`Azure SAS token configured for: ${attachedDb.name} (no secret needed, using direct URL)`,
|
|
1152
|
-
);
|
|
1153
|
-
return;
|
|
1154
|
-
} else {
|
|
1155
|
-
throw new Error(
|
|
1156
|
-
`Unsupported Azure auth type: ${config.authType} for: ${attachedDb.name}`,
|
|
1157
|
-
);
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
if (await doesSecretExistInDuckDB(connection, secretName)) {
|
|
1161
|
-
await connection.runSQL(`DETACH ${attachedDb.name};`).catch(() => {});
|
|
1162
|
-
}
|
|
1163
|
-
await connection.runSQL(createSecretCommand);
|
|
1164
|
-
|
|
1165
|
-
logger.info(`Created Azure secret: ${secretName}`);
|
|
1166
|
-
logger.info(`Azure ADLS connection configured for: ${attachedDb.name}`);
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
async function doesSecretExistInDuckDB(
|
|
1170
|
-
connection: DuckDBConnection,
|
|
1171
|
-
secretName: string,
|
|
1172
|
-
): Promise<boolean> {
|
|
1173
|
-
const escapedSecretName = escapeSQL(secretName);
|
|
1174
|
-
const result = await connection.runSQL(`
|
|
1175
|
-
SELECT COUNT(*) AS count
|
|
1176
|
-
FROM duckdb_secrets()
|
|
1177
|
-
WHERE name = '${escapedSecretName}';
|
|
1178
|
-
`);
|
|
1179
|
-
const rows = result.rows;
|
|
1180
|
-
return Number(rows?.[0]?.count ?? 0) > 0;
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
// Main attachment function
|
|
1184
|
-
async function attachDatabasesToDuckDB(
|
|
1185
|
-
duckdbConnection: DuckDBConnection,
|
|
1186
|
-
attachedDatabases: AttachedDatabase[],
|
|
1187
|
-
): Promise<void> {
|
|
1188
|
-
const attachHandlers = {
|
|
1189
|
-
bigquery: attachBigQuery,
|
|
1190
|
-
snowflake: attachSnowflake,
|
|
1191
|
-
postgres: attachPostgres,
|
|
1192
|
-
gcs: attachCloudStorage,
|
|
1193
|
-
s3: attachCloudStorage,
|
|
1194
|
-
azure: attachAzureStorage,
|
|
1195
|
-
};
|
|
1196
|
-
|
|
1197
|
-
// Generic (non-tier) attach session: defer to the deployment policy — off
|
|
1198
|
-
// under local-only, left at DuckDB's default under on-demand so existing
|
|
1199
|
-
// users see no behaviour change. Publisher still installs each attached
|
|
1200
|
-
// type's extension explicitly below.
|
|
1201
|
-
await applyExtensionSessionSettings(duckdbConnection);
|
|
1202
|
-
|
|
1203
|
-
// Pre-load extensions needed by any attached database type, once per connection
|
|
1204
|
-
const hasAzure = attachedDatabases.some((db) => db.type === "azure");
|
|
1205
|
-
if (hasAzure) {
|
|
1206
|
-
await installAndLoadExtension(duckdbConnection, "azure");
|
|
1207
|
-
await installAndLoadExtension(duckdbConnection, "httpfs");
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
for (const attachedDb of attachedDatabases) {
|
|
1211
|
-
try {
|
|
1212
|
-
// Check if already attached
|
|
1213
|
-
if (
|
|
1214
|
-
await isDatabaseAttached(duckdbConnection, attachedDb.name || "")
|
|
1215
|
-
) {
|
|
1216
|
-
logger.info(
|
|
1217
|
-
`Database ${attachedDb.name} is already attached, skipping`,
|
|
1218
|
-
);
|
|
1219
|
-
continue;
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// Get the appropriate handler
|
|
1223
|
-
const handler =
|
|
1224
|
-
attachHandlers[attachedDb.type as keyof typeof attachHandlers];
|
|
1225
|
-
if (!handler) {
|
|
1226
|
-
throw new Error(`Unsupported database type: ${attachedDb.type}`);
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
// Execute attachment
|
|
1230
|
-
try {
|
|
1231
|
-
await handler(duckdbConnection, attachedDb);
|
|
1232
|
-
} catch (attachError) {
|
|
1233
|
-
handleAlreadyAttachedError(attachError, attachedDb.name || "");
|
|
1234
|
-
}
|
|
1235
|
-
} catch (error) {
|
|
1236
|
-
logger.error(`Failed to attach database ${attachedDb.name}:`, error);
|
|
1237
|
-
throw new Error(
|
|
1238
|
-
`Failed to attach database ${attachedDb.name}: ${(error as Error).message}`,
|
|
1239
|
-
);
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
type ApiAzureConnection = components["schemas"]["AzureConnection"];
|
|
1245
|
-
|
|
1246
|
-
/**
|
|
1247
|
-
* Builds the actual Azure URL for a blob given an AzureConnection config.
|
|
1248
|
-
* Strips any glob pattern from the base URL and appends the blob name.
|
|
1249
|
-
*/
|
|
1250
|
-
function buildAzureFileUrl(
|
|
1251
|
-
azureConn: ApiAzureConnection,
|
|
1252
|
-
blobName: string,
|
|
1253
|
-
): string {
|
|
1254
|
-
if (azureConn.authType === "sas_token" && azureConn.sasUrl) {
|
|
1255
|
-
const qIdx = azureConn.sasUrl.indexOf("?");
|
|
1256
|
-
const baseUrl =
|
|
1257
|
-
qIdx >= 0 ? azureConn.sasUrl.substring(0, qIdx) : azureConn.sasUrl;
|
|
1258
|
-
const token = qIdx >= 0 ? azureConn.sasUrl.substring(qIdx) : "";
|
|
1259
|
-
// Single file URL — replace the filename portion so we don't double-append
|
|
1260
|
-
if (/\.(parquet|csv|json|jsonl|ndjson)$/i.test(baseUrl)) {
|
|
1261
|
-
const dir = baseUrl.replace(/\/[^/]+$/, "");
|
|
1262
|
-
return `${dir}/${blobName}${token}`;
|
|
1263
|
-
}
|
|
1264
|
-
// Glob or directory — strip trailing glob/slash and append blobName
|
|
1265
|
-
const cleanBase = baseUrl.replace(/\/\*[^/]*$/, "").replace(/\/+$/, "");
|
|
1266
|
-
return `${cleanBase}/${blobName}${token}`;
|
|
1267
|
-
} else if (azureConn.authType === "service_principal" && azureConn.fileUrl) {
|
|
1268
|
-
const url = azureConn.fileUrl;
|
|
1269
|
-
// Single file URL (ends with a data file extension) — replace the
|
|
1270
|
-
// filename with blobName so we don't double-append
|
|
1271
|
-
if (/\.(parquet|csv|json|jsonl|ndjson)$/i.test(url)) {
|
|
1272
|
-
return url.replace(/\/[^/]+$/, `/${blobName}`);
|
|
1273
|
-
}
|
|
1274
|
-
// Glob or directory — strip glob pattern and trailing slash, append blobName
|
|
1275
|
-
const base = url.replace(/\*[^/]*$/, "").replace(/\/+$/, "");
|
|
1276
|
-
return `${base}/${blobName}`;
|
|
1277
|
-
}
|
|
1278
|
-
throw new Error(
|
|
1279
|
-
`Cannot build Azure file URL: missing sasUrl or fileUrl in config`,
|
|
1280
|
-
);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
/**
|
|
1284
|
-
* Extends DuckDBConnection to resolve Azure attached-database table paths.
|
|
1285
|
-
* When Malloy compiles di.table('azure_schema.blob_name'), the path
|
|
1286
|
-
* 'azure_schema.blob_name' is passed to fetchTableSchema. This override
|
|
1287
|
-
* detects that prefix, constructs the real Azure URL, and forwards it to
|
|
1288
|
-
* DuckDB so the azure/httpfs extension can read the file.
|
|
1289
|
-
*/
|
|
1290
|
-
class AzureDuckDBConnection extends DuckDBConnection {
|
|
1291
|
-
private azureDatabases: AttachedDatabase[];
|
|
1292
|
-
|
|
1293
|
-
constructor(
|
|
1294
|
-
options: PublisherDuckDBOptions,
|
|
1295
|
-
azureDatabases: AttachedDatabase[],
|
|
1296
|
-
) {
|
|
1297
|
-
super(options);
|
|
1298
|
-
this.azureDatabases = azureDatabases;
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
async fetchTableSchema(
|
|
1302
|
-
tableKey: string,
|
|
1303
|
-
tablePath: string,
|
|
1304
|
-
): Promise<TableSourceDef> {
|
|
1305
|
-
const dotIdx = tablePath.indexOf(".");
|
|
1306
|
-
if (dotIdx > 0) {
|
|
1307
|
-
const schemaName = tablePath.substring(0, dotIdx);
|
|
1308
|
-
const blobName = tablePath.substring(dotIdx + 1);
|
|
1309
|
-
|
|
1310
|
-
const azureDb = this.azureDatabases.find(
|
|
1311
|
-
(db) =>
|
|
1312
|
-
db.type === "azure" &&
|
|
1313
|
-
db.name === schemaName &&
|
|
1314
|
-
db.azureConnection,
|
|
1315
|
-
);
|
|
1316
|
-
|
|
1317
|
-
if (azureDb) {
|
|
1318
|
-
const azureUrl = buildAzureFileUrl(
|
|
1319
|
-
azureDb.azureConnection!,
|
|
1320
|
-
blobName,
|
|
1321
|
-
);
|
|
1322
|
-
logger.debug("Resolved Azure table path", {
|
|
1323
|
-
original: tablePath,
|
|
1324
|
-
resolved: azureUrl,
|
|
1325
|
-
});
|
|
1326
|
-
const result = await super.fetchTableSchema(tableKey, azureUrl);
|
|
1327
|
-
if (!result) {
|
|
1328
|
-
throw new Error(`Azure file not found: ${azureUrl}`);
|
|
1329
|
-
}
|
|
1330
|
-
return result;
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
const result = await super.fetchTableSchema(tableKey, tablePath);
|
|
1335
|
-
if (!result) {
|
|
1336
|
-
throw new Error(`Table ${tablePath} not found`);
|
|
1337
|
-
}
|
|
1338
|
-
return result;
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
class DuckLakeConnection extends DuckDBConnection {
|
|
1343
|
-
private connectionName: string;
|
|
1344
|
-
|
|
1345
|
-
constructor(options: PublisherDuckDBOptions) {
|
|
1346
|
-
super(options);
|
|
1347
|
-
|
|
1348
|
-
// Validate that this is a DuckLake connection by checking the database path pattern
|
|
1349
|
-
if (!options.databasePath?.endsWith("_ducklake.duckdb")) {
|
|
1350
|
-
throw new Error(
|
|
1351
|
-
`DuckLakeConnection should only be used for DuckLake connections. ` +
|
|
1352
|
-
`Expected database path ending with '_ducklake.duckdb', got: ${options.databasePath}`,
|
|
1353
|
-
);
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
this.connectionName = options.name;
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
async fetchTableSchema(
|
|
1360
|
-
tableKey: string,
|
|
1361
|
-
tablePath: string,
|
|
1362
|
-
): Promise<TableSourceDef> {
|
|
1363
|
-
// DuckLake-specific logic: prefix table path with connection name if needed
|
|
1364
|
-
const parts = tablePath.split(".");
|
|
1365
|
-
if (
|
|
1366
|
-
!tablePath.startsWith(this.connectionName) &&
|
|
1367
|
-
(parts.length === 1 || parts.length === 2)
|
|
1368
|
-
) {
|
|
1369
|
-
const prefixedPath = `${this.connectionName}.${tablePath}`;
|
|
1370
|
-
logger.debug("Prefixing DuckLake table path", {
|
|
1371
|
-
original: tablePath,
|
|
1372
|
-
prefixed: prefixedPath,
|
|
1373
|
-
connectionName: this.connectionName,
|
|
1374
|
-
});
|
|
1375
|
-
const result = await super.fetchTableSchema(tableKey, prefixedPath);
|
|
1376
|
-
if (!result) {
|
|
1377
|
-
throw new Error(
|
|
1378
|
-
`Table ${prefixedPath} not found in connection ${this.connectionName}`,
|
|
1379
|
-
);
|
|
1380
|
-
}
|
|
1381
|
-
return result;
|
|
1382
|
-
}
|
|
1383
|
-
// If already prefixed or has 3+ parts, use as-is;
|
|
1384
|
-
// For attached databases, in the future
|
|
1385
|
-
const result = await super.fetchTableSchema(tableKey, tablePath);
|
|
1386
|
-
if (!result) {
|
|
1387
|
-
throw new Error(
|
|
1388
|
-
`Table ${tablePath} not found in connection ${this.connectionName}`,
|
|
1389
|
-
);
|
|
1390
|
-
}
|
|
1391
|
-
return result;
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
export async function deleteDuckLakeConnectionFile(
|
|
1396
|
-
connectionName: string,
|
|
1397
|
-
environmentPath: string,
|
|
1398
|
-
): Promise<void> {
|
|
1399
|
-
assertSafePackageName(connectionName);
|
|
1400
|
-
assertSafeEnvironmentPath(environmentPath);
|
|
1401
|
-
const ducklakePath = safeJoinUnderRoot(
|
|
1402
|
-
environmentPath,
|
|
1403
|
-
`${connectionName}_ducklake.duckdb`,
|
|
1404
|
-
);
|
|
1405
|
-
try {
|
|
1406
|
-
await fs.access(ducklakePath);
|
|
1407
|
-
await fs.rm(ducklakePath);
|
|
1408
|
-
logger.info(
|
|
1409
|
-
`Removed DuckLake connection file ${connectionName}_ducklake.duckdb from ${environmentPath}`,
|
|
1410
|
-
);
|
|
1411
|
-
} catch (error) {
|
|
1412
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
1413
|
-
logger.debug(
|
|
1414
|
-
`DuckLake connection file ${connectionName}_ducklake.duckdb does not exist, skipping deletion`,
|
|
1415
|
-
);
|
|
1416
|
-
} else {
|
|
1417
|
-
logger.error(
|
|
1418
|
-
`Failed to remove DuckLake connection file ${connectionName}_ducklake.duckdb from ${environmentPath}`,
|
|
1419
|
-
{ error },
|
|
1420
|
-
);
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
export type EnvironmentMalloyConfig = {
|
|
1426
|
-
malloyConfig: MalloyConfig;
|
|
1427
|
-
apiConnections: InternalConnection[];
|
|
1428
|
-
// Releases both core-managed connections and Publisher wrapper-managed
|
|
1429
|
-
// DuckDB connections captured by wrapConnections.
|
|
1430
|
-
releaseConnections: () => Promise<void>;
|
|
1431
|
-
};
|
|
1432
|
-
|
|
1433
|
-
function entryToDuckDBOptions(
|
|
1434
|
-
name: string,
|
|
1435
|
-
entry: CoreConnectionEntry,
|
|
1436
|
-
workingDirectory?: string,
|
|
1437
|
-
): PublisherDuckDBOptions {
|
|
1438
|
-
const { is: _is, ...rest } = entry;
|
|
1439
|
-
if (workingDirectory !== undefined) {
|
|
1440
|
-
rest.workingDirectory = workingDirectory;
|
|
1441
|
-
}
|
|
1442
|
-
// `name` is always present; only the other fields may be undefined.
|
|
1443
|
-
return { ...removeUndefined(rest), name };
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
function removeUndefined<T extends object>(value: T): Partial<T> {
|
|
1447
|
-
return Object.fromEntries(
|
|
1448
|
-
Object.entries(value).filter(
|
|
1449
|
-
([, fieldValue]) => fieldValue !== undefined,
|
|
1450
|
-
),
|
|
1451
|
-
) as Partial<T>;
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
function buildSnowflakePrivateKeyConnection(
|
|
1455
|
-
metadata: EnvironmentConnectionMetadata,
|
|
1456
|
-
): SnowflakeConnection {
|
|
1457
|
-
const name = metadata.apiConnection.name!;
|
|
1458
|
-
const snowflake = metadata.apiConnection.snowflakeConnection;
|
|
1459
|
-
if (!snowflake?.privateKey) {
|
|
1460
|
-
throw new Error(
|
|
1461
|
-
`Snowflake private key is required for connection ${name}`,
|
|
1462
|
-
);
|
|
1463
|
-
}
|
|
1464
|
-
if (!snowflake.account) {
|
|
1465
|
-
throw new Error(`Snowflake account is required for connection ${name}`);
|
|
1466
|
-
}
|
|
1467
|
-
if (!snowflake.username) {
|
|
1468
|
-
throw new Error(`Snowflake username is required for connection ${name}`);
|
|
1469
|
-
}
|
|
1470
|
-
if (!snowflake.warehouse) {
|
|
1471
|
-
throw new Error(`Snowflake warehouse is required for connection ${name}`);
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
return new SnowflakeConnection(name, {
|
|
1475
|
-
connOptions: {
|
|
1476
|
-
account: snowflake.account,
|
|
1477
|
-
username: snowflake.username,
|
|
1478
|
-
privateKey: normalizeSnowflakePrivateKey(snowflake.privateKey),
|
|
1479
|
-
authenticator: "SNOWFLAKE_JWT",
|
|
1480
|
-
warehouse: snowflake.warehouse,
|
|
1481
|
-
...removeUndefined({
|
|
1482
|
-
password: snowflake.password,
|
|
1483
|
-
privateKeyPass: snowflake.privateKeyPass,
|
|
1484
|
-
database: snowflake.database,
|
|
1485
|
-
schema: snowflake.schema,
|
|
1486
|
-
role: snowflake.role,
|
|
1487
|
-
}),
|
|
1488
|
-
},
|
|
1489
|
-
timeoutMs: snowflake.responseTimeoutMilliseconds,
|
|
1490
|
-
// Match the pool sizing used on the registry path (and in main's
|
|
1491
|
-
// pre-MalloyConfig switch). Server-owned, not exposed via API.
|
|
1492
|
-
poolOptions: buildPoolOptions({ poolMin: 1, poolMax: 20 }),
|
|
1493
|
-
});
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
// TLS mode for a proxied postgres connection, as `pg` connectionString query
|
|
1497
|
-
// params. @malloydata/db-postgres forwards a connectionString straight to pg —
|
|
1498
|
-
// which parses sslmode. The tunnel terminates at 127.0.0.1, so pg's hostname
|
|
1499
|
-
// check can't apply from the connectionString: `verify-ca` validates the cert
|
|
1500
|
-
// chain against the trusted CA bundle (NODE_EXTRA_CA_CERTS, e.g. the baked
|
|
1501
|
-
// Amazon RDS roots) WITHOUT the hostname; `no-verify` encrypts without checking;
|
|
1502
|
-
// `disable` uses no TLS. `verify-full` (chain + hostname) can't be expressed as
|
|
1503
|
-
// a connectionString param through a tunnel — it needs a raw `ssl` object with
|
|
1504
|
-
// `servername` set to the real host; see resolveProxiedTls below.
|
|
1505
|
-
export function buildProxiedSslQuery(name: string, sslmode?: string): string {
|
|
1506
|
-
const caBundle = process.env.NODE_EXTRA_CA_CERTS;
|
|
1507
|
-
// Default: encrypt (no-verify) so a force-SSL target (the common RDS case)
|
|
1508
|
-
// isn't rejected for plaintext. Operators opt up to verify-ca when the
|
|
1509
|
-
// target's CA is in the trust bundle, or down to disable for non-TLS DBs.
|
|
1510
|
-
const mode = sslmode ?? "no-verify";
|
|
1511
|
-
switch (mode) {
|
|
1512
|
-
case "disable":
|
|
1513
|
-
// Explicit: with no sslmode in the connectionString, pg falls back to
|
|
1514
|
-
// the PGSSLMODE env (set on the non-proxied path), which would verify
|
|
1515
|
-
// and fail the 127.0.0.1 hostname check. Force plaintext explicitly.
|
|
1516
|
-
return "?sslmode=disable";
|
|
1517
|
-
case "no-verify":
|
|
1518
|
-
return "?sslmode=no-verify";
|
|
1519
|
-
case "verify-ca":
|
|
1520
|
-
// Env-set backstop; validateConnectionShape does the readable-file check
|
|
1521
|
-
// at config load (so a bad bundle fails fast instead of at connect time).
|
|
1522
|
-
if (!caBundle) {
|
|
1523
|
-
throw new Error(
|
|
1524
|
-
`Connection proxy on '${name}' uses sslmode 'verify-ca' but no trusted CA bundle is available (NODE_EXTRA_CA_CERTS is unset). Add the CA bundle to the image or use sslmode 'no-verify'.`,
|
|
1525
|
-
);
|
|
1526
|
-
}
|
|
1527
|
-
return `?uselibpqcompat=true&sslmode=verify-ca&sslrootcert=${encodeURIComponent(caBundle)}`;
|
|
1528
|
-
default:
|
|
1529
|
-
throw new Error(
|
|
1530
|
-
`Connection proxy on '${name}' has unsupported sslmode '${mode}' (expected disable | no-verify | verify-ca; verify-full is applied via the ssl object in resolveProxiedTls, not this query).`,
|
|
1531
|
-
);
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
// Resolve a proxied connection's TLS mode into either a connectionString query
|
|
1536
|
-
// suffix (disable/no-verify/verify-ca — parsed by pg from the connectionString)
|
|
1537
|
-
// or a raw `ssl` object. `verify-full` takes the ssl path: full verification
|
|
1538
|
-
// (chain + hostname) through a tunnel needs `servername` set to the real DB host,
|
|
1539
|
-
// which a connectionString sslmode param can't carry. The socket targets the
|
|
1540
|
-
// 127.0.0.1 IP literal, so pg preserves our `servername` (it only overwrites it
|
|
1541
|
-
// with `host` when host is a DNS name), verifying the cert's SAN against the real
|
|
1542
|
-
// host. Verification runs against Node's ambient trust anchors — its bundled
|
|
1543
|
-
// Mozilla CA roots plus NODE_EXTRA_CA_CERTS (e.g. the baked Amazon RDS roots) —
|
|
1544
|
-
// not the OS system store (unless Node is run with --use-system-ca).
|
|
1545
|
-
export function resolveProxiedTls(
|
|
1546
|
-
name: string,
|
|
1547
|
-
host: string | undefined,
|
|
1548
|
-
sslmode?: string,
|
|
1549
|
-
): { query: string; ssl?: PostgresSSLConfig } {
|
|
1550
|
-
if (sslmode === "verify-full") {
|
|
1551
|
-
if (!host) {
|
|
1552
|
-
// validateConnectionShape requires host on a proxied connection, so this
|
|
1553
|
-
// is unreachable in practice — guard so servername is never undefined.
|
|
1554
|
-
throw new Error(
|
|
1555
|
-
`Connection proxy on '${name}' uses sslmode 'verify-full' but has no host to verify against.`,
|
|
1556
|
-
);
|
|
1557
|
-
}
|
|
1558
|
-
return { query: "", ssl: { servername: host, rejectUnauthorized: true } };
|
|
1559
|
-
}
|
|
1560
|
-
return { query: buildProxiedSslQuery(name, sslmode) };
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
function buildProxiedPostgresConnection(
|
|
1564
|
-
metadata: EnvironmentConnectionMetadata,
|
|
1565
|
-
endpoint: ProxyEndpoint,
|
|
1566
|
-
): PooledPostgresConnection {
|
|
1567
|
-
const name = metadata.apiConnection.name!;
|
|
1568
|
-
const pg = metadata.apiConnection.postgresConnection;
|
|
1569
|
-
if (!pg) {
|
|
1570
|
-
throw new Error(
|
|
1571
|
-
`Proxied connection '${name}' has type 'postgres' but no postgresConnection config.`,
|
|
1572
|
-
);
|
|
1573
|
-
}
|
|
1574
|
-
// Build a connectionString to the LOCAL tunnel endpoint carrying the TLS mode.
|
|
1575
|
-
// This is NOT the tenant's connectionString (rejected in validateConnectionShape
|
|
1576
|
-
// because it can't be tunneled) — we construct it ourselves and it targets
|
|
1577
|
-
// 127.0.0.1:<localport>, so there's no ambiguity about where it connects.
|
|
1578
|
-
const enc = encodeURIComponent;
|
|
1579
|
-
const auth = pg.userName
|
|
1580
|
-
? `${enc(pg.userName)}${pg.password ? `:${enc(pg.password)}` : ""}@`
|
|
1581
|
-
: "";
|
|
1582
|
-
const db = pg.databaseName ? `/${enc(pg.databaseName)}` : "";
|
|
1583
|
-
const { query, ssl } = resolveProxiedTls(name, pg.host, pg.sslmode);
|
|
1584
|
-
const connectionString = `postgresql://${auth}${endpoint.host}:${endpoint.port}${db}${query}`;
|
|
1585
|
-
return new PooledPostgresConnection({
|
|
1586
|
-
name,
|
|
1587
|
-
connectionString,
|
|
1588
|
-
...(ssl ? { ssl } : {}),
|
|
1589
|
-
// Pool sizing mirrors buildSnowflakePrivateKeyConnection.
|
|
1590
|
-
poolMin: 1,
|
|
1591
|
-
poolMax: 20,
|
|
1592
|
-
});
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
function buildDuckLakeConnection(
|
|
1596
|
-
metadata: EnvironmentConnectionMetadata,
|
|
1597
|
-
entry: CoreConnectionEntry,
|
|
1598
|
-
): DuckLakeConnection {
|
|
1599
|
-
return new DuckLakeConnection(
|
|
1600
|
-
entryToDuckDBOptions(
|
|
1601
|
-
metadata.apiConnection.name!,
|
|
1602
|
-
entry,
|
|
1603
|
-
metadata.workingDirectory,
|
|
1604
|
-
),
|
|
1605
|
-
);
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
function buildAzureDuckDBConnection(
|
|
1609
|
-
metadata: EnvironmentConnectionMetadata,
|
|
1610
|
-
entry: CoreConnectionEntry,
|
|
1611
|
-
): AzureDuckDBConnection {
|
|
1612
|
-
return new AzureDuckDBConnection(
|
|
1613
|
-
entryToDuckDBOptions(
|
|
1614
|
-
metadata.apiConnection.name!,
|
|
1615
|
-
entry,
|
|
1616
|
-
metadata.workingDirectory,
|
|
1617
|
-
),
|
|
1618
|
-
metadata.attachedDatabases,
|
|
1619
|
-
);
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
function getMetadataForLookup(
|
|
1623
|
-
metadata: Map<string, EnvironmentConnectionMetadata>,
|
|
1624
|
-
name?: string,
|
|
1625
|
-
): EnvironmentConnectionMetadata | undefined {
|
|
1626
|
-
return name ? metadata.get(name) : undefined;
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
/**
|
|
1630
|
-
* Make a connection's digest the caller-supplied API `fingerprint`, verbatim.
|
|
1631
|
-
*
|
|
1632
|
-
* The fingerprint is the connection's data identity as declared by the caller
|
|
1633
|
-
* (secret-free, stable across credential rotation). When set, it replaces the
|
|
1634
|
-
* locally derived `getDigest()` so it becomes the connection's contribution to
|
|
1635
|
-
* content-addressed source ids everywhere a digest is read:
|
|
1636
|
-
*
|
|
1637
|
-
* - build planning (`compilePackageBuildPlan` -> `conn.getDigest()`),
|
|
1638
|
-
* - the package-load worker's connection-metadata RPC, and
|
|
1639
|
-
* - Malloy's own serve-time manifest resolution (the runtime calls
|
|
1640
|
-
* `conn.getDigest()` internally when a build manifest is bound).
|
|
1641
|
-
*
|
|
1642
|
-
* Overriding the method on the resolved instance — inside the one
|
|
1643
|
-
* `lookupConnection` wrapper every resolution passes through — is what keeps
|
|
1644
|
-
* build-time and serve-time ids identical; applying it per call site would
|
|
1645
|
-
* invite divergence and a silent live-serving fallback. The value is used
|
|
1646
|
-
* verbatim (never re-hashed or combined with local details) and treated as an
|
|
1647
|
-
* opaque token: it is not a secret, but it is never logged or parsed.
|
|
1648
|
-
* Without a fingerprint the connection keeps its locally derived digest.
|
|
1649
|
-
*/
|
|
1650
|
-
function applyConnectionFingerprint(
|
|
1651
|
-
connection: Connection,
|
|
1652
|
-
metadata: EnvironmentConnectionMetadata | undefined,
|
|
1653
|
-
): Connection {
|
|
1654
|
-
const fingerprint = metadata?.apiConnection.fingerprint;
|
|
1655
|
-
if (fingerprint) {
|
|
1656
|
-
connection.getDigest = () => fingerprint;
|
|
1657
|
-
}
|
|
1658
|
-
return connection;
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
function isDuckDBConnection(
|
|
1662
|
-
connection: Connection,
|
|
1663
|
-
): connection is DuckDBConnection {
|
|
1664
|
-
return connection instanceof DuckDBConnection;
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
/**
|
|
1668
|
-
* @param isUpdateConnectionRequest Forces a re-ATTACH on the DuckLake wrapper
|
|
1669
|
-
* even when its catalog database is already attached on the cached
|
|
1670
|
-
* connection. Set true when this build is replacing a prior generation due
|
|
1671
|
-
* to a connection-config change, so the new generation rebinds against the
|
|
1672
|
-
* updated DuckLake settings (catalog DSN, storage secrets) rather than
|
|
1673
|
-
* trusting whatever attach state the prior generation left behind. On a
|
|
1674
|
-
* fresh environment the existing-attach check fails anyway, so the flag is a
|
|
1675
|
-
* no-op there. Only the DuckLake branch consults it today.
|
|
1676
|
-
*/
|
|
1677
|
-
export function buildEnvironmentMalloyConfig(
|
|
1678
|
-
connections: ApiConnection[] = [],
|
|
1679
|
-
environmentPath: string = "",
|
|
1680
|
-
isUpdateConnectionRequest: boolean = false,
|
|
1681
|
-
): EnvironmentMalloyConfig {
|
|
1682
|
-
const assembled = assembleEnvironmentConnections(
|
|
1683
|
-
connections,
|
|
1684
|
-
environmentPath,
|
|
1685
|
-
);
|
|
1686
|
-
// Cache the build Promise rather than the resolved Connection so two
|
|
1687
|
-
// concurrent lookupConnection() calls for the same name share one build
|
|
1688
|
-
// and we never leak a losing-instance pool/handle. Per-branch caches
|
|
1689
|
-
// preserve the concrete subtype so use sites don't need narrowing casts.
|
|
1690
|
-
const duckLakeCache = new Map<string, Promise<DuckLakeConnection>>();
|
|
1691
|
-
const snowflakeJwtCache = new Map<string, Promise<SnowflakeConnection>>();
|
|
1692
|
-
const azureDuckDBCache = new Map<string, Promise<AzureDuckDBConnection>>();
|
|
1693
|
-
const proxyConnectionCache = new Map<
|
|
1694
|
-
string,
|
|
1695
|
-
Promise<PooledPostgresConnection>
|
|
1696
|
-
>();
|
|
1697
|
-
const proxyEndpoints = new Map<string, ProxyEndpoint>();
|
|
1698
|
-
const attachPromises = new WeakMap<Connection, Promise<void>>();
|
|
1699
|
-
|
|
1700
|
-
const malloyConfig = new MalloyConfig(assembled.pojo, {
|
|
1701
|
-
config: contextOverlay({ rootDirectory: environmentPath }),
|
|
1702
|
-
});
|
|
1703
|
-
|
|
1704
|
-
async function attachOnce(
|
|
1705
|
-
connection: Connection,
|
|
1706
|
-
metadata: EnvironmentConnectionMetadata,
|
|
1707
|
-
): Promise<void> {
|
|
1708
|
-
if (
|
|
1709
|
-
metadata.attachedDatabases.length === 0 ||
|
|
1710
|
-
!isDuckDBConnection(connection)
|
|
1711
|
-
) {
|
|
1712
|
-
return;
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
let attachPromise = attachPromises.get(connection);
|
|
1716
|
-
if (!attachPromise) {
|
|
1717
|
-
// One ATTACH run per connection object per config generation.
|
|
1718
|
-
attachPromise = attachDatabasesToDuckDB(
|
|
1719
|
-
connection,
|
|
1720
|
-
metadata.attachedDatabases,
|
|
1721
|
-
);
|
|
1722
|
-
attachPromises.set(connection, attachPromise);
|
|
1723
|
-
}
|
|
1724
|
-
await attachPromise;
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
malloyConfig.wrapConnections(
|
|
1728
|
-
(base: LookupConnection<Connection>): LookupConnection<Connection> => {
|
|
1729
|
-
const resolveConnection = async (
|
|
1730
|
-
name: string | undefined,
|
|
1731
|
-
metadata: EnvironmentConnectionMetadata | undefined,
|
|
1732
|
-
): Promise<Connection> => {
|
|
1733
|
-
if (metadata?.isDuckLake) {
|
|
1734
|
-
let connectionPromise = duckLakeCache.get(name!);
|
|
1735
|
-
if (!connectionPromise) {
|
|
1736
|
-
const entry = assembled.pojo.connections[name!];
|
|
1737
|
-
connectionPromise = Promise.resolve(
|
|
1738
|
-
buildDuckLakeConnection(metadata, entry),
|
|
1739
|
-
);
|
|
1740
|
-
duckLakeCache.set(name!, connectionPromise);
|
|
1741
|
-
}
|
|
1742
|
-
const connection = await connectionPromise;
|
|
1743
|
-
if (
|
|
1744
|
-
isUpdateConnectionRequest ||
|
|
1745
|
-
!(await isDatabaseAttached(connection, name!))
|
|
1746
|
-
) {
|
|
1747
|
-
await attachDuckLake(
|
|
1748
|
-
connection,
|
|
1749
|
-
name!,
|
|
1750
|
-
metadata.apiConnection.ducklakeConnection!,
|
|
1751
|
-
);
|
|
1752
|
-
}
|
|
1753
|
-
return connection;
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
if (metadata?.proxy) {
|
|
1757
|
-
let connectionPromise = proxyConnectionCache.get(name!);
|
|
1758
|
-
if (!connectionPromise) {
|
|
1759
|
-
const connType = metadata.apiConnection.type;
|
|
1760
|
-
if (connType !== "postgres") {
|
|
1761
|
-
throw new Error(
|
|
1762
|
-
`SSH proxy is not yet supported for connection type '${connType}'. ` +
|
|
1763
|
-
`Only 'postgres' is implemented.`,
|
|
1764
|
-
);
|
|
1765
|
-
}
|
|
1766
|
-
// validateConnectionShape already enforces explicit host/port
|
|
1767
|
-
// at config load (the connectionString form can't be tunneled);
|
|
1768
|
-
// this is a defense-in-depth guard for any path that reaches
|
|
1769
|
-
// lookup without that validation.
|
|
1770
|
-
const pgConfig = metadata.apiConnection.postgresConnection;
|
|
1771
|
-
if (!pgConfig?.host || !pgConfig?.port) {
|
|
1772
|
-
throw new Error(
|
|
1773
|
-
`SSH proxy for connection '${name}' requires explicit host and ` +
|
|
1774
|
-
`port on the postgres connection; the connectionString form ` +
|
|
1775
|
-
`is not supported with a proxy.`,
|
|
1776
|
-
);
|
|
1777
|
-
}
|
|
1778
|
-
connectionPromise = (async () => {
|
|
1779
|
-
const endpoint = await openProxy(metadata.proxy!, {
|
|
1780
|
-
host: pgConfig.host!,
|
|
1781
|
-
port: pgConfig.port!,
|
|
1782
|
-
});
|
|
1783
|
-
try {
|
|
1784
|
-
const connection = buildProxiedPostgresConnection(
|
|
1785
|
-
metadata,
|
|
1786
|
-
endpoint,
|
|
1787
|
-
);
|
|
1788
|
-
// Register only after the build succeeds: a throw between
|
|
1789
|
-
// openProxy and here would otherwise leave an orphaned
|
|
1790
|
-
// tunnel in proxyEndpoints that a retry can't reach (the
|
|
1791
|
-
// outer .catch evicts the cache but not the endpoint).
|
|
1792
|
-
proxyEndpoints.set(name!, endpoint);
|
|
1793
|
-
return connection;
|
|
1794
|
-
} catch (err) {
|
|
1795
|
-
await endpoint.close().catch(() => {});
|
|
1796
|
-
throw err;
|
|
1797
|
-
}
|
|
1798
|
-
})();
|
|
1799
|
-
// Drop a rejected build from the cache so a later lookup can
|
|
1800
|
-
// retry, rather than replaying a transient SSH failure (bastion
|
|
1801
|
-
// restart, network blip) until the environment is rebuilt.
|
|
1802
|
-
connectionPromise.catch(() => {
|
|
1803
|
-
if (
|
|
1804
|
-
proxyConnectionCache.get(name!) === connectionPromise
|
|
1805
|
-
) {
|
|
1806
|
-
proxyConnectionCache.delete(name!);
|
|
1807
|
-
}
|
|
1808
|
-
});
|
|
1809
|
-
proxyConnectionCache.set(name!, connectionPromise);
|
|
1810
|
-
}
|
|
1811
|
-
return connectionPromise;
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
if (metadata?.hasSnowflakePrivateKey) {
|
|
1815
|
-
let connectionPromise = snowflakeJwtCache.get(name!);
|
|
1816
|
-
if (!connectionPromise) {
|
|
1817
|
-
connectionPromise = Promise.resolve(
|
|
1818
|
-
buildSnowflakePrivateKeyConnection(metadata),
|
|
1819
|
-
);
|
|
1820
|
-
snowflakeJwtCache.set(name!, connectionPromise);
|
|
1821
|
-
}
|
|
1822
|
-
return connectionPromise;
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
if (metadata?.hasAzureAttachment) {
|
|
1826
|
-
let connectionPromise = azureDuckDBCache.get(name!);
|
|
1827
|
-
if (!connectionPromise) {
|
|
1828
|
-
const entry = assembled.pojo.connections[name!];
|
|
1829
|
-
connectionPromise = Promise.resolve(
|
|
1830
|
-
buildAzureDuckDBConnection(metadata, entry),
|
|
1831
|
-
);
|
|
1832
|
-
azureDuckDBCache.set(name!, connectionPromise);
|
|
1833
|
-
}
|
|
1834
|
-
const connection = await connectionPromise;
|
|
1835
|
-
await attachOnce(connection, metadata);
|
|
1836
|
-
return connection;
|
|
1837
|
-
}
|
|
1838
|
-
|
|
1839
|
-
const connection = await base.lookupConnection(name);
|
|
1840
|
-
if (metadata) {
|
|
1841
|
-
await attachOnce(connection, metadata);
|
|
1842
|
-
}
|
|
1843
|
-
return connection;
|
|
1844
|
-
};
|
|
1845
|
-
|
|
1846
|
-
return {
|
|
1847
|
-
lookupConnection: async (name?: string): Promise<Connection> => {
|
|
1848
|
-
const metadata = getMetadataForLookup(assembled.metadata, name);
|
|
1849
|
-
const connection = await resolveConnection(name, metadata);
|
|
1850
|
-
// Pin every environment-level DuckDB session against implicit
|
|
1851
|
-
// auto-install (policy-driven) at this one exit — independent of
|
|
1852
|
-
// the attach paths, so the guarantee holds even if a resolution
|
|
1853
|
-
// branch is ever added that doesn't attach. The DuckLake tier
|
|
1854
|
-
// already pinned always-off inside its attach; the WeakSet makes
|
|
1855
|
-
// this a no-op there. (The per-package `:memory:` sandbox is a
|
|
1856
|
-
// separate MalloyConfig and is pinned in package.ts.)
|
|
1857
|
-
if (isDuckDBConnection(connection)) {
|
|
1858
|
-
await applyExtensionSessionSettings(connection);
|
|
1859
|
-
}
|
|
1860
|
-
// Every resolution branch funnels through this one exit so a
|
|
1861
|
-
// configured fingerprint is applied no matter which wrapper
|
|
1862
|
-
// produced the connection (see applyConnectionFingerprint).
|
|
1863
|
-
return applyConnectionFingerprint(connection, metadata);
|
|
1864
|
-
},
|
|
1865
|
-
};
|
|
1866
|
-
},
|
|
1867
|
-
);
|
|
1868
|
-
|
|
1869
|
-
return {
|
|
1870
|
-
malloyConfig,
|
|
1871
|
-
apiConnections: assembled.apiConnections,
|
|
1872
|
-
releaseConnections: async () => {
|
|
1873
|
-
const wrapperPromises: Promise<Connection>[] = [
|
|
1874
|
-
...duckLakeCache.values(),
|
|
1875
|
-
...snowflakeJwtCache.values(),
|
|
1876
|
-
...azureDuckDBCache.values(),
|
|
1877
|
-
...proxyConnectionCache.values(),
|
|
1878
|
-
];
|
|
1879
|
-
const closeResults = await Promise.allSettled([
|
|
1880
|
-
malloyConfig.shutdown("close"),
|
|
1881
|
-
...wrapperPromises.map(async (promise) => {
|
|
1882
|
-
const connection = await promise;
|
|
1883
|
-
await connection.close();
|
|
1884
|
-
}),
|
|
1885
|
-
]);
|
|
1886
|
-
// Close proxy tunnels only AFTER the build promises above have settled:
|
|
1887
|
-
// openProxy may still be in flight during a teardown race, and the
|
|
1888
|
-
// endpoint is registered inside that promise — capturing the map
|
|
1889
|
-
// earlier would miss it and leak the tunnel. Closing after the DB
|
|
1890
|
-
// connections also keeps the tunnel alive while they drain.
|
|
1891
|
-
const endpointResults = await Promise.allSettled(
|
|
1892
|
-
[...proxyEndpoints.values()].map((ep) => ep.close()),
|
|
1893
|
-
);
|
|
1894
|
-
duckLakeCache.clear();
|
|
1895
|
-
snowflakeJwtCache.clear();
|
|
1896
|
-
azureDuckDBCache.clear();
|
|
1897
|
-
proxyConnectionCache.clear();
|
|
1898
|
-
proxyEndpoints.clear();
|
|
1899
|
-
|
|
1900
|
-
const failures = [...closeResults, ...endpointResults].filter(
|
|
1901
|
-
(result): result is PromiseRejectedResult =>
|
|
1902
|
-
result.status === "rejected",
|
|
1903
|
-
);
|
|
1904
|
-
// Log every failure individually before throwing — Promise.allSettled
|
|
1905
|
-
// already let every branch run, and a single AggregateError otherwise
|
|
1906
|
-
// hides per-branch detail from callers that just log the error.
|
|
1907
|
-
for (const failure of failures) {
|
|
1908
|
-
logger.error("Failed to release environment connection", {
|
|
1909
|
-
error: failure.reason,
|
|
1910
|
-
});
|
|
1911
|
-
}
|
|
1912
|
-
if (failures.length > 0) {
|
|
1913
|
-
throw new AggregateError(
|
|
1914
|
-
failures.map((failure) => failure.reason),
|
|
1915
|
-
"Failed to release one or more environment connections",
|
|
1916
|
-
);
|
|
1917
|
-
}
|
|
1918
|
-
},
|
|
1919
|
-
};
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
export async function createEnvironmentConnections(
|
|
1923
|
-
connections: ApiConnection[] = [],
|
|
1924
|
-
environmentPath: string = "",
|
|
1925
|
-
isUpdateConnectionRequest: boolean = false,
|
|
1926
|
-
): Promise<{
|
|
1927
|
-
malloyConnections: Map<string, Connection>;
|
|
1928
|
-
apiConnections: InternalConnection[];
|
|
1929
|
-
releaseConnections: () => Promise<void>;
|
|
1930
|
-
}> {
|
|
1931
|
-
const connectionMap = new Map<string, Connection>();
|
|
1932
|
-
const environmentConfig = buildEnvironmentMalloyConfig(
|
|
1933
|
-
connections,
|
|
1934
|
-
environmentPath,
|
|
1935
|
-
isUpdateConnectionRequest,
|
|
1936
|
-
);
|
|
1937
|
-
|
|
1938
|
-
for (const connection of environmentConfig.apiConnections) {
|
|
1939
|
-
if (!connection.name) continue;
|
|
1940
|
-
logger.info(`Adding connection ${connection.name}`, {
|
|
1941
|
-
type: connection.type,
|
|
1942
|
-
});
|
|
1943
|
-
const malloyConnection =
|
|
1944
|
-
await environmentConfig.malloyConfig.connections.lookupConnection(
|
|
1945
|
-
connection.name,
|
|
1946
|
-
);
|
|
1947
|
-
connection.attributes = getConnectionAttributes(malloyConnection);
|
|
1948
|
-
connectionMap.set(connection.name, malloyConnection);
|
|
1949
|
-
}
|
|
1950
|
-
|
|
1951
|
-
return {
|
|
1952
|
-
malloyConnections: connectionMap,
|
|
1953
|
-
apiConnections: environmentConfig.apiConnections,
|
|
1954
|
-
releaseConnections: environmentConfig.releaseConnections,
|
|
1955
|
-
};
|
|
1956
|
-
}
|
|
1957
|
-
|
|
1958
|
-
function getConnectionAttributes(
|
|
1959
|
-
connection: Connection,
|
|
1960
|
-
): ApiConnectionAttributes {
|
|
1961
|
-
let canStream = false;
|
|
1962
|
-
try {
|
|
1963
|
-
canStream = connection.canStream();
|
|
1964
|
-
} catch {
|
|
1965
|
-
// pass
|
|
1966
|
-
}
|
|
1967
|
-
return {
|
|
1968
|
-
dialectName: connection.dialectName,
|
|
1969
|
-
isPool: connection.isPool(),
|
|
1970
|
-
canPersist: connection.canPersist(),
|
|
1971
|
-
canStream: canStream,
|
|
1972
|
-
};
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
// TODO: Re-write these tests to validate based on credentials provided in the connection config
|
|
1976
|
-
async function testDuckDBConnection(
|
|
1977
|
-
duckdbConnection: DuckDBConnection,
|
|
1978
|
-
connectionConfig: InternalConnection,
|
|
1979
|
-
): Promise<void> {
|
|
1980
|
-
// Test base DuckDB connection with a simple query
|
|
1981
|
-
try {
|
|
1982
|
-
await duckdbConnection.runSQL("SELECT 1 AS test");
|
|
1983
|
-
logger.info(
|
|
1984
|
-
`DuckDB base connection test passed for: ${connectionConfig.name}`,
|
|
1985
|
-
);
|
|
1986
|
-
} catch (error) {
|
|
1987
|
-
throw new Error(
|
|
1988
|
-
`DuckDB base connection test failed: ${(error as Error).message}`,
|
|
1989
|
-
);
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
// Test each attached database if configured
|
|
1993
|
-
const attachedDatabases =
|
|
1994
|
-
connectionConfig.duckdbConnection?.attachedDatabases;
|
|
1995
|
-
if (!attachedDatabases || attachedDatabases.length === 0) {
|
|
1996
|
-
return;
|
|
1997
|
-
}
|
|
1998
|
-
|
|
1999
|
-
const failedAttachments: string[] = [];
|
|
2000
|
-
|
|
2001
|
-
for (const attachedDb of attachedDatabases) {
|
|
2002
|
-
if (!attachedDb.name) {
|
|
2003
|
-
continue;
|
|
2004
|
-
}
|
|
2005
|
-
|
|
2006
|
-
try {
|
|
2007
|
-
// Test the attached database by querying its tables/schemas
|
|
2008
|
-
// Different database types require different test queries
|
|
2009
|
-
switch (attachedDb.type) {
|
|
2010
|
-
case "postgres": {
|
|
2011
|
-
// Test postgres attachment by listing schemas
|
|
2012
|
-
await duckdbConnection.runSQL(
|
|
2013
|
-
`SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${attachedDb.name}' LIMIT 1`,
|
|
2014
|
-
);
|
|
2015
|
-
logger.info(
|
|
2016
|
-
`Attached Postgres database test passed: ${attachedDb.name}`,
|
|
2017
|
-
);
|
|
2018
|
-
break;
|
|
2019
|
-
}
|
|
2020
|
-
case "bigquery": {
|
|
2021
|
-
// Test BigQuery attachment by listing datasets
|
|
2022
|
-
// BigQuery attached databases show as catalogs
|
|
2023
|
-
await duckdbConnection.runSQL(
|
|
2024
|
-
`SELECT database_name FROM duckdb_databases() WHERE database_name = '${attachedDb.name}'`,
|
|
2025
|
-
);
|
|
2026
|
-
logger.info(
|
|
2027
|
-
`Attached BigQuery database test passed: ${attachedDb.name}`,
|
|
2028
|
-
);
|
|
2029
|
-
break;
|
|
2030
|
-
}
|
|
2031
|
-
case "snowflake": {
|
|
2032
|
-
// Test Snowflake attachment by verifying database is attached
|
|
2033
|
-
await duckdbConnection.runSQL(
|
|
2034
|
-
`SELECT database_name FROM duckdb_databases() WHERE database_name = '${attachedDb.name}'`,
|
|
2035
|
-
);
|
|
2036
|
-
logger.info(
|
|
2037
|
-
`Attached Snowflake database test passed: ${attachedDb.name}`,
|
|
2038
|
-
);
|
|
2039
|
-
break;
|
|
2040
|
-
}
|
|
2041
|
-
case "gcs": {
|
|
2042
|
-
await duckdbConnection.runSQL(
|
|
2043
|
-
`SELECT name FROM duckdb_secrets() WHERE name LIKE '%${attachedDb.name}%' LIMIT 1`,
|
|
2044
|
-
);
|
|
2045
|
-
logger.info(
|
|
2046
|
-
`Cloud storage credentials test passed: ${attachedDb.name}`,
|
|
2047
|
-
);
|
|
2048
|
-
break;
|
|
2049
|
-
}
|
|
2050
|
-
case "s3": {
|
|
2051
|
-
await duckdbConnection.runSQL(
|
|
2052
|
-
`SELECT name FROM duckdb_secrets() WHERE name LIKE '%${attachedDb.name}%' LIMIT 1`,
|
|
2053
|
-
);
|
|
2054
|
-
logger.info(
|
|
2055
|
-
`Cloud storage credentials test passed: ${attachedDb.name}`,
|
|
2056
|
-
);
|
|
2057
|
-
break;
|
|
2058
|
-
}
|
|
2059
|
-
case "azure": {
|
|
2060
|
-
const azureConfig = attachedDb.azureConnection;
|
|
2061
|
-
if (azureConfig?.authType === "sas_token") {
|
|
2062
|
-
// SAS token is embedded in the URL — no DuckDB secret is created.
|
|
2063
|
-
if (!azureConfig.sasUrl) {
|
|
2064
|
-
throw new Error(
|
|
2065
|
-
`Azure SAS token URL is missing for: ${attachedDb.name}`,
|
|
2066
|
-
);
|
|
2067
|
-
}
|
|
2068
|
-
logger.info(
|
|
2069
|
-
`Azure SAS token URL present for: ${attachedDb.name}`,
|
|
2070
|
-
);
|
|
2071
|
-
} else {
|
|
2072
|
-
await duckdbConnection.runSQL(
|
|
2073
|
-
`SELECT name FROM duckdb_secrets() WHERE name LIKE '%${attachedDb.name}%' LIMIT 1`,
|
|
2074
|
-
);
|
|
2075
|
-
logger.info(
|
|
2076
|
-
`Azure SPN credentials test passed: ${attachedDb.name}`,
|
|
2077
|
-
);
|
|
2078
|
-
}
|
|
2079
|
-
break;
|
|
2080
|
-
}
|
|
2081
|
-
default: {
|
|
2082
|
-
logger.warn(
|
|
2083
|
-
`Unknown attached database type: ${attachedDb.type}`,
|
|
2084
|
-
);
|
|
2085
|
-
}
|
|
2086
|
-
}
|
|
2087
|
-
} catch (error) {
|
|
2088
|
-
const errorMessage = `Attached database '${attachedDb.name}' (${attachedDb.type}) test failed: ${(error as Error).message}`;
|
|
2089
|
-
logger.error(errorMessage);
|
|
2090
|
-
failedAttachments.push(errorMessage);
|
|
2091
|
-
}
|
|
2092
|
-
}
|
|
2093
|
-
|
|
2094
|
-
if (failedAttachments.length > 0) {
|
|
2095
|
-
throw new Error(
|
|
2096
|
-
`DuckDB connection test failed for attached databases:\n${failedAttachments.join("\n")}`,
|
|
2097
|
-
);
|
|
2098
|
-
}
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
export async function testConnectionConfig(
|
|
2102
|
-
connectionConfig: ApiConnection,
|
|
2103
|
-
): Promise<ApiConnectionStatus> {
|
|
2104
|
-
let environmentConfig: EnvironmentMalloyConfig | null = null;
|
|
2105
|
-
try {
|
|
2106
|
-
// Validate that connection name is provided
|
|
2107
|
-
if (!connectionConfig.name) {
|
|
2108
|
-
throw new Error("Connection name is required");
|
|
2109
|
-
}
|
|
2110
|
-
|
|
2111
|
-
environmentConfig = buildEnvironmentMalloyConfig([connectionConfig]);
|
|
2112
|
-
const connection =
|
|
2113
|
-
await environmentConfig.malloyConfig.connections.lookupConnection(
|
|
2114
|
-
connectionConfig.name,
|
|
2115
|
-
);
|
|
2116
|
-
|
|
2117
|
-
// Handle DuckDB connections specially since they have attached databases
|
|
2118
|
-
if (connectionConfig.type === "duckdb") {
|
|
2119
|
-
await testDuckDBConnection(
|
|
2120
|
-
connection as DuckDBConnection,
|
|
2121
|
-
connectionConfig as InternalConnection,
|
|
2122
|
-
);
|
|
2123
|
-
} else if (connectionConfig.type === "ducklake") {
|
|
2124
|
-
// DuckLake uses DuckDB internally — verify the database is attached
|
|
2125
|
-
const duckConn = connection as DuckDBConnection;
|
|
2126
|
-
const attached = await isDatabaseAttached(
|
|
2127
|
-
duckConn,
|
|
2128
|
-
connectionConfig.name as string,
|
|
2129
|
-
);
|
|
2130
|
-
if (!attached) {
|
|
2131
|
-
throw new Error(
|
|
2132
|
-
`DuckLake connection test failed: Error attaching database '${connectionConfig.name}'`,
|
|
2133
|
-
);
|
|
2134
|
-
}
|
|
2135
|
-
await duckConn.runSQL(
|
|
2136
|
-
`SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${connectionConfig.name}' LIMIT 1`,
|
|
2137
|
-
);
|
|
2138
|
-
|
|
2139
|
-
logger.info(
|
|
2140
|
-
`DuckLake connection test passed: ${connectionConfig.name}`,
|
|
2141
|
-
);
|
|
2142
|
-
} else {
|
|
2143
|
-
// Test other connection types using their test() method
|
|
2144
|
-
await (
|
|
2145
|
-
connection as
|
|
2146
|
-
| PostgresConnection
|
|
2147
|
-
| BigQueryConnection
|
|
2148
|
-
| SnowflakeConnection
|
|
2149
|
-
| TrinoConnection
|
|
2150
|
-
| MySQLConnection
|
|
2151
|
-
).test();
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
|
-
return {
|
|
2155
|
-
status: "ok",
|
|
2156
|
-
errorMessage: "",
|
|
2157
|
-
};
|
|
2158
|
-
} catch (error) {
|
|
2159
|
-
if (error instanceof AxiosError) {
|
|
2160
|
-
logAxiosError(error);
|
|
2161
|
-
} else {
|
|
2162
|
-
logger.error(error);
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
return {
|
|
2166
|
-
status: "failed",
|
|
2167
|
-
errorMessage: (error as Error).message,
|
|
2168
|
-
};
|
|
2169
|
-
} finally {
|
|
2170
|
-
if (environmentConfig) {
|
|
2171
|
-
try {
|
|
2172
|
-
await environmentConfig.releaseConnections();
|
|
2173
|
-
} catch (closeError) {
|
|
2174
|
-
logger.warn("Error releasing temporary connection test config", {
|
|
2175
|
-
error: closeError,
|
|
2176
|
-
});
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
if (connectionConfig.type === "ducklake" && connectionConfig.name) {
|
|
2181
|
-
await deleteDuckLakeConnectionFile(
|
|
2182
|
-
connectionConfig.name,
|
|
2183
|
-
process.cwd(),
|
|
2184
|
-
);
|
|
2185
|
-
}
|
|
2186
|
-
}
|
|
2187
|
-
}
|