@malloy-publisher/server 0.0.235 → 0.0.237

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