@malloy-publisher/server 0.0.382-dev → 0.2.1

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 (175) hide show
  1. package/README.docker.md +145 -20
  2. package/README.md +65 -12
  3. package/dist/app/api-doc.html +5 -0
  4. package/dist/app/api-doc.yaml +3929 -534
  5. package/dist/app/assets/EnvironmentPage-CHq5BF4l.js +1 -0
  6. package/dist/app/assets/HomePage-CIp-pJCa.js +1 -0
  7. package/dist/app/assets/LightMode-mHHPjEeA.js +1 -0
  8. package/dist/app/assets/MainPage-CylxO5Yu.js +2 -0
  9. package/dist/app/assets/MaterializationsPage-a0vjqxib.js +1 -0
  10. package/dist/app/assets/ModelPage-Cz-MrSZQ.js +1 -0
  11. package/dist/app/assets/PackagePage-Cdg5D7AN.js +1 -0
  12. package/dist/app/assets/RouteError-B5SIyn6G.js +1 -0
  13. package/dist/app/assets/ThemeEditorPage-B62rRc-y.js +1 -0
  14. package/dist/app/assets/WorkbookPage-45cG1Gx6.js +1 -0
  15. package/dist/app/assets/{core-CjeTkq8O.es-BqRc6yhC.js → core-DyU47reh.es-DY9b3WjS.js} +1 -1
  16. package/dist/app/assets/github-dark-DenFmJkN.es-DHJKELXO.js +1 -0
  17. package/dist/app/assets/index-5eLCcNmP.css +1 -0
  18. package/dist/app/assets/index-C5ClsnS7.js +1774 -0
  19. package/dist/app/assets/index-DF9tVqwN.js +1 -0
  20. package/dist/app/assets/index-DSoCUF-f.js +2725 -0
  21. package/dist/app/assets/index-DgVRz2wm.js +2 -0
  22. package/dist/app/assets/index-P-pF8nU5.js +632 -0
  23. package/dist/app/assets/index-w_0OQJgZ.js +23 -0
  24. package/dist/app/index.html +8 -4
  25. package/dist/cpufeatures-1yrn0vtw.node +0 -0
  26. package/dist/default-publisher.config.json +23 -0
  27. package/dist/{instrumentation.js → instrumentation.mjs} +10631 -10573
  28. package/dist/package_load_worker.mjs +15219 -0
  29. package/dist/runtime/publisher.js +331 -0
  30. package/dist/{server.js → server.mjs} +141276 -74705
  31. package/dist/sshcrypto-vd2k5hq9.node +0 -0
  32. package/package.json +41 -28
  33. package/publisher.config.example.bigquery.json +33 -0
  34. package/publisher.config.example.duckdb.json +23 -0
  35. package/.eslintrc.json +0 -44
  36. package/.prettierignore +0 -1
  37. package/.prettierrc +0 -4
  38. package/build.ts +0 -38
  39. package/dist/app/assets/HomePage-Dn3E4CuB.js +0 -1
  40. package/dist/app/assets/MainPage-BzB3yoqi.js +0 -2
  41. package/dist/app/assets/ModelPage-C9O_sAXT.js +0 -1
  42. package/dist/app/assets/PackagePage-DcxKEjBX.js +0 -1
  43. package/dist/app/assets/ProjectPage-BDj307rF.js +0 -1
  44. package/dist/app/assets/RouteError-DAShbVCG.js +0 -1
  45. package/dist/app/assets/WorkbookPage-Cs_XYEaB.js +0 -1
  46. package/dist/app/assets/index-15BOvhp0.js +0 -456
  47. package/dist/app/assets/index-Bb2jqquW.js +0 -676
  48. package/dist/app/assets/index-CMlGQMcl.css +0 -1
  49. package/dist/app/assets/index-D68X76-7.js +0 -1276
  50. package/dist/app/assets/index.umd-DGBekgSu.js +0 -1145
  51. package/dxt/malloy_bridge.py +0 -354
  52. package/dxt/manifest.json +0 -22
  53. package/eslint.config.mjs +0 -76
  54. package/k6-tests/README.md +0 -144
  55. package/k6-tests/bun.lock +0 -797
  56. package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
  57. package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
  58. package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
  59. package/k6-tests/load-test/load-test-crud.ts +0 -142
  60. package/k6-tests/load-test/load-test.ts +0 -784
  61. package/k6-tests/package.json +0 -27
  62. package/k6-tests/scripts/run-with-otel.sh +0 -50
  63. package/k6-tests/smoke-test/smoke-test.ts +0 -158
  64. package/k6-tests/tsconfig.json +0 -29
  65. package/k6-tests/utils/client_factory.ts +0 -128
  66. package/k6-tests/utils/common.ts +0 -489
  67. package/k6-tests/utils/logger.ts +0 -62
  68. package/k6-tests/utils/types.d.ts +0 -35
  69. package/malloy_mcp.dxt +0 -0
  70. package/publisher.config.json +0 -33
  71. package/src/config.spec.ts +0 -850
  72. package/src/config.ts +0 -305
  73. package/src/constants.ts +0 -10
  74. package/src/controller/compile.controller.ts +0 -35
  75. package/src/controller/connection.controller.ts +0 -515
  76. package/src/controller/database.controller.ts +0 -21
  77. package/src/controller/manifest.controller.ts +0 -29
  78. package/src/controller/materialization.controller.ts +0 -125
  79. package/src/controller/model.controller.ts +0 -107
  80. package/src/controller/package.controller.ts +0 -182
  81. package/src/controller/query.controller.ts +0 -60
  82. package/src/controller/watch-mode.controller.ts +0 -98
  83. package/src/data_styles.ts +0 -71
  84. package/src/dto/connection.dto.spec.ts +0 -131
  85. package/src/dto/connection.dto.ts +0 -174
  86. package/src/dto/index.ts +0 -2
  87. package/src/dto/package.dto.spec.ts +0 -42
  88. package/src/dto/package.dto.ts +0 -12
  89. package/src/dto/validate.spec.ts +0 -76
  90. package/src/dto/validate.ts +0 -31
  91. package/src/errors.ts +0 -116
  92. package/src/health.ts +0 -189
  93. package/src/instrumentation.ts +0 -148
  94. package/src/logger.ts +0 -154
  95. package/src/mcp/error_messages.ts +0 -199
  96. package/src/mcp/handler_utils.ts +0 -239
  97. package/src/mcp/mcp_constants.ts +0 -22
  98. package/src/mcp/prompts/handlers.ts +0 -84
  99. package/src/mcp/prompts/index.ts +0 -11
  100. package/src/mcp/prompts/prompt_definitions.ts +0 -160
  101. package/src/mcp/prompts/prompt_service.ts +0 -67
  102. package/src/mcp/prompts/utils.ts +0 -62
  103. package/src/mcp/resource_metadata.ts +0 -47
  104. package/src/mcp/resources/model_resource.ts +0 -153
  105. package/src/mcp/resources/notebook_resource.ts +0 -137
  106. package/src/mcp/resources/package_resource.ts +0 -370
  107. package/src/mcp/resources/project_resource.ts +0 -184
  108. package/src/mcp/resources/query_resource.ts +0 -117
  109. package/src/mcp/resources/source_resource.ts +0 -141
  110. package/src/mcp/resources/view_resource.ts +0 -136
  111. package/src/mcp/server.ts +0 -62
  112. package/src/mcp/tools/discovery_tools.ts +0 -258
  113. package/src/mcp/tools/execute_query_tool.ts +0 -265
  114. package/src/server.ts +0 -1195
  115. package/src/service/connection.spec.ts +0 -1331
  116. package/src/service/connection.ts +0 -1511
  117. package/src/service/connection_service.spec.ts +0 -641
  118. package/src/service/connection_service.ts +0 -181
  119. package/src/service/db_utils.spec.ts +0 -714
  120. package/src/service/db_utils.ts +0 -1227
  121. package/src/service/filter.spec.ts +0 -447
  122. package/src/service/filter.ts +0 -337
  123. package/src/service/filter_integration.spec.ts +0 -825
  124. package/src/service/gcs_s3_utils.ts +0 -427
  125. package/src/service/manifest_service.spec.ts +0 -201
  126. package/src/service/manifest_service.ts +0 -106
  127. package/src/service/materialization_service.spec.ts +0 -648
  128. package/src/service/materialization_service.ts +0 -929
  129. package/src/service/materialized_table_gc.spec.ts +0 -383
  130. package/src/service/materialized_table_gc.ts +0 -279
  131. package/src/service/model.spec.ts +0 -257
  132. package/src/service/model.ts +0 -1069
  133. package/src/service/package.spec.ts +0 -236
  134. package/src/service/package.ts +0 -463
  135. package/src/service/project.ts +0 -681
  136. package/src/service/project_compile.spec.ts +0 -197
  137. package/src/service/project_store.spec.ts +0 -859
  138. package/src/service/project_store.ts +0 -1531
  139. package/src/service/quoting.ts +0 -41
  140. package/src/service/resolve_project.ts +0 -13
  141. package/src/storage/BaseRepository.ts +0 -31
  142. package/src/storage/DatabaseInterface.ts +0 -180
  143. package/src/storage/StorageManager.mock.ts +0 -59
  144. package/src/storage/StorageManager.ts +0 -210
  145. package/src/storage/duckdb/ConnectionRepository.ts +0 -141
  146. package/src/storage/duckdb/DuckDBConnection.ts +0 -177
  147. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  148. package/src/storage/duckdb/DuckDBRepository.ts +0 -224
  149. package/src/storage/duckdb/ManifestRepository.ts +0 -119
  150. package/src/storage/duckdb/MaterializationRepository.ts +0 -249
  151. package/src/storage/duckdb/PackageRepository.ts +0 -135
  152. package/src/storage/duckdb/ProjectRepository.ts +0 -148
  153. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  154. package/src/storage/duckdb/schema.ts +0 -147
  155. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -146
  156. package/src/utils.ts +0 -13
  157. package/tests/fixtures/persist-test/data/orders.csv +0 -5
  158. package/tests/fixtures/persist-test/persist_test.malloy +0 -11
  159. package/tests/fixtures/persist-test/publisher.json +0 -5
  160. package/tests/fixtures/publisher.config.json +0 -15
  161. package/tests/harness/e2e.ts +0 -96
  162. package/tests/harness/mcp_test_setup.ts +0 -344
  163. package/tests/harness/mocks.ts +0 -37
  164. package/tests/harness/rest_e2e.ts +0 -68
  165. package/tests/harness/uris.ts +0 -31
  166. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -470
  167. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -434
  168. package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -663
  169. package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -122
  170. package/tests/integration/mcp/setup.spec.ts +0 -5
  171. package/tests/unit/duckdb/attached_databases.test.ts +0 -1132
  172. package/tests/unit/ducklake/ducklake.test.ts +0 -950
  173. package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
  174. package/tests/unit/mcp/prompt_happy.test.ts +0 -51
  175. package/tsconfig.json +0 -32
@@ -1,1511 +0,0 @@
1
- import { BigQueryConnection } from "@malloydata/db-bigquery";
2
- import { DuckDBConnection } from "@malloydata/db-duckdb";
3
- import { MySQLConnection } from "@malloydata/db-mysql";
4
- import { PostgresConnection } from "@malloydata/db-postgres";
5
- import { SnowflakeConnection } from "@malloydata/db-snowflake";
6
- import { TrinoConnection } from "@malloydata/db-trino";
7
- import { Connection, TableSourceDef } from "@malloydata/malloy";
8
- import { BaseConnection } from "@malloydata/malloy/connection";
9
- import { AxiosError } from "axios";
10
- import fs from "fs/promises";
11
- import path from "path";
12
- import { v4 as uuidv4 } from "uuid";
13
- import { components } from "../api";
14
- import { TEMP_DIR_PATH } from "../constants";
15
- import { logAxiosError, logger } from "../logger";
16
- import { CloudStorageCredentials } from "./gcs_s3_utils";
17
-
18
- type AttachedDatabase = components["schemas"]["AttachedDatabase"];
19
- type ApiConnection = components["schemas"]["Connection"];
20
- type ApiConnectionAttributes = components["schemas"]["ConnectionAttributes"];
21
- type ApiConnectionStatus = components["schemas"]["ConnectionStatus"];
22
-
23
- // Extends the public API connection with the internal connection objects
24
- // which contains passwords and connection strings.
25
- export type InternalConnection = ApiConnection & {
26
- postgresConnection?: components["schemas"]["PostgresConnection"];
27
- bigqueryConnection?: components["schemas"]["BigqueryConnection"];
28
- snowflakeConnection?: components["schemas"]["SnowflakeConnection"];
29
- trinoConnection?: components["schemas"]["TrinoConnection"];
30
- mysqlConnection?: components["schemas"]["MysqlConnection"];
31
- duckdbConnection?: components["schemas"]["DuckdbConnection"];
32
- ducklakeConnection?: components["schemas"]["DucklakeConnection"];
33
- };
34
-
35
- function validateAndBuildTrinoConfig(
36
- trinoConfig: components["schemas"]["TrinoConnection"],
37
- ) {
38
- if (!trinoConfig.server?.includes(trinoConfig.port?.toString() || "")) {
39
- trinoConfig.server = `${trinoConfig.server}:${trinoConfig.port}`;
40
- }
41
-
42
- // Build base config
43
- const baseConfig: {
44
- server: string;
45
- port?: number;
46
- catalog?: string;
47
- schema?: string;
48
- user?: string;
49
- password?: string;
50
- extraConfig?: Record<string, unknown>;
51
- } = {
52
- server: trinoConfig.server,
53
- port: trinoConfig.port,
54
- catalog: trinoConfig.catalog,
55
- schema: trinoConfig.schema,
56
- user: trinoConfig.user,
57
- };
58
-
59
- if (trinoConfig.peakaKey) {
60
- baseConfig.extraConfig = {
61
- extraCredential: {
62
- peakaKey: trinoConfig.peakaKey,
63
- },
64
- };
65
- delete baseConfig.password;
66
- delete baseConfig.catalog;
67
- delete baseConfig.schema;
68
- } else if (
69
- trinoConfig.server?.startsWith("https://") &&
70
- trinoConfig.password
71
- ) {
72
- // Only add password if no peakaKey and HTTPS connection
73
- baseConfig.password = trinoConfig.password;
74
- }
75
-
76
- if (trinoConfig.server?.startsWith("http://")) {
77
- delete baseConfig.password;
78
- return baseConfig;
79
- } else if (trinoConfig.server?.startsWith("https://")) {
80
- return baseConfig;
81
- } else {
82
- throw new Error(
83
- `Invalid Trino connection: expected "http://server:port" or "https://server:port".`,
84
- );
85
- }
86
- }
87
-
88
- // Shared utilities
89
- async function installAndLoadExtension(
90
- connection: DuckDBConnection,
91
- extensionName: string,
92
- fromCommunity = false,
93
- ): Promise<void> {
94
- try {
95
- const installCommand = fromCommunity
96
- ? `FORCE INSTALL '${extensionName}' FROM community;`
97
- : `INSTALL ${extensionName};`;
98
- await connection.runSQL(installCommand);
99
- logger.info(`${extensionName} extension installed`);
100
- } catch (error) {
101
- logger.info(
102
- `${extensionName} extension already installed or install skipped`,
103
- { error },
104
- );
105
- }
106
-
107
- await connection.runSQL(`LOAD ${extensionName};`);
108
- logger.info(`${extensionName} extension loaded`);
109
- }
110
-
111
- async function isDatabaseAttached(
112
- connection: DuckDBConnection,
113
- dbName: string,
114
- ): Promise<boolean> {
115
- try {
116
- const existingDatabases = await connection.runSQL("SHOW DATABASES");
117
- const rows = Array.isArray(existingDatabases)
118
- ? existingDatabases
119
- : existingDatabases.rows || [];
120
-
121
- logger.debug(`Existing databases:`, rows);
122
-
123
- return rows.some((row: Record<string, unknown>) =>
124
- Object.values(row).some(
125
- (value) => typeof value === "string" && value === dbName,
126
- ),
127
- );
128
- } catch (error) {
129
- logger.warn(`Failed to check existing databases:`, error);
130
- return false;
131
- }
132
- }
133
-
134
- function sanitizeSecretName(name: string): string {
135
- return `secret_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
136
- }
137
-
138
- function escapeSQL(value: string): string {
139
- return value.replace(/'/g, "''");
140
- }
141
-
142
- function handleAlreadyAttachedError(error: unknown, dbName: string): void {
143
- if (error instanceof Error && error.message.includes("already exists")) {
144
- logger.info(`Database ${dbName} is already attached, skipping`);
145
- } else {
146
- throw error;
147
- }
148
- }
149
-
150
- function normalizePrivateKey(privateKey: string): string {
151
- let privateKeyContent = privateKey.trim();
152
-
153
- if (!privateKeyContent.includes("\n")) {
154
- // Try encrypted key first, then unencrypted
155
- const keyPatterns = [
156
- {
157
- beginRegex: /-----BEGIN\s+ENCRYPTED\s+PRIVATE\s+KEY-----/i,
158
- endRegex: /-----END\s+ENCRYPTED\s+PRIVATE\s+KEY-----/i,
159
- beginMarker: "-----BEGIN ENCRYPTED PRIVATE KEY-----",
160
- endMarker: "-----END ENCRYPTED PRIVATE KEY-----",
161
- },
162
- {
163
- beginRegex: /-----BEGIN\s+PRIVATE\s+KEY-----/i,
164
- endRegex: /-----END\s+PRIVATE\s+KEY-----/i,
165
- beginMarker: "-----BEGIN PRIVATE KEY-----",
166
- endMarker: "-----END PRIVATE KEY-----",
167
- },
168
- ];
169
-
170
- for (const pattern of keyPatterns) {
171
- const beginMatch = privateKeyContent.match(pattern.beginRegex);
172
- const endMatch = privateKeyContent.match(pattern.endRegex);
173
-
174
- if (beginMatch && endMatch) {
175
- const beginPos = beginMatch.index! + beginMatch[0].length;
176
- const endPos = endMatch.index!;
177
- const keyData = privateKeyContent
178
- .substring(beginPos, endPos)
179
- .replace(/\s+/g, "");
180
-
181
- const lines: string[] = [];
182
- for (let i = 0; i < keyData.length; i += 64) {
183
- lines.push(keyData.slice(i, i + 64));
184
- }
185
- privateKeyContent = `${pattern.beginMarker}\n${lines.join("\n")}\n${pattern.endMarker}\n`;
186
- break;
187
- }
188
- }
189
- } else {
190
- if (!privateKeyContent.endsWith("\n")) {
191
- privateKeyContent += "\n";
192
- }
193
- }
194
-
195
- return privateKeyContent;
196
- }
197
-
198
- // Database-specific attachment handlers
199
- async function attachBigQuery(
200
- connection: DuckDBConnection,
201
- attachedDb: AttachedDatabase,
202
- ): Promise<void> {
203
- if (!attachedDb.bigqueryConnection) {
204
- throw new Error(
205
- `BigQuery connection configuration missing for: ${attachedDb.name}`,
206
- );
207
- }
208
-
209
- const config = attachedDb.bigqueryConnection;
210
- let projectId = config.defaultProjectId;
211
- let serviceAccountJson: string | undefined;
212
-
213
- // Parse and validate service account key
214
- if (config.serviceAccountKeyJson) {
215
- const keyData = JSON.parse(config.serviceAccountKeyJson as string);
216
-
217
- const requiredFields = [
218
- "type",
219
- "project_id",
220
- "private_key",
221
- "client_email",
222
- ];
223
- for (const field of requiredFields) {
224
- if (!keyData[field]) {
225
- throw new Error(
226
- `Invalid service account key: missing "${field}" field`,
227
- );
228
- }
229
- }
230
-
231
- if (keyData.type !== "service_account") {
232
- throw new Error('Invalid service account key: incorrect "type" field');
233
- }
234
-
235
- projectId = keyData.project_id || config.defaultProjectId;
236
- serviceAccountJson = config.serviceAccountKeyJson as string;
237
- logger.info(`Using service account: ${keyData.client_email}`);
238
- }
239
-
240
- if (!projectId || !serviceAccountJson) {
241
- throw new Error(
242
- `BigQuery project_id and service account key required for: ${attachedDb.name}`,
243
- );
244
- }
245
-
246
- await installAndLoadExtension(connection, "bigquery", true);
247
-
248
- const secretName = sanitizeSecretName(`bigquery_${attachedDb.name}`);
249
- const escapedJson = escapeSQL(serviceAccountJson);
250
-
251
- const createSecretCommand = `
252
- CREATE OR REPLACE SECRET ${secretName} (
253
- TYPE BIGQUERY,
254
- SCOPE 'bq://${projectId}',
255
- SERVICE_ACCOUNT_JSON '${escapedJson}'
256
- );
257
- `;
258
-
259
- await connection.runSQL(createSecretCommand);
260
- logger.info(
261
- `Created BigQuery secret: ${secretName} for project: ${projectId}`,
262
- );
263
-
264
- const attachCommand = `ATTACH 'project=${projectId}' AS ${attachedDb.name} (TYPE bigquery, READ_ONLY);`;
265
- await connection.runSQL(attachCommand);
266
- logger.info(`Successfully attached BigQuery database: ${attachedDb.name}`);
267
- }
268
-
269
- async function attachSnowflake(
270
- connection: DuckDBConnection,
271
- attachedDb: AttachedDatabase,
272
- ): Promise<void> {
273
- if (!attachedDb.snowflakeConnection) {
274
- throw new Error(
275
- `Snowflake connection configuration missing for: ${attachedDb.name}`,
276
- );
277
- }
278
-
279
- const config = attachedDb.snowflakeConnection;
280
-
281
- // Validate required fields
282
- const requiredFields = {
283
- account: config.account,
284
- username: config.username,
285
- password: config.password,
286
- };
287
- for (const [field, value] of Object.entries(requiredFields)) {
288
- if (!value) {
289
- throw new Error(
290
- `Snowflake ${field} is required for: ${attachedDb.name}`,
291
- );
292
- }
293
- }
294
-
295
- await installAndLoadExtension(connection, "snowflake", true);
296
-
297
- // Verify ADBC driver
298
- try {
299
- const version = await connection.runSQL("SELECT snowflake_version();");
300
- logger.info(`Snowflake ADBC driver verified with version:`, version.rows);
301
- } catch (error) {
302
- throw new Error(
303
- `Snowflake ADBC driver verification failed: ${error instanceof Error ? error.message : String(error)}`,
304
- );
305
- }
306
-
307
- // Build connection parameters
308
- const params = {
309
- account: escapeSQL(config.account || ""),
310
- user: escapeSQL(config.username || ""),
311
- password: escapeSQL(config.password || ""),
312
- database: config.database ? escapeSQL(config.database) : undefined,
313
- warehouse: config.warehouse ? escapeSQL(config.warehouse) : undefined,
314
- schema: config.schema ? escapeSQL(config.schema) : undefined,
315
- role: config.role ? escapeSQL(config.role) : undefined,
316
- };
317
-
318
- // Create attach string
319
- const attachParts = [
320
- `account=${params.account}`,
321
- `user=${params.user}`,
322
- `password=${params.password}`,
323
- ];
324
-
325
- if (params.database) attachParts.push(`database=${params.database}`);
326
- if (params.warehouse) attachParts.push(`warehouse=${params.warehouse}`);
327
-
328
- const secretString = `CREATE OR REPLACE SECRET ${attachedDb.name}_secret (
329
- TYPE snowflake,
330
- ACCOUNT '${params.account}',
331
- USER '${params.user}',
332
- PASSWORD '${params.password}',
333
- DATABASE '${params.database}',
334
- WAREHOUSE '${params.warehouse}'
335
- );`;
336
-
337
- await connection.runSQL(secretString);
338
-
339
- const testresult = await connection.runSQL(
340
- `SELECT * FROM snowflake_query('SELECT 1', '${attachedDb.name}_secret');`,
341
- );
342
- logger.info(`Testing Snowflake connection:`, testresult.rows);
343
-
344
- const attachCommand = `ATTACH '${attachedDb.name}' AS ${attachedDb.name} (TYPE snowflake, SECRET ${attachedDb.name}_secret, READ_ONLY);`;
345
- await connection.runSQL(attachCommand);
346
- logger.info(`Successfully attached Snowflake database: ${attachedDb.name}`);
347
- }
348
-
349
- function buildPgConnectionString(
350
- pg: components["schemas"]["PostgresConnection"],
351
- ): string {
352
- if (pg.connectionString) {
353
- return pg.connectionString;
354
- }
355
-
356
- const parts: string[] = [];
357
- if (pg.host) parts.push(`host=${pg.host}`);
358
- if (pg.port) parts.push(`port=${pg.port}`);
359
- if (pg.databaseName) parts.push(`dbname=${pg.databaseName}`);
360
- if (pg.userName) parts.push(`user=${pg.userName}`);
361
- if (pg.password) parts.push(`password=${pg.password}`);
362
-
363
- const pgSSLMode = process.env.PGSSLMODE;
364
-
365
- if (pgSSLMode) {
366
- const mapping: Record<string, string> = {
367
- "no-verify": "disable",
368
- disable: "disable",
369
- allow: "allow",
370
- prefer: "prefer",
371
- require: "require",
372
- "verify-ca": "verify-ca",
373
- "verify-full": "verify-full",
374
- };
375
-
376
- const sslmode = mapping[pgSSLMode.toLowerCase()];
377
- if (sslmode) parts.push(`sslmode=${sslmode}`);
378
- }
379
-
380
- return parts.join(" ");
381
- }
382
-
383
- async function attachPostgres(
384
- connection: DuckDBConnection,
385
- attachedDb: AttachedDatabase,
386
- ): Promise<void> {
387
- if (!attachedDb.postgresConnection) {
388
- throw new Error(
389
- `PostgreSQL connection configuration missing for: ${attachedDb.name}`,
390
- );
391
- }
392
-
393
- await installAndLoadExtension(connection, "postgres");
394
-
395
- const config = attachedDb.postgresConnection;
396
- const attachString: string = buildPgConnectionString(config);
397
-
398
- const attachCommand = `ATTACH '${escapeSQL(attachString)}' AS ${attachedDb.name} (TYPE postgres, READ_ONLY);`;
399
- await connection.runSQL(attachCommand);
400
- logger.info(`Successfully attached PostgreSQL database: ${attachedDb.name}`);
401
- }
402
-
403
- async function attachDuckLake(
404
- connection: DuckDBConnection,
405
- dbName: string,
406
- ducklakeConfig: components["schemas"]["DucklakeConnection"],
407
- ): Promise<void> {
408
- await installAndLoadExtension(connection, "ducklake");
409
- await installAndLoadExtension(connection, "postgres");
410
- await installAndLoadExtension(connection, "aws");
411
- await installAndLoadExtension(connection, "httpfs");
412
- if (!ducklakeConfig.catalog?.postgresConnection) {
413
- throw new Error(
414
- `PostgreSQL connection configuration is required for DuckLake catalog: ${dbName}`,
415
- );
416
- }
417
-
418
- if (!ducklakeConfig.storage?.bucketUrl) {
419
- throw new Error(`Storage bucketUrl is required for DuckLake: ${dbName}`);
420
- }
421
-
422
- // Set up cloud storage secret so DuckDB can access S3/GCS
423
- const hasS3 = !!ducklakeConfig.storage.s3Connection;
424
- const hasGCS = !!ducklakeConfig.storage.gcsConnection;
425
-
426
- if (hasS3) {
427
- await attachCloudStorage(connection, {
428
- name: `${dbName}_storage`,
429
- type: "s3",
430
- s3Connection: ducklakeConfig.storage.s3Connection,
431
- });
432
- } else if (hasGCS) {
433
- await attachCloudStorage(connection, {
434
- name: `${dbName}_storage`,
435
- type: "gcs",
436
- gcsConnection: ducklakeConfig.storage.gcsConnection,
437
- });
438
- }
439
-
440
- const pg = ducklakeConfig.catalog.postgresConnection;
441
- const pgConnString: string = buildPgConnectionString(pg);
442
- // Attach DuckLake with Postgres catalog and cloud storage data path in READ_ONLY mode
443
- // The client manages metadata - we only read from the catalogs
444
- logger.info(`pgConnString: ${pgConnString}`);
445
- const escapedPgConnString = escapeSQL(pgConnString);
446
- logger.info(`Final escaped connection string: ${escapedPgConnString}`);
447
- const escapedBucketUrl = escapeSQL(ducklakeConfig.storage.bucketUrl);
448
- logger.info(`escapedBucketUrl: ${escapedBucketUrl}`);
449
- const attachCommand = `ATTACH OR REPLACE 'ducklake:postgres:${escapedPgConnString}' AS ${dbName} (DATA_PATH '${escapedBucketUrl}', OVERRIDE_DATA_PATH true, READ_ONLY true);`;
450
- logger.info(`Attaching DuckLake database using command: ${attachCommand}`);
451
- try {
452
- await connection.runSQL(attachCommand);
453
- logger.info(
454
- `Successfully attached DuckLake database in READ_ONLY mode: ${dbName}`,
455
- );
456
- } catch (error) {
457
- // Handle case where DuckLake database is already attached
458
- if (
459
- error instanceof Error &&
460
- (error.message.includes("already exists") ||
461
- error.message.includes("already attached"))
462
- ) {
463
- logger.info(
464
- `DuckLake database ${dbName} is already attached, skipping`,
465
- );
466
- } else {
467
- throw error;
468
- }
469
- }
470
- }
471
-
472
- async function attachCloudStorage(
473
- connection: DuckDBConnection,
474
- attachedDb: AttachedDatabase,
475
- ): Promise<void> {
476
- const isGCS = attachedDb.type === "gcs";
477
- const isS3 = attachedDb.type === "s3";
478
-
479
- if (!isGCS && !isS3) {
480
- throw new Error(`Invalid cloud storage type: ${attachedDb.type}`);
481
- }
482
-
483
- const storageType = attachedDb.type?.toUpperCase() || "";
484
- let credentials: CloudStorageCredentials;
485
-
486
- if (isGCS) {
487
- if (!attachedDb.gcsConnection) {
488
- throw new Error(
489
- `GCS connection configuration missing for: ${attachedDb.name}`,
490
- );
491
- }
492
- if (!attachedDb.gcsConnection.keyId || !attachedDb.gcsConnection.secret) {
493
- throw new Error(
494
- `GCS keyId and secret are required for: ${attachedDb.name}`,
495
- );
496
- }
497
- credentials = {
498
- type: "gcs",
499
- accessKeyId: attachedDb.gcsConnection.keyId,
500
- secretAccessKey: attachedDb.gcsConnection.secret,
501
- };
502
- } else {
503
- if (!attachedDb.s3Connection) {
504
- throw new Error(
505
- `S3 connection configuration missing for: ${attachedDb.name}`,
506
- );
507
- }
508
- if (
509
- !attachedDb.s3Connection.accessKeyId ||
510
- !attachedDb.s3Connection.secretAccessKey
511
- ) {
512
- throw new Error(
513
- `S3 accessKeyId and secretAccessKey are required for: ${attachedDb.name}`,
514
- );
515
- }
516
- credentials = {
517
- type: "s3",
518
- accessKeyId: attachedDb.s3Connection.accessKeyId,
519
- secretAccessKey: attachedDb.s3Connection.secretAccessKey,
520
- region: attachedDb.s3Connection.region,
521
- endpoint: attachedDb.s3Connection.endpoint,
522
- sessionToken: attachedDb.s3Connection.sessionToken,
523
- };
524
- }
525
-
526
- await installAndLoadExtension(connection, "httpfs");
527
-
528
- const secretName = sanitizeSecretName(
529
- `${attachedDb.type}_${attachedDb.name}`,
530
- );
531
- const escapedKeyId = escapeSQL(credentials.accessKeyId);
532
- const escapedSecret = escapeSQL(credentials.secretAccessKey);
533
-
534
- let createSecretCommand: string;
535
-
536
- if (isGCS) {
537
- createSecretCommand = `
538
- CREATE OR REPLACE SECRET ${secretName} (
539
- TYPE gcs,
540
- KEY_ID '${escapedKeyId}',
541
- SECRET '${escapedSecret}'
542
- );
543
- `;
544
- } else {
545
- const region = credentials.region || "us-east-1";
546
-
547
- if (credentials.endpoint) {
548
- const escapedEndpoint = escapeSQL(credentials.endpoint);
549
- createSecretCommand = `
550
- CREATE OR REPLACE SECRET ${secretName} (
551
- TYPE s3,
552
- KEY_ID '${escapedKeyId}',
553
- SECRET '${escapedSecret}',
554
- REGION '${region}',
555
- ENDPOINT '${escapedEndpoint}',
556
- URL_STYLE 'path'
557
- );
558
- `;
559
- } else if (credentials.sessionToken) {
560
- const escapedToken = escapeSQL(credentials.sessionToken);
561
- createSecretCommand = `
562
- CREATE OR REPLACE SECRET ${secretName} (
563
- TYPE s3,
564
- KEY_ID '${escapedKeyId}',
565
- SECRET '${escapedSecret}',
566
- REGION '${region}',
567
- SESSION_TOKEN '${escapedToken}'
568
- );
569
- `;
570
- } else {
571
- createSecretCommand = `
572
- CREATE OR REPLACE SECRET ${secretName} (
573
- TYPE s3,
574
- KEY_ID '${escapedKeyId}',
575
- SECRET '${escapedSecret}',
576
- REGION '${region}'
577
- );
578
- `;
579
- }
580
- }
581
-
582
- if (await doesSecretExistInDuckDB(connection, secretName)) {
583
- // Force refresh attachments using this storage
584
- await connection.runSQL(`DETACH ${attachedDb.name};`).catch(() => {});
585
- }
586
- await connection.runSQL(createSecretCommand);
587
-
588
- logger.info(`Created ${storageType} secret: ${secretName}`);
589
- logger.info(`${storageType} connection configured for: ${attachedDb.name}`);
590
- }
591
-
592
- async function attachAzureStorage(
593
- connection: DuckDBConnection,
594
- attachedDb: AttachedDatabase,
595
- ): Promise<void> {
596
- if (!attachedDb.azureConnection) {
597
- throw new Error(
598
- `Azure connection configuration missing for: ${attachedDb.name}`,
599
- );
600
- }
601
-
602
- const config = attachedDb.azureConnection;
603
-
604
- // Extensions are loaded once in attachDatabasesToDuckDB before the loop
605
- const secretName = sanitizeSecretName(`azure_${attachedDb.name}`);
606
-
607
- let createSecretCommand: string;
608
-
609
- if (config.authType === "service_principal") {
610
- if (
611
- !config.tenantId ||
612
- !config.clientId ||
613
- !config.clientSecret ||
614
- !config.accountName
615
- ) {
616
- throw new Error(
617
- `Azure SPN auth requires tenantId, clientId, clientSecret, and accountName for: ${attachedDb.name}`,
618
- );
619
- }
620
-
621
- const escapedTenantId = escapeSQL(config.tenantId);
622
- const escapedClientId = escapeSQL(config.clientId);
623
- const escapedClientSecret = escapeSQL(config.clientSecret);
624
- const escapedAccountName = escapeSQL(config.accountName);
625
-
626
- createSecretCommand = `
627
- CREATE OR REPLACE SECRET ${secretName} (
628
- TYPE azure,
629
- PROVIDER service_principal,
630
- TENANT_ID '${escapedTenantId}',
631
- CLIENT_ID '${escapedClientId}',
632
- CLIENT_SECRET '${escapedClientSecret}',
633
- ACCOUNT_NAME '${escapedAccountName}'
634
- );
635
- `;
636
- } else if (config.authType === "sas_token") {
637
- if (!config.sasUrl) {
638
- throw new Error(
639
- `Azure SAS token auth requires sasUrl for: ${attachedDb.name}`,
640
- );
641
- }
642
-
643
- // For SAS token auth, DuckDB can read the HTTPS SAS URL directly via httpfs.
644
- // No Azure secret is needed — just ensure httpfs is loaded.
645
- logger.info(
646
- `Azure SAS token configured for: ${attachedDb.name} (no secret needed, using direct URL)`,
647
- );
648
- return;
649
- } else {
650
- throw new Error(
651
- `Unsupported Azure auth type: ${config.authType} for: ${attachedDb.name}`,
652
- );
653
- }
654
-
655
- if (await doesSecretExistInDuckDB(connection, secretName)) {
656
- await connection.runSQL(`DETACH ${attachedDb.name};`).catch(() => {});
657
- }
658
- await connection.runSQL(createSecretCommand);
659
-
660
- logger.info(`Created Azure secret: ${secretName}`);
661
- logger.info(`Azure ADLS connection configured for: ${attachedDb.name}`);
662
- }
663
-
664
- async function doesSecretExistInDuckDB(
665
- connection: DuckDBConnection,
666
- secretName: string,
667
- ): Promise<boolean> {
668
- const escapedSecretName = escapeSQL(secretName);
669
- const result = await connection.runSQL(`
670
- SELECT COUNT(*) AS count
671
- FROM duckdb_secrets()
672
- WHERE name = '${escapedSecretName}';
673
- `);
674
- const rows = result.rows;
675
- return Number(rows?.[0]?.count ?? 0) > 0;
676
- }
677
-
678
- // Main attachment function
679
- async function attachDatabasesToDuckDB(
680
- duckdbConnection: DuckDBConnection,
681
- attachedDatabases: AttachedDatabase[],
682
- ): Promise<void> {
683
- const attachHandlers = {
684
- bigquery: attachBigQuery,
685
- snowflake: attachSnowflake,
686
- postgres: attachPostgres,
687
- gcs: attachCloudStorage,
688
- s3: attachCloudStorage,
689
- azure: attachAzureStorage,
690
- };
691
-
692
- // Pre-load extensions needed by any attached database type, once per connection
693
- const hasAzure = attachedDatabases.some((db) => db.type === "azure");
694
- if (hasAzure) {
695
- await installAndLoadExtension(duckdbConnection, "azure");
696
- await installAndLoadExtension(duckdbConnection, "httpfs");
697
- }
698
-
699
- for (const attachedDb of attachedDatabases) {
700
- try {
701
- // Check if already attached
702
- if (
703
- await isDatabaseAttached(duckdbConnection, attachedDb.name || "")
704
- ) {
705
- logger.info(
706
- `Database ${attachedDb.name} is already attached, skipping`,
707
- );
708
- continue;
709
- }
710
-
711
- // Get the appropriate handler
712
- const handler =
713
- attachHandlers[attachedDb.type as keyof typeof attachHandlers];
714
- if (!handler) {
715
- throw new Error(`Unsupported database type: ${attachedDb.type}`);
716
- }
717
-
718
- // Execute attachment
719
- try {
720
- await handler(duckdbConnection, attachedDb);
721
- } catch (attachError) {
722
- handleAlreadyAttachedError(attachError, attachedDb.name || "");
723
- }
724
- } catch (error) {
725
- logger.error(`Failed to attach database ${attachedDb.name}:`, error);
726
- throw new Error(
727
- `Failed to attach database ${attachedDb.name}: ${(error as Error).message}`,
728
- );
729
- }
730
- }
731
- }
732
-
733
- type ApiAzureConnection = components["schemas"]["AzureConnection"];
734
-
735
- /**
736
- * Builds the actual Azure URL for a blob given an AzureConnection config.
737
- * Strips any glob pattern from the base URL and appends the blob name.
738
- */
739
- function buildAzureFileUrl(
740
- azureConn: ApiAzureConnection,
741
- blobName: string,
742
- ): string {
743
- if (azureConn.authType === "sas_token" && azureConn.sasUrl) {
744
- const qIdx = azureConn.sasUrl.indexOf("?");
745
- const baseUrl =
746
- qIdx >= 0 ? azureConn.sasUrl.substring(0, qIdx) : azureConn.sasUrl;
747
- const token = qIdx >= 0 ? azureConn.sasUrl.substring(qIdx) : "";
748
- // Single file URL — replace the filename portion so we don't double-append
749
- if (/\.(parquet|csv|json|jsonl|ndjson)$/i.test(baseUrl)) {
750
- const dir = baseUrl.replace(/\/[^/]+$/, "");
751
- return `${dir}/${blobName}${token}`;
752
- }
753
- // Glob or directory — strip trailing glob/slash and append blobName
754
- const cleanBase = baseUrl.replace(/\/\*[^/]*$/, "").replace(/\/+$/, "");
755
- return `${cleanBase}/${blobName}${token}`;
756
- } else if (azureConn.authType === "service_principal" && azureConn.fileUrl) {
757
- const url = azureConn.fileUrl;
758
- // Single file URL (ends with a data file extension) — replace the
759
- // filename with blobName so we don't double-append
760
- if (/\.(parquet|csv|json|jsonl|ndjson)$/i.test(url)) {
761
- return url.replace(/\/[^/]+$/, `/${blobName}`);
762
- }
763
- // Glob or directory — strip glob pattern and trailing slash, append blobName
764
- const base = url.replace(/\*[^/]*$/, "").replace(/\/+$/, "");
765
- return `${base}/${blobName}`;
766
- }
767
- throw new Error(
768
- `Cannot build Azure file URL: missing sasUrl or fileUrl in config`,
769
- );
770
- }
771
-
772
- /**
773
- * Extends DuckDBConnection to resolve Azure attached-database table paths.
774
- * When Malloy compiles di.table('azure_schema.blob_name'), the path
775
- * 'azure_schema.blob_name' is passed to fetchTableSchema. This override
776
- * detects that prefix, constructs the real Azure URL, and forwards it to
777
- * DuckDB so the azure/httpfs extension can read the file.
778
- */
779
- class AzureDuckDBConnection extends DuckDBConnection {
780
- private azureDatabases: AttachedDatabase[];
781
-
782
- constructor(
783
- connectionName: string,
784
- databasePath: string,
785
- workingDirectory: string,
786
- azureDatabases: AttachedDatabase[],
787
- ) {
788
- super(connectionName, databasePath, workingDirectory);
789
- this.azureDatabases = azureDatabases;
790
- }
791
-
792
- async fetchTableSchema(
793
- tableKey: string,
794
- tablePath: string,
795
- ): Promise<TableSourceDef> {
796
- const dotIdx = tablePath.indexOf(".");
797
- if (dotIdx > 0) {
798
- const schemaName = tablePath.substring(0, dotIdx);
799
- const blobName = tablePath.substring(dotIdx + 1);
800
-
801
- const azureDb = this.azureDatabases.find(
802
- (db) =>
803
- db.type === "azure" &&
804
- db.name === schemaName &&
805
- db.azureConnection,
806
- );
807
-
808
- if (azureDb) {
809
- const azureUrl = buildAzureFileUrl(
810
- azureDb.azureConnection!,
811
- blobName,
812
- );
813
- logger.debug("Resolved Azure table path", {
814
- original: tablePath,
815
- resolved: azureUrl,
816
- });
817
- const result = await super.fetchTableSchema(tableKey, azureUrl);
818
- if (!result) {
819
- throw new Error(`Azure file not found: ${azureUrl}`);
820
- }
821
- return result;
822
- }
823
- }
824
-
825
- const result = await super.fetchTableSchema(tableKey, tablePath);
826
- if (!result) {
827
- throw new Error(`Table ${tablePath} not found`);
828
- }
829
- return result;
830
- }
831
- }
832
-
833
- class DuckLakeConnection extends DuckDBConnection {
834
- private connectionName: string;
835
-
836
- constructor(
837
- connectionName: string,
838
- databasePath: string,
839
- workingDirectory: string,
840
- ) {
841
- super(connectionName, databasePath, workingDirectory);
842
-
843
- // Validate that this is a DuckLake connection by checking the database path pattern
844
- if (!databasePath.endsWith("_ducklake.duckdb")) {
845
- throw new Error(
846
- `DuckLakeConnection should only be used for DuckLake connections. ` +
847
- `Expected database path ending with '_ducklake.duckdb', got: ${databasePath}`,
848
- );
849
- }
850
-
851
- this.connectionName = connectionName;
852
- }
853
-
854
- async fetchTableSchema(
855
- tableKey: string,
856
- tablePath: string,
857
- ): Promise<TableSourceDef> {
858
- // DuckLake-specific logic: prefix table path with connection name if needed
859
- const parts = tablePath.split(".");
860
- if (
861
- !tablePath.startsWith(this.connectionName) &&
862
- (parts.length === 1 || parts.length === 2)
863
- ) {
864
- const prefixedPath = `${this.connectionName}.${tablePath}`;
865
- logger.debug("Prefixing DuckLake table path", {
866
- original: tablePath,
867
- prefixed: prefixedPath,
868
- connectionName: this.connectionName,
869
- });
870
- const result = await super.fetchTableSchema(tableKey, prefixedPath);
871
- if (!result) {
872
- throw new Error(
873
- `Table ${prefixedPath} not found in connection ${this.connectionName}`,
874
- );
875
- }
876
- return result;
877
- }
878
- // If already prefixed or has 3+ parts, use as-is;
879
- // For attached databases, in the future
880
- const result = await super.fetchTableSchema(tableKey, tablePath);
881
- if (!result) {
882
- throw new Error(
883
- `Table ${tablePath} not found in connection ${this.connectionName}`,
884
- );
885
- }
886
- return result;
887
- }
888
- }
889
-
890
- export async function deleteDuckLakeConnectionFile(
891
- connectionName: string,
892
- projectPath: string,
893
- ): Promise<void> {
894
- const ducklakePath = path.join(
895
- projectPath,
896
- `${connectionName}_ducklake.duckdb`,
897
- );
898
- try {
899
- await fs.access(ducklakePath);
900
- await fs.rm(ducklakePath);
901
- logger.info(
902
- `Removed DuckLake connection file ${connectionName}_ducklake.duckdb from ${projectPath}`,
903
- );
904
- } catch (error) {
905
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
906
- logger.debug(
907
- `DuckLake connection file ${connectionName}_ducklake.duckdb does not exist, skipping deletion`,
908
- );
909
- } else {
910
- logger.error(
911
- `Failed to remove DuckLake connection file ${connectionName}_ducklake.duckdb from ${projectPath}`,
912
- { error },
913
- );
914
- }
915
- }
916
- }
917
-
918
- export async function createProjectConnections(
919
- connections: ApiConnection[] = [],
920
- projectPath: string = "",
921
- isUpdateConnectionRequest: boolean = false,
922
- ): Promise<{
923
- malloyConnections: Map<string, BaseConnection>;
924
- apiConnections: InternalConnection[];
925
- }> {
926
- const connectionMap = new Map<string, BaseConnection>();
927
- const processedConnections = new Set<string>();
928
- const apiConnections: InternalConnection[] = [];
929
-
930
- for (const connection of connections) {
931
- if (connection.name && processedConnections.has(connection.name)) {
932
- continue;
933
- }
934
-
935
- logger.info(`Adding connection ${connection.name}`, {
936
- connection,
937
- });
938
-
939
- if (!connection.name) {
940
- throw "Invalid connection configuration. No name.";
941
- }
942
-
943
- processedConnections.add(connection.name);
944
-
945
- switch (connection.type) {
946
- case "postgres": {
947
- const configReader = async () => {
948
- if (!connection.postgresConnection) {
949
- throw "Invalid connection configuration. No postgres connection.";
950
- }
951
- return {
952
- host: connection.postgresConnection.host,
953
- port: connection.postgresConnection.port,
954
- username: connection.postgresConnection.userName,
955
- password: connection.postgresConnection.password,
956
- databaseName: connection.postgresConnection.databaseName,
957
- connectionString:
958
- connection.postgresConnection.connectionString,
959
- };
960
- };
961
- const postgresConnection = new PostgresConnection(
962
- connection.name,
963
- () => ({}),
964
- configReader,
965
- );
966
- connectionMap.set(connection.name, postgresConnection);
967
- connection.attributes = getConnectionAttributes(postgresConnection);
968
- break;
969
- }
970
-
971
- case "mysql": {
972
- if (!connection.mysqlConnection) {
973
- throw "Invalid connection configuration. No mysql connection.";
974
- }
975
- const config = {
976
- host: connection.mysqlConnection.host,
977
- port: connection.mysqlConnection.port,
978
- user: connection.mysqlConnection.user,
979
- password: connection.mysqlConnection.password,
980
- database: connection.mysqlConnection.database,
981
- };
982
- const mysqlConnection = new MySQLConnection(
983
- connection.name,
984
- config,
985
- );
986
- connectionMap.set(connection.name, mysqlConnection);
987
- connection.attributes = getConnectionAttributes(mysqlConnection);
988
- break;
989
- }
990
-
991
- case "bigquery": {
992
- if (!connection.bigqueryConnection) {
993
- throw "Invalid connection configuration. No bigquery connection.";
994
- }
995
-
996
- // If a service account key file is provided, we persist it to disk
997
- // and pass the path to the BigQueryConnection.
998
- let serviceAccountKeyPath = undefined;
999
- if (connection.bigqueryConnection.serviceAccountKeyJson) {
1000
- serviceAccountKeyPath = path.join(
1001
- TEMP_DIR_PATH,
1002
- `${connection.name}-${uuidv4()}-service-account-key.json`,
1003
- );
1004
- await fs.writeFile(
1005
- serviceAccountKeyPath,
1006
- connection.bigqueryConnection.serviceAccountKeyJson as string,
1007
- );
1008
- }
1009
-
1010
- const bigqueryConnectionOptions = {
1011
- projectId: connection.bigqueryConnection.defaultProjectId,
1012
- serviceAccountKeyPath: serviceAccountKeyPath,
1013
- location: connection.bigqueryConnection.location,
1014
- maximumBytesBilled:
1015
- connection.bigqueryConnection.maximumBytesBilled,
1016
- timeoutMs:
1017
- connection.bigqueryConnection.queryTimeoutMilliseconds,
1018
- billingProjectId: connection.bigqueryConnection.billingProjectId,
1019
- };
1020
- const bigqueryConnection = new BigQueryConnection(
1021
- connection.name,
1022
- () => ({}),
1023
- bigqueryConnectionOptions,
1024
- );
1025
- connectionMap.set(connection.name, bigqueryConnection);
1026
- connection.attributes = getConnectionAttributes(bigqueryConnection);
1027
- break;
1028
- }
1029
-
1030
- case "snowflake": {
1031
- if (!connection.snowflakeConnection) {
1032
- throw new Error(
1033
- "Snowflake connection configuration is missing.",
1034
- );
1035
- }
1036
- if (!connection.snowflakeConnection.account) {
1037
- throw new Error("Snowflake account is required.");
1038
- }
1039
-
1040
- if (!connection.snowflakeConnection.username) {
1041
- throw new Error("Snowflake username is required.");
1042
- }
1043
-
1044
- if (
1045
- !connection.snowflakeConnection.password &&
1046
- !connection.snowflakeConnection.privateKey
1047
- ) {
1048
- throw new Error(
1049
- "Snowflake password or private key or private key path is required.",
1050
- );
1051
- }
1052
-
1053
- if (!connection.snowflakeConnection.warehouse) {
1054
- throw new Error("Snowflake warehouse is required.");
1055
- }
1056
-
1057
- let privateKeyPath = undefined;
1058
-
1059
- if (connection.snowflakeConnection.privateKey) {
1060
- privateKeyPath = path.join(
1061
- TEMP_DIR_PATH,
1062
- `${connection.name}-${uuidv4()}-private-key.pem`,
1063
- );
1064
- const normalizedKey = normalizePrivateKey(
1065
- connection.snowflakeConnection.privateKey as string,
1066
- );
1067
- await fs.writeFile(privateKeyPath, normalizedKey);
1068
- }
1069
-
1070
- const snowflakeConnectionOptions = {
1071
- connOptions: {
1072
- account: connection.snowflakeConnection.account,
1073
- username: connection.snowflakeConnection.username,
1074
- warehouse: connection.snowflakeConnection.warehouse,
1075
- database: connection.snowflakeConnection.database,
1076
- schema: connection.snowflakeConnection.schema,
1077
- role: connection.snowflakeConnection.role,
1078
- ...(connection.snowflakeConnection.privateKey
1079
- ? {
1080
- privateKeyPath: privateKeyPath,
1081
- authenticator: "SNOWFLAKE_JWT",
1082
- privateKeyPass:
1083
- connection.snowflakeConnection.privateKeyPass ||
1084
- undefined,
1085
- }
1086
- : {
1087
- password:
1088
- connection.snowflakeConnection.password ||
1089
- undefined,
1090
- }),
1091
- timeout:
1092
- connection.snowflakeConnection.responseTimeoutMilliseconds,
1093
- },
1094
- poolOptions: {
1095
- min: 1,
1096
- max: 20,
1097
- },
1098
- };
1099
- const snowflakeConnection = new SnowflakeConnection(
1100
- connection.name,
1101
- snowflakeConnectionOptions,
1102
- );
1103
- connectionMap.set(connection.name, snowflakeConnection);
1104
- connection.attributes =
1105
- getConnectionAttributes(snowflakeConnection);
1106
- break;
1107
- }
1108
-
1109
- case "trino": {
1110
- if (!connection.trinoConnection) {
1111
- throw new Error("Trino connection configuration is missing.");
1112
- }
1113
-
1114
- const trinoConnectionOptions = validateAndBuildTrinoConfig(
1115
- connection.trinoConnection,
1116
- );
1117
- const trinoConnection = new TrinoConnection(
1118
- connection.name,
1119
- {},
1120
- trinoConnectionOptions,
1121
- );
1122
- connectionMap.set(connection.name, trinoConnection);
1123
- connection.attributes = getConnectionAttributes(trinoConnection);
1124
- break;
1125
- }
1126
-
1127
- case "duckdb": {
1128
- if (!connection.duckdbConnection) {
1129
- throw new Error("DuckDB connection configuration is missing.");
1130
- }
1131
-
1132
- if (
1133
- connection.duckdbConnection.attachedDatabases?.some(
1134
- (database) => database.name === connection.name,
1135
- )
1136
- ) {
1137
- throw new Error(
1138
- `DuckDB attached databases names cannot conflict with connection name ${connection.name}`,
1139
- );
1140
- }
1141
-
1142
- if (connection.name === "duckdb") {
1143
- throw new Error("DuckDB connection name cannot be 'duckdb'");
1144
- }
1145
-
1146
- if (connection.duckdbConnection?.attachedDatabases?.length == 0) {
1147
- throw new Error(
1148
- "DuckDB connection must have at least one attached database",
1149
- );
1150
- }
1151
-
1152
- // Create DuckDB connection with project basePath as working directory
1153
- // This ensures relative paths in the project are resolved correctly
1154
- // Use unique memory database path to prevent sharing across connections
1155
- const attachedDatabases =
1156
- connection.duckdbConnection.attachedDatabases ?? [];
1157
- const hasAzureAttached = attachedDatabases.some(
1158
- (db) => db.type === "azure",
1159
- );
1160
- const duckdbConnection = hasAzureAttached
1161
- ? new AzureDuckDBConnection(
1162
- connection.name,
1163
- path.join(projectPath, `${connection.name}.duckdb`),
1164
- projectPath,
1165
- attachedDatabases,
1166
- )
1167
- : new DuckDBConnection(
1168
- connection.name,
1169
- path.join(projectPath, `${connection.name}.duckdb`),
1170
- projectPath,
1171
- );
1172
-
1173
- // Attach databases if configured
1174
- if (attachedDatabases.length > 0) {
1175
- await attachDatabasesToDuckDB(
1176
- duckdbConnection,
1177
- attachedDatabases,
1178
- );
1179
- }
1180
-
1181
- connectionMap.set(connection.name, duckdbConnection);
1182
- connection.attributes = getConnectionAttributes(duckdbConnection);
1183
- break;
1184
- }
1185
-
1186
- case "motherduck": {
1187
- if (!connection.motherduckConnection) {
1188
- throw new Error(
1189
- "MotherDuck connection configuration is missing.",
1190
- );
1191
- }
1192
-
1193
- if (!connection.motherduckConnection.accessToken) {
1194
- throw new Error("MotherDuck access token is required.");
1195
- }
1196
-
1197
- let databasePath = `md:`;
1198
- // Build the MotherDuck database path
1199
- if (connection.motherduckConnection.database) {
1200
- databasePath = `md:${connection.motherduckConnection.database}?attach_mode=single`;
1201
- }
1202
-
1203
- // Create MotherDuck connection using DuckDBConnectionOptions interface
1204
- const motherduckConnection = new DuckDBConnection({
1205
- name: connection.name,
1206
- databasePath: databasePath,
1207
- motherDuckToken: connection.motherduckConnection.accessToken,
1208
- workingDirectory: projectPath,
1209
- });
1210
-
1211
- connectionMap.set(connection.name, motherduckConnection);
1212
- connection.attributes =
1213
- getConnectionAttributes(motherduckConnection);
1214
- break;
1215
- }
1216
-
1217
- case "ducklake": {
1218
- if (!connection.ducklakeConnection) {
1219
- throw new Error("DuckLake connection configuration is missing.");
1220
- }
1221
-
1222
- // Creating one Connection per DuckLake connection to avoid conflicts with other connections and better isolation.
1223
- const ducklakeDuckdbConnection = new DuckLakeConnection(
1224
- connection.name,
1225
- path.join(projectPath, `${connection.name}_ducklake.duckdb`),
1226
- projectPath,
1227
- );
1228
-
1229
- // Only attach DuckLake if it's not already attached or is it an update connection request
1230
- if (
1231
- isUpdateConnectionRequest ||
1232
- !(await isDatabaseAttached(
1233
- ducklakeDuckdbConnection,
1234
- connection.name,
1235
- ))
1236
- ) {
1237
- await attachDuckLake(
1238
- ducklakeDuckdbConnection,
1239
- connection.name,
1240
- connection.ducklakeConnection,
1241
- );
1242
- }
1243
-
1244
- connectionMap.set(connection.name, ducklakeDuckdbConnection);
1245
- connection.attributes = getConnectionAttributes(
1246
- ducklakeDuckdbConnection,
1247
- );
1248
- break;
1249
- }
1250
-
1251
- default: {
1252
- throw new Error(`Unsupported connection type: ${connection.type}`);
1253
- }
1254
- }
1255
-
1256
- // Add the connection to apiConnections (this will be sanitized when returned)
1257
- apiConnections.push(connection);
1258
- }
1259
-
1260
- return {
1261
- malloyConnections: connectionMap,
1262
- apiConnections: apiConnections,
1263
- };
1264
- }
1265
-
1266
- function getConnectionAttributes(
1267
- connection: Connection,
1268
- ): ApiConnectionAttributes {
1269
- let canStream = false;
1270
- try {
1271
- canStream = connection.canStream();
1272
- } catch {
1273
- // pass
1274
- }
1275
- return {
1276
- dialectName: connection.dialectName,
1277
- isPool: connection.isPool(),
1278
- canPersist: connection.canPersist(),
1279
- canStream: canStream,
1280
- };
1281
- }
1282
-
1283
- // TODO: Re-write these tests to validate based on credentials provided in the connection config
1284
- async function testDuckDBConnection(
1285
- duckdbConnection: DuckDBConnection,
1286
- connectionConfig: InternalConnection,
1287
- ): Promise<void> {
1288
- // Test base DuckDB connection with a simple query
1289
- try {
1290
- await duckdbConnection.runSQL("SELECT 1 AS test");
1291
- logger.info(
1292
- `DuckDB base connection test passed for: ${connectionConfig.name}`,
1293
- );
1294
- } catch (error) {
1295
- throw new Error(
1296
- `DuckDB base connection test failed: ${(error as Error).message}`,
1297
- );
1298
- }
1299
-
1300
- // Test each attached database if configured
1301
- const attachedDatabases =
1302
- connectionConfig.duckdbConnection?.attachedDatabases;
1303
- if (!attachedDatabases || attachedDatabases.length === 0) {
1304
- return;
1305
- }
1306
-
1307
- const failedAttachments: string[] = [];
1308
-
1309
- for (const attachedDb of attachedDatabases) {
1310
- if (!attachedDb.name) {
1311
- continue;
1312
- }
1313
-
1314
- try {
1315
- // Test the attached database by querying its tables/schemas
1316
- // Different database types require different test queries
1317
- switch (attachedDb.type) {
1318
- case "postgres": {
1319
- // Test postgres attachment by listing schemas
1320
- await duckdbConnection.runSQL(
1321
- `SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${attachedDb.name}' LIMIT 1`,
1322
- );
1323
- logger.info(
1324
- `Attached Postgres database test passed: ${attachedDb.name}`,
1325
- );
1326
- break;
1327
- }
1328
- case "bigquery": {
1329
- // Test BigQuery attachment by listing datasets
1330
- // BigQuery attached databases show as catalogs
1331
- await duckdbConnection.runSQL(
1332
- `SELECT database_name FROM duckdb_databases() WHERE database_name = '${attachedDb.name}'`,
1333
- );
1334
- logger.info(
1335
- `Attached BigQuery database test passed: ${attachedDb.name}`,
1336
- );
1337
- break;
1338
- }
1339
- case "snowflake": {
1340
- // Test Snowflake attachment by verifying database is attached
1341
- await duckdbConnection.runSQL(
1342
- `SELECT database_name FROM duckdb_databases() WHERE database_name = '${attachedDb.name}'`,
1343
- );
1344
- logger.info(
1345
- `Attached Snowflake database test passed: ${attachedDb.name}`,
1346
- );
1347
- break;
1348
- }
1349
- case "gcs": {
1350
- await duckdbConnection.runSQL(
1351
- `SELECT name FROM duckdb_secrets() WHERE name LIKE '%${attachedDb.name}%' LIMIT 1`,
1352
- );
1353
- logger.info(
1354
- `Cloud storage credentials test passed: ${attachedDb.name}`,
1355
- );
1356
- break;
1357
- }
1358
- case "s3": {
1359
- await duckdbConnection.runSQL(
1360
- `SELECT name FROM duckdb_secrets() WHERE name LIKE '%${attachedDb.name}%' LIMIT 1`,
1361
- );
1362
- logger.info(
1363
- `Cloud storage credentials test passed: ${attachedDb.name}`,
1364
- );
1365
- break;
1366
- }
1367
- case "azure": {
1368
- const azureConfig = attachedDb.azureConnection;
1369
- if (azureConfig?.authType === "sas_token") {
1370
- // SAS token is embedded in the URL — no DuckDB secret is created.
1371
- if (!azureConfig.sasUrl) {
1372
- throw new Error(
1373
- `Azure SAS token URL is missing for: ${attachedDb.name}`,
1374
- );
1375
- }
1376
- logger.info(
1377
- `Azure SAS token URL present for: ${attachedDb.name}`,
1378
- );
1379
- } else {
1380
- await duckdbConnection.runSQL(
1381
- `SELECT name FROM duckdb_secrets() WHERE name LIKE '%${attachedDb.name}%' LIMIT 1`,
1382
- );
1383
- logger.info(
1384
- `Azure SPN credentials test passed: ${attachedDb.name}`,
1385
- );
1386
- }
1387
- break;
1388
- }
1389
- default: {
1390
- logger.warn(
1391
- `Unknown attached database type: ${attachedDb.type}`,
1392
- );
1393
- }
1394
- }
1395
- } catch (error) {
1396
- const errorMessage = `Attached database '${attachedDb.name}' (${attachedDb.type}) test failed: ${(error as Error).message}`;
1397
- logger.error(errorMessage);
1398
- failedAttachments.push(errorMessage);
1399
- }
1400
- }
1401
-
1402
- if (failedAttachments.length > 0) {
1403
- throw new Error(
1404
- `DuckDB connection test failed for attached databases:\n${failedAttachments.join("\n")}`,
1405
- );
1406
- }
1407
- }
1408
-
1409
- export async function testConnectionConfig(
1410
- connectionConfig: ApiConnection,
1411
- ): Promise<ApiConnectionStatus> {
1412
- let malloyConnections: Map<string, BaseConnection> | null = null;
1413
- try {
1414
- // Validate that connection name is provided
1415
- if (!connectionConfig.name) {
1416
- throw new Error("Connection name is required");
1417
- }
1418
-
1419
- // Use createProjectConnections to create the connection, then test it
1420
- const result = await createProjectConnections(
1421
- [connectionConfig], // Pass the single connection config
1422
- );
1423
- malloyConnections = result.malloyConnections;
1424
-
1425
- // Get the created connection
1426
- const connection = malloyConnections.get(connectionConfig.name);
1427
- if (!connection) {
1428
- throw new Error(
1429
- `Failed to create connection: ${connectionConfig.name}`,
1430
- );
1431
- }
1432
-
1433
- // Handle DuckDB connections specially since they have attached databases
1434
- if (connectionConfig.type === "duckdb") {
1435
- await testDuckDBConnection(
1436
- connection as DuckDBConnection,
1437
- connectionConfig as InternalConnection,
1438
- );
1439
- } else if (connectionConfig.type === "ducklake") {
1440
- // DuckLake uses DuckDB internally — verify the database is attached
1441
- const duckConn = connection as DuckDBConnection;
1442
- const attached = await isDatabaseAttached(
1443
- duckConn,
1444
- connectionConfig.name as string,
1445
- );
1446
- if (!attached) {
1447
- throw new Error(
1448
- `DuckLake connection test failed: Error attaching database '${connectionConfig.name}'`,
1449
- );
1450
- }
1451
- await duckConn.runSQL(
1452
- `SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${connectionConfig.name}' LIMIT 1`,
1453
- );
1454
-
1455
- logger.info(
1456
- `DuckLake connection test passed: ${connectionConfig.name}`,
1457
- );
1458
- } else {
1459
- // Test other connection types using their test() method
1460
- await (
1461
- connection as
1462
- | PostgresConnection
1463
- | BigQueryConnection
1464
- | SnowflakeConnection
1465
- | TrinoConnection
1466
- | MySQLConnection
1467
- ).test();
1468
- }
1469
-
1470
- return {
1471
- status: "ok",
1472
- errorMessage: "",
1473
- };
1474
- } catch (error) {
1475
- if (error instanceof AxiosError) {
1476
- logAxiosError(error);
1477
- } else {
1478
- logger.error(error);
1479
- }
1480
-
1481
- return {
1482
- status: "failed",
1483
- errorMessage: (error as Error).message,
1484
- };
1485
- } finally {
1486
- // Cleanup: close all connections and remove ducklake files created during testing
1487
- if (malloyConnections) {
1488
- for (const [connName, conn] of malloyConnections) {
1489
- try {
1490
- // Close the connection
1491
- if (
1492
- conn &&
1493
- typeof (conn as DuckDBConnection).close === "function"
1494
- ) {
1495
- await (conn as DuckDBConnection).close();
1496
- }
1497
- } catch (closeError) {
1498
- logger.warn(
1499
- `Error closing connection ${connName} during test cleanup`,
1500
- { error: closeError },
1501
- );
1502
- } finally {
1503
- // Remove ducklake files created during testing (only for ducklake connections)
1504
- if (connectionConfig.type === "ducklake") {
1505
- await deleteDuckLakeConnectionFile(connName, process.cwd());
1506
- }
1507
- }
1508
- }
1509
- }
1510
- }
1511
- }