@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.
- package/README.docker.md +145 -20
- package/README.md +65 -12
- package/dist/app/api-doc.html +5 -0
- package/dist/app/api-doc.yaml +3929 -534
- package/dist/app/assets/EnvironmentPage-CHq5BF4l.js +1 -0
- package/dist/app/assets/HomePage-CIp-pJCa.js +1 -0
- package/dist/app/assets/LightMode-mHHPjEeA.js +1 -0
- package/dist/app/assets/MainPage-CylxO5Yu.js +2 -0
- package/dist/app/assets/MaterializationsPage-a0vjqxib.js +1 -0
- package/dist/app/assets/ModelPage-Cz-MrSZQ.js +1 -0
- package/dist/app/assets/PackagePage-Cdg5D7AN.js +1 -0
- package/dist/app/assets/RouteError-B5SIyn6G.js +1 -0
- package/dist/app/assets/ThemeEditorPage-B62rRc-y.js +1 -0
- package/dist/app/assets/WorkbookPage-45cG1Gx6.js +1 -0
- package/dist/app/assets/{core-CjeTkq8O.es-BqRc6yhC.js → core-DyU47reh.es-DY9b3WjS.js} +1 -1
- package/dist/app/assets/github-dark-DenFmJkN.es-DHJKELXO.js +1 -0
- package/dist/app/assets/index-5eLCcNmP.css +1 -0
- package/dist/app/assets/index-C5ClsnS7.js +1774 -0
- package/dist/app/assets/index-DF9tVqwN.js +1 -0
- package/dist/app/assets/index-DSoCUF-f.js +2725 -0
- package/dist/app/assets/index-DgVRz2wm.js +2 -0
- package/dist/app/assets/index-P-pF8nU5.js +632 -0
- package/dist/app/assets/index-w_0OQJgZ.js +23 -0
- package/dist/app/index.html +8 -4
- package/dist/cpufeatures-1yrn0vtw.node +0 -0
- package/dist/default-publisher.config.json +23 -0
- package/dist/{instrumentation.js → instrumentation.mjs} +10631 -10573
- package/dist/package_load_worker.mjs +15219 -0
- package/dist/runtime/publisher.js +331 -0
- package/dist/{server.js → server.mjs} +141276 -74705
- package/dist/sshcrypto-vd2k5hq9.node +0 -0
- package/package.json +41 -28
- package/publisher.config.example.bigquery.json +33 -0
- package/publisher.config.example.duckdb.json +23 -0
- package/.eslintrc.json +0 -44
- package/.prettierignore +0 -1
- package/.prettierrc +0 -4
- package/build.ts +0 -38
- package/dist/app/assets/HomePage-Dn3E4CuB.js +0 -1
- package/dist/app/assets/MainPage-BzB3yoqi.js +0 -2
- package/dist/app/assets/ModelPage-C9O_sAXT.js +0 -1
- package/dist/app/assets/PackagePage-DcxKEjBX.js +0 -1
- package/dist/app/assets/ProjectPage-BDj307rF.js +0 -1
- package/dist/app/assets/RouteError-DAShbVCG.js +0 -1
- package/dist/app/assets/WorkbookPage-Cs_XYEaB.js +0 -1
- package/dist/app/assets/index-15BOvhp0.js +0 -456
- package/dist/app/assets/index-Bb2jqquW.js +0 -676
- package/dist/app/assets/index-CMlGQMcl.css +0 -1
- package/dist/app/assets/index-D68X76-7.js +0 -1276
- package/dist/app/assets/index.umd-DGBekgSu.js +0 -1145
- package/dxt/malloy_bridge.py +0 -354
- package/dxt/manifest.json +0 -22
- package/eslint.config.mjs +0 -76
- package/k6-tests/README.md +0 -144
- package/k6-tests/bun.lock +0 -797
- package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
- package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
- package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
- package/k6-tests/load-test/load-test-crud.ts +0 -142
- package/k6-tests/load-test/load-test.ts +0 -784
- package/k6-tests/package.json +0 -27
- package/k6-tests/scripts/run-with-otel.sh +0 -50
- package/k6-tests/smoke-test/smoke-test.ts +0 -158
- package/k6-tests/tsconfig.json +0 -29
- package/k6-tests/utils/client_factory.ts +0 -128
- package/k6-tests/utils/common.ts +0 -489
- package/k6-tests/utils/logger.ts +0 -62
- package/k6-tests/utils/types.d.ts +0 -35
- package/malloy_mcp.dxt +0 -0
- package/publisher.config.json +0 -33
- package/src/config.spec.ts +0 -850
- package/src/config.ts +0 -305
- package/src/constants.ts +0 -10
- package/src/controller/compile.controller.ts +0 -35
- package/src/controller/connection.controller.ts +0 -515
- package/src/controller/database.controller.ts +0 -21
- package/src/controller/manifest.controller.ts +0 -29
- package/src/controller/materialization.controller.ts +0 -125
- package/src/controller/model.controller.ts +0 -107
- package/src/controller/package.controller.ts +0 -182
- package/src/controller/query.controller.ts +0 -60
- package/src/controller/watch-mode.controller.ts +0 -98
- package/src/data_styles.ts +0 -71
- package/src/dto/connection.dto.spec.ts +0 -131
- package/src/dto/connection.dto.ts +0 -174
- package/src/dto/index.ts +0 -2
- package/src/dto/package.dto.spec.ts +0 -42
- package/src/dto/package.dto.ts +0 -12
- package/src/dto/validate.spec.ts +0 -76
- package/src/dto/validate.ts +0 -31
- package/src/errors.ts +0 -116
- package/src/health.ts +0 -189
- package/src/instrumentation.ts +0 -148
- package/src/logger.ts +0 -154
- package/src/mcp/error_messages.ts +0 -199
- package/src/mcp/handler_utils.ts +0 -239
- package/src/mcp/mcp_constants.ts +0 -22
- package/src/mcp/prompts/handlers.ts +0 -84
- package/src/mcp/prompts/index.ts +0 -11
- package/src/mcp/prompts/prompt_definitions.ts +0 -160
- package/src/mcp/prompts/prompt_service.ts +0 -67
- package/src/mcp/prompts/utils.ts +0 -62
- package/src/mcp/resource_metadata.ts +0 -47
- package/src/mcp/resources/model_resource.ts +0 -153
- package/src/mcp/resources/notebook_resource.ts +0 -137
- package/src/mcp/resources/package_resource.ts +0 -370
- package/src/mcp/resources/project_resource.ts +0 -184
- package/src/mcp/resources/query_resource.ts +0 -117
- package/src/mcp/resources/source_resource.ts +0 -141
- package/src/mcp/resources/view_resource.ts +0 -136
- package/src/mcp/server.ts +0 -62
- package/src/mcp/tools/discovery_tools.ts +0 -258
- package/src/mcp/tools/execute_query_tool.ts +0 -265
- package/src/server.ts +0 -1195
- package/src/service/connection.spec.ts +0 -1331
- package/src/service/connection.ts +0 -1511
- package/src/service/connection_service.spec.ts +0 -641
- package/src/service/connection_service.ts +0 -181
- package/src/service/db_utils.spec.ts +0 -714
- package/src/service/db_utils.ts +0 -1227
- package/src/service/filter.spec.ts +0 -447
- package/src/service/filter.ts +0 -337
- package/src/service/filter_integration.spec.ts +0 -825
- package/src/service/gcs_s3_utils.ts +0 -427
- package/src/service/manifest_service.spec.ts +0 -201
- package/src/service/manifest_service.ts +0 -106
- package/src/service/materialization_service.spec.ts +0 -648
- package/src/service/materialization_service.ts +0 -929
- package/src/service/materialized_table_gc.spec.ts +0 -383
- package/src/service/materialized_table_gc.ts +0 -279
- package/src/service/model.spec.ts +0 -257
- package/src/service/model.ts +0 -1069
- package/src/service/package.spec.ts +0 -236
- package/src/service/package.ts +0 -463
- package/src/service/project.ts +0 -681
- package/src/service/project_compile.spec.ts +0 -197
- package/src/service/project_store.spec.ts +0 -859
- package/src/service/project_store.ts +0 -1531
- package/src/service/quoting.ts +0 -41
- package/src/service/resolve_project.ts +0 -13
- package/src/storage/BaseRepository.ts +0 -31
- package/src/storage/DatabaseInterface.ts +0 -180
- package/src/storage/StorageManager.mock.ts +0 -59
- package/src/storage/StorageManager.ts +0 -210
- package/src/storage/duckdb/ConnectionRepository.ts +0 -141
- package/src/storage/duckdb/DuckDBConnection.ts +0 -177
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/DuckDBRepository.ts +0 -224
- package/src/storage/duckdb/ManifestRepository.ts +0 -119
- package/src/storage/duckdb/MaterializationRepository.ts +0 -249
- package/src/storage/duckdb/PackageRepository.ts +0 -135
- package/src/storage/duckdb/ProjectRepository.ts +0 -148
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/duckdb/schema.ts +0 -147
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -146
- package/src/utils.ts +0 -13
- package/tests/fixtures/persist-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-test/persist_test.malloy +0 -11
- package/tests/fixtures/persist-test/publisher.json +0 -5
- package/tests/fixtures/publisher.config.json +0 -15
- package/tests/harness/e2e.ts +0 -96
- package/tests/harness/mcp_test_setup.ts +0 -344
- package/tests/harness/mocks.ts +0 -37
- package/tests/harness/rest_e2e.ts +0 -68
- package/tests/harness/uris.ts +0 -31
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -470
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -434
- package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -663
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -122
- package/tests/integration/mcp/setup.spec.ts +0 -5
- package/tests/unit/duckdb/attached_databases.test.ts +0 -1132
- package/tests/unit/ducklake/ducklake.test.ts +0 -950
- package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
- package/tests/unit/mcp/prompt_happy.test.ts +0 -51
- 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
|
-
}
|