@malloy-publisher/server 0.0.122 → 0.0.124

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.
@@ -64,235 +64,324 @@ function validateAndBuildTrinoConfig(
64
64
  }
65
65
  }
66
66
 
67
- async function attachDatabasesToDuckDB(
68
- duckdbConnection: DuckDBConnection,
69
- attachedDatabases: AttachedDatabase[],
67
+ // Shared utilities
68
+ async function installAndLoadExtension(
69
+ connection: DuckDBConnection,
70
+ extensionName: string,
71
+ fromCommunity = false,
70
72
  ): Promise<void> {
71
- for (const attachedDb of attachedDatabases) {
72
- try {
73
- // Check if database is already attached
74
- try {
75
- const checkQuery = `SHOW DATABASES`;
76
- const existingDatabases = await duckdbConnection.runSQL(checkQuery);
77
- const rows = Array.isArray(existingDatabases)
78
- ? existingDatabases
79
- : existingDatabases.rows || [];
80
-
81
- logger.debug(`Existing databases:`, rows);
82
-
83
- // Check if the database name exists in any column (handle different column names)
84
- const isAlreadyAttached = rows.some(
85
- (row: Record<string, unknown>) => {
86
- return Object.values(row).some(
87
- (value: unknown) =>
88
- typeof value === "string" && value === attachedDb.name,
89
- );
90
- },
91
- );
73
+ try {
74
+ const installCommand = fromCommunity
75
+ ? `FORCE INSTALL '${extensionName}' FROM community;`
76
+ : `INSTALL ${extensionName};`;
77
+ await connection.runSQL(installCommand);
78
+ logger.info(`${extensionName} extension installed`);
79
+ } catch (error) {
80
+ logger.info(
81
+ `${extensionName} extension already installed or install skipped`,
82
+ { error },
83
+ );
84
+ }
92
85
 
93
- if (isAlreadyAttached) {
94
- logger.info(
95
- `Database ${attachedDb.name} is already attached, skipping`,
96
- );
97
- continue;
98
- }
99
- } catch (error) {
100
- logger.warn(
101
- `Failed to check existing databases, proceeding with attachment:`,
102
- error,
86
+ await connection.runSQL(`LOAD ${extensionName};`);
87
+ logger.info(`${extensionName} extension loaded`);
88
+ }
89
+
90
+ async function isDatabaseAttached(
91
+ connection: DuckDBConnection,
92
+ dbName: string,
93
+ ): Promise<boolean> {
94
+ try {
95
+ const existingDatabases = await connection.runSQL("SHOW DATABASES");
96
+ const rows = Array.isArray(existingDatabases)
97
+ ? existingDatabases
98
+ : existingDatabases.rows || [];
99
+
100
+ logger.debug(`Existing databases:`, rows);
101
+
102
+ return rows.some((row: Record<string, unknown>) =>
103
+ Object.values(row).some(
104
+ (value) => typeof value === "string" && value === dbName,
105
+ ),
106
+ );
107
+ } catch (error) {
108
+ logger.warn(`Failed to check existing databases:`, error);
109
+ return false;
110
+ }
111
+ }
112
+
113
+ function sanitizeSecretName(name: string): string {
114
+ return `secret_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
115
+ }
116
+
117
+ function escapeSQL(value: string): string {
118
+ return value.replace(/'/g, "''");
119
+ }
120
+
121
+ function handleAlreadyAttachedError(error: unknown, dbName: string): void {
122
+ if (error instanceof Error && error.message.includes("already exists")) {
123
+ logger.info(`Database ${dbName} is already attached, skipping`);
124
+ } else {
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ // Database-specific attachment handlers
130
+ async function attachBigQuery(
131
+ connection: DuckDBConnection,
132
+ attachedDb: AttachedDatabase,
133
+ ): Promise<void> {
134
+ if (!attachedDb.bigqueryConnection) {
135
+ throw new Error(
136
+ `BigQuery connection configuration missing for: ${attachedDb.name}`,
137
+ );
138
+ }
139
+
140
+ const config = attachedDb.bigqueryConnection;
141
+ let projectId = config.defaultProjectId;
142
+ let serviceAccountJson: string | undefined;
143
+
144
+ // Parse and validate service account key
145
+ if (config.serviceAccountKeyJson) {
146
+ const keyData = JSON.parse(config.serviceAccountKeyJson as string);
147
+
148
+ const requiredFields = [
149
+ "type",
150
+ "project_id",
151
+ "private_key",
152
+ "client_email",
153
+ ];
154
+ for (const field of requiredFields) {
155
+ if (!keyData[field]) {
156
+ throw new Error(
157
+ `Invalid service account key: missing "${field}" field`,
103
158
  );
104
159
  }
160
+ }
105
161
 
106
- switch (attachedDb.type) {
107
- case "bigquery": {
108
- if (!attachedDb.bigqueryConnection) {
109
- throw new Error(
110
- `BigQuery connection configuration is missing for attached database: ${attachedDb.name}`,
111
- );
112
- }
162
+ if (keyData.type !== "service_account") {
163
+ throw new Error('Invalid service account key: incorrect "type" field');
164
+ }
113
165
 
114
- // Install and load the bigquery extension
115
- await duckdbConnection.runSQL(
116
- "INSTALL bigquery FROM community;",
117
- );
118
- await duckdbConnection.runSQL("LOAD bigquery;");
166
+ projectId = keyData.project_id || config.defaultProjectId;
167
+ serviceAccountJson = config.serviceAccountKeyJson as string;
168
+ logger.info(`Using service account: ${keyData.client_email}`);
169
+ }
119
170
 
120
- // Build the ATTACH command for BigQuery
121
- const bigqueryConfig = attachedDb.bigqueryConnection;
122
- const attachParams = new URLSearchParams();
171
+ if (!projectId || !serviceAccountJson) {
172
+ throw new Error(
173
+ `BigQuery project_id and service account key required for: ${attachedDb.name}`,
174
+ );
175
+ }
123
176
 
124
- if (!bigqueryConfig.defaultProjectId) {
125
- throw new Error(
126
- `BigQuery defaultProjectId is required for attached database: ${attachedDb.name}`,
127
- );
128
- }
129
- attachParams.set("project", bigqueryConfig.defaultProjectId);
130
-
131
- // Handle service account key if provided
132
- if (bigqueryConfig.serviceAccountKeyJson) {
133
- const serviceAccountKeyPath = path.join(
134
- TEMP_DIR_PATH,
135
- `duckdb-${attachedDb.name}-${uuidv4()}-service-account-key.json`,
136
- );
137
- await fs.writeFile(
138
- serviceAccountKeyPath,
139
- bigqueryConfig.serviceAccountKeyJson as string,
140
- );
141
- attachParams.set(
142
- "service_account_key",
143
- serviceAccountKeyPath,
144
- );
145
- }
177
+ await installAndLoadExtension(connection, "bigquery", true);
146
178
 
147
- const attachCommand = `ATTACH '${attachParams.toString()}' AS ${attachedDb.name} (TYPE bigquery, READ_ONLY);`;
148
- try {
149
- await duckdbConnection.runSQL(attachCommand);
150
- logger.info(
151
- `Successfully attached BigQuery database: ${attachedDb.name}`,
152
- );
153
- } catch (attachError: unknown) {
154
- if (
155
- attachError instanceof Error &&
156
- attachError.message &&
157
- attachError.message.includes("already exists")
158
- ) {
159
- logger.info(
160
- `BigQuery database ${attachedDb.name} is already attached, skipping`,
161
- );
162
- } else {
163
- throw attachError;
164
- }
165
- }
166
- break;
167
- }
179
+ const secretName = sanitizeSecretName(`bigquery_${attachedDb.name}`);
180
+ const escapedJson = escapeSQL(serviceAccountJson);
168
181
 
169
- case "snowflake": {
170
- if (!attachedDb.snowflakeConnection) {
171
- throw new Error(
172
- `Snowflake connection configuration is missing for attached database: ${attachedDb.name}`,
173
- );
174
- }
182
+ const createSecretCommand = `
183
+ CREATE OR REPLACE SECRET ${secretName} (
184
+ TYPE BIGQUERY,
185
+ SCOPE 'bq://${projectId}',
186
+ SERVICE_ACCOUNT_JSON '${escapedJson}'
187
+ );
188
+ `;
175
189
 
176
- // Install and load the snowflake extension
177
- await duckdbConnection.runSQL(
178
- "INSTALL snowflake FROM community;",
179
- );
180
- await duckdbConnection.runSQL("LOAD snowflake;");
190
+ await connection.runSQL(createSecretCommand);
191
+ logger.info(
192
+ `Created BigQuery secret: ${secretName} for project: ${projectId}`,
193
+ );
181
194
 
182
- // Build the ATTACH command for Snowflake
183
- const snowflakeConfig = attachedDb.snowflakeConnection;
184
- const attachParams = new URLSearchParams();
195
+ const attachCommand = `ATTACH 'project=${projectId}' AS ${attachedDb.name} (TYPE bigquery, READ_ONLY);`;
196
+ await connection.runSQL(attachCommand);
197
+ logger.info(`Successfully attached BigQuery database: ${attachedDb.name}`);
198
+ }
185
199
 
186
- if (snowflakeConfig.account) {
187
- attachParams.set("account", snowflakeConfig.account);
188
- }
189
- if (snowflakeConfig.username) {
190
- attachParams.set("username", snowflakeConfig.username);
191
- }
192
- if (snowflakeConfig.password) {
193
- attachParams.set("password", snowflakeConfig.password);
194
- }
195
- if (snowflakeConfig.database) {
196
- attachParams.set("database", snowflakeConfig.database);
197
- }
198
- if (snowflakeConfig.warehouse) {
199
- attachParams.set("warehouse", snowflakeConfig.warehouse);
200
- }
201
- if (snowflakeConfig.role) {
202
- attachParams.set("role", snowflakeConfig.role);
203
- }
200
+ async function attachSnowflake(
201
+ connection: DuckDBConnection,
202
+ attachedDb: AttachedDatabase,
203
+ ): Promise<void> {
204
+ if (!attachedDb.snowflakeConnection) {
205
+ throw new Error(
206
+ `Snowflake connection configuration missing for: ${attachedDb.name}`,
207
+ );
208
+ }
204
209
 
205
- const attachCommand = `ATTACH '${attachParams.toString()}' AS ${attachedDb.name} (TYPE snowflake, READ_ONLY);`;
206
- try {
207
- await duckdbConnection.runSQL(attachCommand);
208
- logger.info(
209
- `Successfully attached Snowflake database: ${attachedDb.name}`,
210
- );
211
- } catch (attachError: unknown) {
212
- if (
213
- attachError instanceof Error &&
214
- attachError.message &&
215
- attachError.message.includes("already exists")
216
- ) {
217
- logger.info(
218
- `Snowflake database ${attachedDb.name} is already attached, skipping`,
219
- );
220
- } else {
221
- throw attachError;
222
- }
223
- }
224
- break;
225
- }
210
+ const config = attachedDb.snowflakeConnection;
226
211
 
227
- case "postgres": {
228
- if (!attachedDb.postgresConnection) {
229
- throw new Error(
230
- `PostgreSQL connection configuration is missing for attached database: ${attachedDb.name}`,
231
- );
232
- }
212
+ // Validate required fields
213
+ const requiredFields = {
214
+ account: config.account,
215
+ username: config.username,
216
+ password: config.password,
217
+ };
218
+ for (const [field, value] of Object.entries(requiredFields)) {
219
+ if (!value) {
220
+ throw new Error(
221
+ `Snowflake ${field} is required for: ${attachedDb.name}`,
222
+ );
223
+ }
224
+ }
233
225
 
234
- // Install and load the postgres extension
235
- await duckdbConnection.runSQL(
236
- "INSTALL postgres FROM community;",
237
- );
238
- await duckdbConnection.runSQL("LOAD postgres;");
239
-
240
- // Build the ATTACH command for PostgreSQL
241
- const postgresConfig = attachedDb.postgresConnection;
242
- let attachString: string;
243
-
244
- // Use connection string if provided, otherwise build from individual parameters
245
- if (postgresConfig.connectionString) {
246
- attachString = postgresConfig.connectionString;
247
- } else {
248
- // Build connection string from individual parameters
249
- const params = new URLSearchParams();
250
-
251
- if (postgresConfig.host) {
252
- params.set("host", postgresConfig.host);
253
- }
254
- if (postgresConfig.port) {
255
- params.set("port", postgresConfig.port.toString());
256
- }
257
- if (postgresConfig.databaseName) {
258
- params.set("dbname", postgresConfig.databaseName);
259
- }
260
- if (postgresConfig.userName) {
261
- params.set("user", postgresConfig.userName);
262
- }
263
- if (postgresConfig.password) {
264
- params.set("password", postgresConfig.password);
265
- }
266
-
267
- attachString = params.toString();
268
- }
226
+ await installAndLoadExtension(connection, "snowflake", true);
269
227
 
270
- const attachCommand = `ATTACH '${attachString}' AS ${attachedDb.name} (TYPE postgres, READ_ONLY);`;
271
- try {
272
- await duckdbConnection.runSQL(attachCommand);
273
- logger.info(
274
- `Successfully attached PostgreSQL database: ${attachedDb.name}`,
275
- );
276
- } catch (attachError: unknown) {
277
- if (
278
- attachError instanceof Error &&
279
- attachError.message &&
280
- attachError.message.includes("already exists")
281
- ) {
282
- logger.info(
283
- `PostgreSQL database ${attachedDb.name} is already attached, skipping`,
284
- );
285
- } else {
286
- throw attachError;
287
- }
288
- }
289
- break;
290
- }
228
+ // Verify ADBC driver
229
+ try {
230
+ const version = await connection.runSQL("SELECT snowflake_version();");
231
+ logger.info(`Snowflake ADBC driver verified with version:`, version.rows);
232
+ } catch (error) {
233
+ throw new Error(
234
+ `Snowflake ADBC driver verification failed: ${error instanceof Error ? error.message : String(error)}`,
235
+ );
236
+ }
291
237
 
292
- default:
293
- throw new Error(
294
- `Unsupported attached database type: ${attachedDb.type}`,
295
- );
238
+ // Build connection parameters
239
+ const params = {
240
+ account: escapeSQL(config.account || ""),
241
+ user: escapeSQL(config.username || ""),
242
+ password: escapeSQL(config.password || ""),
243
+ database: config.database ? escapeSQL(config.database) : undefined,
244
+ warehouse: config.warehouse ? escapeSQL(config.warehouse) : undefined,
245
+ schema: config.schema ? escapeSQL(config.schema) : undefined,
246
+ role: config.role ? escapeSQL(config.role) : undefined,
247
+ };
248
+
249
+ // Create attach string
250
+ const attachParts = [
251
+ `account=${params.account}`,
252
+ `user=${params.user}`,
253
+ `password=${params.password}`,
254
+ ];
255
+
256
+ if (params.database) attachParts.push(`database=${params.database}`);
257
+ if (params.warehouse) attachParts.push(`warehouse=${params.warehouse}`);
258
+
259
+ const secretString = `CREATE OR REPLACE SECRET ${attachedDb.name}_secret (
260
+ TYPE snowflake,
261
+ ACCOUNT '${params.account}',
262
+ USER '${params.user}',
263
+ PASSWORD '${params.password}',
264
+ DATABASE '${params.database}',
265
+ WAREHOUSE '${params.warehouse}'
266
+ );`;
267
+
268
+ await connection.runSQL(secretString);
269
+
270
+ const testresult = await connection.runSQL(
271
+ `SELECT * FROM snowflake_scan('SELECT 1', '${attachedDb.name}_secret');`,
272
+ );
273
+ logger.info(`Testing Snowflake connection:`, testresult.rows);
274
+
275
+ const attachCommand = `ATTACH '${attachedDb.name}' AS ${attachedDb.name} (TYPE snowflake, SECRET ${attachedDb.name}_secret, READ_ONLY);`;
276
+ await connection.runSQL(attachCommand);
277
+ logger.info(`Successfully attached Snowflake database: ${attachedDb.name}`);
278
+ }
279
+
280
+ async function attachPostgres(
281
+ connection: DuckDBConnection,
282
+ attachedDb: AttachedDatabase,
283
+ ): Promise<void> {
284
+ if (!attachedDb.postgresConnection) {
285
+ throw new Error(
286
+ `PostgreSQL connection configuration missing for: ${attachedDb.name}`,
287
+ );
288
+ }
289
+
290
+ await installAndLoadExtension(connection, "postgres");
291
+
292
+ const config = attachedDb.postgresConnection;
293
+ let attachString: string;
294
+
295
+ if (config.connectionString) {
296
+ attachString = config.connectionString;
297
+ } else {
298
+ const parts: string[] = [];
299
+ if (config.host) parts.push(`host=${config.host}`);
300
+ if (config.port) parts.push(`port=${config.port}`);
301
+ if (config.databaseName) parts.push(`dbname=${config.databaseName}`);
302
+ if (config.userName) parts.push(`user=${config.userName}`);
303
+ if (config.password) parts.push(`password=${config.password}`);
304
+ if (process.env.PGSSLMODE === "no-verify") parts.push(`sslmode=disable`);
305
+ attachString = parts.join(" ");
306
+ }
307
+
308
+ const attachCommand = `ATTACH '${attachString}' AS ${attachedDb.name} (TYPE postgres, READ_ONLY);`;
309
+ await connection.runSQL(attachCommand);
310
+ logger.info(`Successfully attached PostgreSQL database: ${attachedDb.name}`);
311
+ }
312
+
313
+ async function attachMotherDuck(
314
+ connection: DuckDBConnection,
315
+ attachedDb: AttachedDatabase,
316
+ ): Promise<void> {
317
+ if (!attachedDb.motherDuckConnection) {
318
+ throw new Error(
319
+ `MotherDuck connection configuration missing for: ${attachedDb.name}`,
320
+ );
321
+ }
322
+
323
+ const config = attachedDb.motherDuckConnection;
324
+
325
+ if (!config.database) {
326
+ throw new Error(
327
+ `MotherDuck database name is required for: ${attachedDb.name}`,
328
+ );
329
+ }
330
+
331
+ await installAndLoadExtension(connection, "motherduck");
332
+
333
+ // Set token if provided
334
+ if (config.accessToken) {
335
+ const escapedToken = escapeSQL(config.accessToken);
336
+ await connection.runSQL(`SET motherduck_token = '${escapedToken}';`);
337
+ }
338
+
339
+ const connectionString = `md:${config.database}`;
340
+ logger.info(
341
+ `Connecting to MotherDuck database: ${config.database} as ${attachedDb.name}`,
342
+ );
343
+
344
+ const attachCommand = `ATTACH '${connectionString}' AS ${attachedDb.name} (TYPE motherduck, READ_ONLY);`;
345
+ await connection.runSQL(attachCommand);
346
+ logger.info(`Successfully attached MotherDuck database: ${attachedDb.name}`);
347
+ }
348
+
349
+ // Main attachment function
350
+ async function attachDatabasesToDuckDB(
351
+ duckdbConnection: DuckDBConnection,
352
+ attachedDatabases: AttachedDatabase[],
353
+ ): Promise<void> {
354
+ const attachHandlers = {
355
+ bigquery: attachBigQuery,
356
+ snowflake: attachSnowflake,
357
+ postgres: attachPostgres,
358
+ motherduck: attachMotherDuck,
359
+ };
360
+
361
+ for (const attachedDb of attachedDatabases) {
362
+ try {
363
+ // Check if already attached
364
+ if (
365
+ await isDatabaseAttached(duckdbConnection, attachedDb.name || "")
366
+ ) {
367
+ logger.info(
368
+ `Database ${attachedDb.name} is already attached, skipping`,
369
+ );
370
+ continue;
371
+ }
372
+
373
+ // Get the appropriate handler
374
+ const handler =
375
+ attachHandlers[attachedDb.type as keyof typeof attachHandlers];
376
+ if (!handler) {
377
+ throw new Error(`Unsupported database type: ${attachedDb.type}`);
378
+ }
379
+
380
+ // Execute attachment
381
+ try {
382
+ await handler(duckdbConnection, attachedDb);
383
+ } catch (attachError) {
384
+ handleAlreadyAttachedError(attachError, attachedDb.name || "");
296
385
  }
297
386
  } catch (error) {
298
387
  logger.error(`Failed to attach database ${attachedDb.name}:`, error);
@@ -305,6 +394,7 @@ async function attachDatabasesToDuckDB(
305
394
 
306
395
  export async function createProjectConnections(
307
396
  connections: ApiConnection[] = [],
397
+ projectPath: string = "",
308
398
  ): Promise<{
309
399
  malloyConnections: Map<string, BaseConnection>;
310
400
  apiConnections: InternalConnection[];
@@ -477,8 +567,32 @@ export async function createProjectConnections(
477
567
  }
478
568
 
479
569
  case "duckdb": {
480
- // DuckDB connections are created at the package level in package.ts
481
- // to ensure the workingDirectory is set correctly for each connection
570
+ if (!connection.duckdbConnection) {
571
+ throw new Error("DuckDB connection configuration is missing.");
572
+ }
573
+
574
+ // Create DuckDB connection with project basePath as working directory
575
+ // This ensures relative paths in the project are resolved correctly
576
+ const duckdbConnection = new DuckDBConnection(
577
+ connection.name,
578
+ ":memory:",
579
+ projectPath,
580
+ );
581
+
582
+ // Attach databases if configured
583
+ if (
584
+ connection.duckdbConnection.attachedDatabases &&
585
+ Array.isArray(connection.duckdbConnection.attachedDatabases) &&
586
+ connection.duckdbConnection.attachedDatabases.length > 0
587
+ ) {
588
+ await attachDatabasesToDuckDB(
589
+ duckdbConnection,
590
+ connection.duckdbConnection.attachedDatabases,
591
+ );
592
+ }
593
+
594
+ connectionMap.set(connection.name, duckdbConnection);
595
+ connection.attributes = getConnectionAttributes(duckdbConnection);
482
596
  break;
483
597
  }
484
598
 
@@ -624,6 +738,13 @@ export async function testConnectionConfig(
624
738
 
625
739
  // Use createProjectConnections to create the connection, then test it
626
740
  // TODO: Test duckdb connections?
741
+ if (connectionConfig.type === "duckdb") {
742
+ return {
743
+ status: "ok",
744
+ errorMessage: "",
745
+ };
746
+ }
747
+
627
748
  const { malloyConnections } = await createProjectConnections(
628
749
  [connectionConfig], // Pass the single connection config
629
750
  );