@malloy-publisher/server 0.0.235 → 0.0.236

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.
Files changed (301) hide show
  1. package/dist/app/api-doc.yaml +47 -17
  2. package/dist/package_load_worker.mjs +8 -1
  3. package/dist/server.mjs +471 -54
  4. package/package.json +5 -2
  5. package/.eslintrc.json +0 -44
  6. package/.prettierignore +0 -1
  7. package/.prettierrc +0 -4
  8. package/build.ts +0 -97
  9. package/eslint.config.mjs +0 -76
  10. package/k6-tests/README.md +0 -144
  11. package/k6-tests/bun.lock +0 -797
  12. package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
  13. package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
  14. package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
  15. package/k6-tests/load-test/load-test-crud.ts +0 -142
  16. package/k6-tests/load-test/load-test.ts +0 -784
  17. package/k6-tests/package.json +0 -27
  18. package/k6-tests/scripts/run-with-otel.sh +0 -50
  19. package/k6-tests/smoke-test/smoke-test.ts +0 -158
  20. package/k6-tests/tsconfig.json +0 -29
  21. package/k6-tests/utils/client_factory.ts +0 -128
  22. package/k6-tests/utils/common.ts +0 -489
  23. package/k6-tests/utils/logger.ts +0 -62
  24. package/k6-tests/utils/types.d.ts +0 -35
  25. package/malloy_mcp.dxt +0 -0
  26. package/publisher.config.example.bigquery.json +0 -33
  27. package/publisher.config.example.duckdb.json +0 -23
  28. package/publisher.config.json +0 -23
  29. package/scripts/bake-duckdb-extensions.js +0 -110
  30. package/src/config.spec.ts +0 -1535
  31. package/src/config.theme.spec.ts +0 -178
  32. package/src/config.ts +0 -1142
  33. package/src/constants.ts +0 -105
  34. package/src/controller/compile.controller.ts +0 -40
  35. package/src/controller/connection.controller.spec.ts +0 -849
  36. package/src/controller/connection.controller.ts +0 -901
  37. package/src/controller/database.controller.ts +0 -24
  38. package/src/controller/materialization.controller.spec.ts +0 -293
  39. package/src/controller/materialization.controller.ts +0 -319
  40. package/src/controller/model.controller.ts +0 -164
  41. package/src/controller/package.controller.spec.ts +0 -273
  42. package/src/controller/package.controller.ts +0 -306
  43. package/src/controller/query.controller.ts +0 -152
  44. package/src/controller/theme.controller.ts +0 -83
  45. package/src/controller/watch-mode.controller.ts +0 -243
  46. package/src/data_styles.ts +0 -71
  47. package/src/default-publisher.config.json +0 -23
  48. package/src/ducklake_version.spec.ts +0 -163
  49. package/src/ducklake_version.ts +0 -153
  50. package/src/errors.spec.ts +0 -119
  51. package/src/errors.ts +0 -237
  52. package/src/filter_deprecation.spec.ts +0 -64
  53. package/src/filter_deprecation.ts +0 -42
  54. package/src/health.spec.ts +0 -123
  55. package/src/health.ts +0 -233
  56. package/src/heap_check.spec.ts +0 -144
  57. package/src/heap_check.ts +0 -144
  58. package/src/instrumentation.ts +0 -214
  59. package/src/json_utils.spec.ts +0 -51
  60. package/src/json_utils.ts +0 -33
  61. package/src/logger.spec.ts +0 -210
  62. package/src/logger.ts +0 -251
  63. package/src/malloy_pin_prereqs.spec.ts +0 -25
  64. package/src/materialization_metrics.spec.ts +0 -169
  65. package/src/materialization_metrics.ts +0 -346
  66. package/src/mcp/error_messages.spec.ts +0 -35
  67. package/src/mcp/error_messages.ts +0 -183
  68. package/src/mcp/handler_utils.spec.ts +0 -108
  69. package/src/mcp/handler_utils.ts +0 -251
  70. package/src/mcp/mcp_constants.ts +0 -6
  71. package/src/mcp/query_envelope.spec.ts +0 -229
  72. package/src/mcp/query_envelope.ts +0 -240
  73. package/src/mcp/server.protocol.spec.ts +0 -250
  74. package/src/mcp/server.ts +0 -87
  75. package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
  76. package/src/mcp/skills/build_skills_bundle.ts +0 -176
  77. package/src/mcp/skills/skills_bundle.json +0 -1
  78. package/src/mcp/skills/skills_bundle.spec.ts +0 -171
  79. package/src/mcp/tool_response.spec.ts +0 -108
  80. package/src/mcp/tool_response.ts +0 -138
  81. package/src/mcp/tools/compile_tool.spec.ts +0 -315
  82. package/src/mcp/tools/compile_tool.ts +0 -208
  83. package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
  84. package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
  85. package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
  86. package/src/mcp/tools/docs_search_tool.ts +0 -128
  87. package/src/mcp/tools/embedding_index.spec.ts +0 -1236
  88. package/src/mcp/tools/embedding_index.ts +0 -808
  89. package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
  90. package/src/mcp/tools/execute_query_tool.ts +0 -316
  91. package/src/mcp/tools/get_context_eval.ts +0 -275
  92. package/src/mcp/tools/get_context_tool.spec.ts +0 -592
  93. package/src/mcp/tools/get_context_tool.ts +0 -585
  94. package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
  95. package/src/mcp/tools/reload_package_tool.ts +0 -132
  96. package/src/mcp_config.spec.ts +0 -919
  97. package/src/mcp_config.ts +0 -425
  98. package/src/oom_guards.integration.spec.ts +0 -269
  99. package/src/package_load/package_load_pool.spec.ts +0 -252
  100. package/src/package_load/package_load_pool.ts +0 -931
  101. package/src/package_load/package_load_worker.ts +0 -1034
  102. package/src/package_load/protocol.ts +0 -364
  103. package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
  104. package/src/package_load/rpc_wait_accountant.ts +0 -76
  105. package/src/package_load_metrics.spec.ts +0 -114
  106. package/src/package_load_metrics.ts +0 -127
  107. package/src/path_safety.spec.ts +0 -158
  108. package/src/path_safety.ts +0 -146
  109. package/src/pg_helpers.spec.ts +0 -223
  110. package/src/pg_helpers.ts +0 -52
  111. package/src/query_cap_metrics.spec.ts +0 -89
  112. package/src/query_cap_metrics.ts +0 -116
  113. package/src/query_concurrency.spec.ts +0 -247
  114. package/src/query_concurrency.ts +0 -237
  115. package/src/query_metadata_metrics.ts +0 -49
  116. package/src/query_param_utils.ts +0 -29
  117. package/src/query_timeout.spec.ts +0 -224
  118. package/src/query_timeout.ts +0 -179
  119. package/src/runtime/publisher.js +0 -328
  120. package/src/server-old.ts +0 -988
  121. package/src/server.ts +0 -2059
  122. package/src/service/annotations.spec.ts +0 -118
  123. package/src/service/annotations.ts +0 -91
  124. package/src/service/authorize.spec.ts +0 -154
  125. package/src/service/authorize.ts +0 -498
  126. package/src/service/authorize_integration.spec.ts +0 -2041
  127. package/src/service/build_plan.spec.ts +0 -685
  128. package/src/service/build_plan.ts +0 -797
  129. package/src/service/compile_authorize.spec.ts +0 -85
  130. package/src/service/compile_fragment_techniques.spec.ts +0 -156
  131. package/src/service/connection.spec.ts +0 -2360
  132. package/src/service/connection.ts +0 -2187
  133. package/src/service/connection_config.spec.ts +0 -734
  134. package/src/service/connection_config.ts +0 -899
  135. package/src/service/connection_federation.spec.ts +0 -184
  136. package/src/service/connection_fingerprint.spec.ts +0 -102
  137. package/src/service/connection_service.spec.ts +0 -711
  138. package/src/service/connection_service.ts +0 -291
  139. package/src/service/cron_evaluator.spec.ts +0 -79
  140. package/src/service/cron_evaluator.ts +0 -105
  141. package/src/service/db_utils.spec.ts +0 -1085
  142. package/src/service/db_utils.ts +0 -1502
  143. package/src/service/duckdb_instance_isolation.spec.ts +0 -137
  144. package/src/service/ducklake_lazy_attach.spec.ts +0 -110
  145. package/src/service/embedding_provider.spec.ts +0 -329
  146. package/src/service/embedding_provider.ts +0 -236
  147. package/src/service/environment.ts +0 -2191
  148. package/src/service/environment_admission.spec.ts +0 -344
  149. package/src/service/environment_compile.spec.ts +0 -197
  150. package/src/service/environment_store.spec.ts +0 -2274
  151. package/src/service/environment_store.ts +0 -2434
  152. package/src/service/environment_store_anchoring.spec.ts +0 -107
  153. package/src/service/environment_store_clone.spec.ts +0 -350
  154. package/src/service/explore_visibility.spec.ts +0 -434
  155. package/src/service/exports_probe.spec.ts +0 -107
  156. package/src/service/extension_fetch_policy.spec.ts +0 -256
  157. package/src/service/filter.spec.ts +0 -458
  158. package/src/service/filter.ts +0 -341
  159. package/src/service/filter_bypass.spec.ts +0 -418
  160. package/src/service/filter_integration.spec.ts +0 -1004
  161. package/src/service/freshness.spec.ts +0 -183
  162. package/src/service/freshness.ts +0 -112
  163. package/src/service/gcs_s3_utils.ts +0 -427
  164. package/src/service/given.ts +0 -105
  165. package/src/service/givens_integration.spec.ts +0 -219
  166. package/src/service/manifest_loader.spec.ts +0 -189
  167. package/src/service/manifest_loader.ts +0 -156
  168. package/src/service/materialization_build_session.spec.ts +0 -435
  169. package/src/service/materialization_build_session.ts +0 -681
  170. package/src/service/materialization_config_validation.spec.ts +0 -99
  171. package/src/service/materialization_config_validation.ts +0 -120
  172. package/src/service/materialization_eligibility.spec.ts +0 -158
  173. package/src/service/materialization_eligibility.ts +0 -305
  174. package/src/service/materialization_schedule_surface.spec.ts +0 -256
  175. package/src/service/materialization_scheduler.spec.ts +0 -289
  176. package/src/service/materialization_scheduler.ts +0 -304
  177. package/src/service/materialization_serve_transform.spec.ts +0 -1003
  178. package/src/service/materialization_serve_transform.ts +0 -779
  179. package/src/service/materialization_service.spec.ts +0 -2373
  180. package/src/service/materialization_service.ts +0 -2342
  181. package/src/service/materialization_test_fixtures.ts +0 -249
  182. package/src/service/model.spec.ts +0 -1157
  183. package/src/service/model.ts +0 -3370
  184. package/src/service/model_limits.spec.ts +0 -209
  185. package/src/service/model_limits.ts +0 -131
  186. package/src/service/model_storage_serve.spec.ts +0 -193
  187. package/src/service/model_storage_serve_joins.spec.ts +0 -193
  188. package/src/service/package.spec.ts +0 -612
  189. package/src/service/package.ts +0 -1799
  190. package/src/service/package_manifest.spec.ts +0 -230
  191. package/src/service/package_manifest.ts +0 -241
  192. package/src/service/package_memory_governor.spec.ts +0 -173
  193. package/src/service/package_memory_governor.ts +0 -233
  194. package/src/service/package_quote_bound_tables.spec.ts +0 -171
  195. package/src/service/package_race.spec.ts +0 -208
  196. package/src/service/package_reload_safety.spec.ts +0 -193
  197. package/src/service/package_rollback.spec.ts +0 -190
  198. package/src/service/package_worker_path.spec.ts +0 -645
  199. package/src/service/path_injection.spec.ts +0 -39
  200. package/src/service/persist_annotation_validation.spec.ts +0 -89
  201. package/src/service/persist_annotation_validation.ts +0 -59
  202. package/src/service/persistence_policy.spec.ts +0 -374
  203. package/src/service/proxy.spec.ts +0 -414
  204. package/src/service/proxy.ts +0 -248
  205. package/src/service/query_boundary.spec.ts +0 -470
  206. package/src/service/query_metadata.spec.ts +0 -408
  207. package/src/service/query_metadata.ts +0 -492
  208. package/src/service/query_metadata_identity.spec.ts +0 -149
  209. package/src/service/query_text.spec.ts +0 -79
  210. package/src/service/query_text.ts +0 -43
  211. package/src/service/quoting.spec.ts +0 -135
  212. package/src/service/quoting.ts +0 -78
  213. package/src/service/resolve_environment.ts +0 -15
  214. package/src/service/restricted_mode.spec.ts +0 -299
  215. package/src/service/source_extraction.ts +0 -225
  216. package/src/service/theme_store.ts +0 -199
  217. package/src/storage/DatabaseInterface.ts +0 -198
  218. package/src/storage/StorageManager.ts +0 -113
  219. package/src/storage/duckdb/ConnectionRepository.ts +0 -143
  220. package/src/storage/duckdb/DuckDBConnection.ts +0 -123
  221. package/src/storage/duckdb/DuckDBRepository.ts +0 -208
  222. package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
  223. package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
  224. package/src/storage/duckdb/MaterializationRepository.ts +0 -336
  225. package/src/storage/duckdb/PackageRepository.ts +0 -135
  226. package/src/storage/duckdb/schema.ts +0 -229
  227. package/src/stream_helpers.spec.ts +0 -280
  228. package/src/stream_helpers.ts +0 -162
  229. package/src/telemetry.ts +0 -20
  230. package/src/test_helpers/metrics_harness.ts +0 -166
  231. package/src/theme_key_parity.spec.ts +0 -57
  232. package/src/utils.ts +0 -29
  233. package/tests/fixtures/authorize-compile/model.malloy +0 -9
  234. package/tests/fixtures/authorize-compile/publisher.json +0 -4
  235. package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
  236. package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
  237. package/tests/fixtures/html-pages-test/data.csv +0 -3
  238. package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
  239. package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
  240. package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
  241. package/tests/fixtures/html-pages-test/public/data.json +0 -1
  242. package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
  243. package/tests/fixtures/html-pages-test/public/index.html +0 -9
  244. package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
  245. package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
  246. package/tests/fixtures/html-pages-test/public/slides.html +0 -12
  247. package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
  248. package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
  249. package/tests/fixtures/html-pages-test/publisher.json +0 -5
  250. package/tests/fixtures/html-pages-test/report.malloy +0 -1
  251. package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
  252. package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
  253. package/tests/fixtures/persist-multi-level/publisher.json +0 -5
  254. package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
  255. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
  256. package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
  257. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
  258. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
  259. package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
  260. package/tests/fixtures/persist-test/data/orders.csv +0 -5
  261. package/tests/fixtures/persist-test/persist_test.malloy +0 -11
  262. package/tests/fixtures/persist-test/publisher.json +0 -5
  263. package/tests/fixtures/publisher.config.json +0 -15
  264. package/tests/fixtures/query-givens/data/orders.csv +0 -7
  265. package/tests/fixtures/query-givens/model.malloy +0 -34
  266. package/tests/fixtures/query-givens/publisher.json +0 -5
  267. package/tests/fixtures/xlsx/database.xlsx +0 -0
  268. package/tests/harness/mcp_test_setup.ts +0 -332
  269. package/tests/harness/rest_e2e.ts +0 -68
  270. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
  271. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
  272. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
  273. package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
  274. package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
  275. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
  276. package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
  277. package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
  278. package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
  279. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
  280. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
  281. package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
  282. package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
  283. package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
  284. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
  285. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
  286. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
  287. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
  288. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
  289. package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
  290. package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
  291. package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
  292. package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
  293. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
  294. package/tests/unit/controller/theme.controller.spec.ts +0 -141
  295. package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
  296. package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
  297. package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
  298. package/tests/unit/duckdb/repositories.test.ts +0 -208
  299. package/tests/unit/ducklake/ducklake.test.ts +0 -952
  300. package/tests/unit/service/theme_store.spec.ts +0 -139
  301. 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
- }