@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.
- package/dist/app/api-doc.yaml +25 -11
- package/dist/app/assets/{HomePage-z6NLKLPp.js → HomePage-zgjLbpTO.js} +1 -1
- package/dist/app/assets/{MainPage-C9McOjLb.js → MainPage-BD2RiQ6H.js} +1 -1
- package/dist/app/assets/{ModelPage-DjlTuT2G.js → ModelPage-BqzWWLvj.js} +1 -1
- package/dist/app/assets/{PackagePage-CDh_gnAZ.js → PackagePage-C-JViRAf.js} +1 -1
- package/dist/app/assets/{ProjectPage-vyvZZWAB.js → ProjectPage-CLlZgxV2.js} +1 -1
- package/dist/app/assets/{RouteError-FbxztVnz.js → RouteError-Bf0xYlO6.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DNXFxaeZ.js → WorkbookPage-C7AyEw5d.js} +1 -1
- package/dist/app/assets/{index-DHFp2DLx.js → index-5F3gtdSl.js} +1 -1
- package/dist/app/assets/{index-a6hx_UrL.js → index-BUztoMie.js} +4 -4
- package/dist/app/assets/{index-BMyI9XZS.js → index-BWXd8Ctd.js} +1 -1
- package/dist/app/assets/{index.umd-Cv1NyZL8.js → index.umd-PbB35uhR.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.js +248 -156
- package/package.json +1 -1
- package/src/controller/connection.controller.ts +2 -3
- package/src/service/connection.ts +334 -213
- package/src/service/db_utils.ts +37 -52
- package/src/service/project.ts +5 -2
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +4 -4
|
@@ -64,235 +64,324 @@ function validateAndBuildTrinoConfig(
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
// Shared utilities
|
|
68
|
+
async function installAndLoadExtension(
|
|
69
|
+
connection: DuckDBConnection,
|
|
70
|
+
extensionName: string,
|
|
71
|
+
fromCommunity = false,
|
|
70
72
|
): Promise<void> {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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
|
);
|