@malloy-publisher/server 0.0.167 → 0.0.168
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/.eslintrc.json +9 -1
- package/dist/app/api-doc.yaml +36 -1
- package/dist/app/assets/HomePage-D2tUw_9U.js +1 -0
- package/dist/app/assets/{MainPage-C9Fr5IN8.js → MainPage-DBQW76L7.js} +2 -2
- package/dist/app/assets/{ModelPage-BkU6HAHA.js → ModelPage-BnfOKuhQ.js} +1 -1
- package/dist/app/assets/PackagePage-zPhE-rDg.js +1 -0
- package/dist/app/assets/ProjectPage-BpSTvuW6.js +1 -0
- package/dist/app/assets/RouteError-Cp9-yCK5.js +1 -0
- package/dist/app/assets/{WorkbookPage-D3rUQZj6.js → WorkbookPage-FD_gmxeE.js} +1 -1
- package/dist/app/assets/{index-BLxl0XLH.js → index-D5QBYuLK.js} +150 -150
- package/dist/app/assets/{index-lhDwptrQ.js → index-DNCvL_5f.js} +1 -1
- package/dist/app/assets/{index-hkABoiMV.js → index-x9S1fsYn.js} +1 -1
- package/dist/app/assets/{index.umd-BkXQ-YAe.js → index.umd-CTYdFEHH.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.js +261 -27
- package/package.json +1 -1
- package/src/controller/connection.controller.ts +22 -2
- package/src/server.ts +5 -1
- package/src/service/connection.spec.ts +105 -0
- package/src/service/connection.ts +293 -17
- package/src/service/db_utils.ts +85 -4
- package/src/service/project.ts +20 -3
- package/tests/harness/mcp_test_setup.ts +166 -26
- package/tests/unit/duckdb/attached_databases.test.ts +61 -3
- package/tests/unit/ducklake/ducklake.test.ts +950 -0
- package/dist/app/assets/HomePage-D76UaGFV.js +0 -1
- package/dist/app/assets/PackagePage-BhE9Wi7b.js +0 -1
- package/dist/app/assets/ProjectPage-BatZLVap.js +0 -1
- package/dist/app/assets/RouteError-Bo5zJ8Xa.js +0 -1
package/dist/app/index.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
|
|
13
13
|
/>
|
|
14
14
|
<title>Malloy Publisher</title>
|
|
15
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
+
<script type="module" crossorigin src="/assets/index-D5QBYuLK.js"></script>
|
|
16
16
|
<link rel="stylesheet" crossorigin href="/assets/index-CMlGQMcl.css">
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
package/dist/server.js
CHANGED
|
@@ -219260,35 +219260,95 @@ async function attachSnowflake(connection, attachedDb) {
|
|
|
219260
219260
|
await connection.runSQL(attachCommand);
|
|
219261
219261
|
logger.info(`Successfully attached Snowflake database: ${attachedDb.name}`);
|
|
219262
219262
|
}
|
|
219263
|
+
function buildPgConnectionString(pg) {
|
|
219264
|
+
if (pg.connectionString) {
|
|
219265
|
+
return pg.connectionString;
|
|
219266
|
+
}
|
|
219267
|
+
const parts = [];
|
|
219268
|
+
if (pg.host)
|
|
219269
|
+
parts.push(`host=${pg.host}`);
|
|
219270
|
+
if (pg.port)
|
|
219271
|
+
parts.push(`port=${pg.port}`);
|
|
219272
|
+
if (pg.databaseName)
|
|
219273
|
+
parts.push(`dbname=${pg.databaseName}`);
|
|
219274
|
+
if (pg.userName)
|
|
219275
|
+
parts.push(`user=${pg.userName}`);
|
|
219276
|
+
if (pg.password)
|
|
219277
|
+
parts.push(`password=${pg.password}`);
|
|
219278
|
+
const pgSSLMode = process.env.PGSSLMODE;
|
|
219279
|
+
if (pgSSLMode) {
|
|
219280
|
+
const mapping = {
|
|
219281
|
+
"no-verify": "disable",
|
|
219282
|
+
disable: "disable",
|
|
219283
|
+
allow: "allow",
|
|
219284
|
+
prefer: "prefer",
|
|
219285
|
+
require: "require",
|
|
219286
|
+
"verify-ca": "verify-ca",
|
|
219287
|
+
"verify-full": "verify-full"
|
|
219288
|
+
};
|
|
219289
|
+
const sslmode = mapping[pgSSLMode.toLowerCase()];
|
|
219290
|
+
if (sslmode)
|
|
219291
|
+
parts.push(`sslmode=${sslmode}`);
|
|
219292
|
+
}
|
|
219293
|
+
return parts.join(" ");
|
|
219294
|
+
}
|
|
219263
219295
|
async function attachPostgres(connection, attachedDb) {
|
|
219264
219296
|
if (!attachedDb.postgresConnection) {
|
|
219265
219297
|
throw new Error(`PostgreSQL connection configuration missing for: ${attachedDb.name}`);
|
|
219266
219298
|
}
|
|
219267
219299
|
await installAndLoadExtension(connection, "postgres");
|
|
219268
219300
|
const config = attachedDb.postgresConnection;
|
|
219269
|
-
|
|
219270
|
-
|
|
219271
|
-
attachString = config.connectionString;
|
|
219272
|
-
} else {
|
|
219273
|
-
const parts = [];
|
|
219274
|
-
if (config.host)
|
|
219275
|
-
parts.push(`host=${config.host}`);
|
|
219276
|
-
if (config.port)
|
|
219277
|
-
parts.push(`port=${config.port}`);
|
|
219278
|
-
if (config.databaseName)
|
|
219279
|
-
parts.push(`dbname=${config.databaseName}`);
|
|
219280
|
-
if (config.userName)
|
|
219281
|
-
parts.push(`user=${config.userName}`);
|
|
219282
|
-
if (config.password)
|
|
219283
|
-
parts.push(`password=${config.password}`);
|
|
219284
|
-
if (process.env.PGSSLMODE === "no-verify")
|
|
219285
|
-
parts.push(`sslmode=disable`);
|
|
219286
|
-
attachString = parts.join(" ");
|
|
219287
|
-
}
|
|
219288
|
-
const attachCommand = `ATTACH '${attachString}' AS ${attachedDb.name} (TYPE postgres, READ_ONLY);`;
|
|
219301
|
+
const attachString = buildPgConnectionString(config);
|
|
219302
|
+
const attachCommand = `ATTACH '${escapeSQL(attachString)}' AS ${attachedDb.name} (TYPE postgres, READ_ONLY);`;
|
|
219289
219303
|
await connection.runSQL(attachCommand);
|
|
219290
219304
|
logger.info(`Successfully attached PostgreSQL database: ${attachedDb.name}`);
|
|
219291
219305
|
}
|
|
219306
|
+
async function attachDuckLake(connection, dbName, ducklakeConfig) {
|
|
219307
|
+
await installAndLoadExtension(connection, "ducklake");
|
|
219308
|
+
await installAndLoadExtension(connection, "postgres");
|
|
219309
|
+
await installAndLoadExtension(connection, "aws");
|
|
219310
|
+
await installAndLoadExtension(connection, "httpfs");
|
|
219311
|
+
if (!ducklakeConfig.catalog?.postgresConnection) {
|
|
219312
|
+
throw new Error(`PostgreSQL connection configuration is required for DuckLake catalog: ${dbName}`);
|
|
219313
|
+
}
|
|
219314
|
+
if (!ducklakeConfig.storage?.bucketUrl) {
|
|
219315
|
+
throw new Error(`Storage bucketUrl is required for DuckLake: ${dbName}`);
|
|
219316
|
+
}
|
|
219317
|
+
const hasS3 = !!ducklakeConfig.storage.s3Connection;
|
|
219318
|
+
const hasGCS = !!ducklakeConfig.storage.gcsConnection;
|
|
219319
|
+
if (hasS3) {
|
|
219320
|
+
await attachCloudStorage(connection, {
|
|
219321
|
+
name: `${dbName}_storage`,
|
|
219322
|
+
type: "s3",
|
|
219323
|
+
s3Connection: ducklakeConfig.storage.s3Connection
|
|
219324
|
+
});
|
|
219325
|
+
} else if (hasGCS) {
|
|
219326
|
+
await attachCloudStorage(connection, {
|
|
219327
|
+
name: `${dbName}_storage`,
|
|
219328
|
+
type: "gcs",
|
|
219329
|
+
gcsConnection: ducklakeConfig.storage.gcsConnection
|
|
219330
|
+
});
|
|
219331
|
+
}
|
|
219332
|
+
const pg = ducklakeConfig.catalog.postgresConnection;
|
|
219333
|
+
const pgConnString = buildPgConnectionString(pg);
|
|
219334
|
+
logger.info(`pgConnString: ${pgConnString}`);
|
|
219335
|
+
const escapedPgConnString = escapeSQL(pgConnString);
|
|
219336
|
+
logger.info(`Final escaped connection string: ${escapedPgConnString}`);
|
|
219337
|
+
const escapedBucketUrl = escapeSQL(ducklakeConfig.storage.bucketUrl);
|
|
219338
|
+
logger.info(`escapedBucketUrl: ${escapedBucketUrl}`);
|
|
219339
|
+
const attachCommand = `ATTACH OR REPLACE 'ducklake:postgres:${escapedPgConnString}' AS ${dbName} (DATA_PATH '${escapedBucketUrl}', OVERRIDE_DATA_PATH true, READ_ONLY true);`;
|
|
219340
|
+
logger.info(`Attaching DuckLake database using command: ${attachCommand}`);
|
|
219341
|
+
try {
|
|
219342
|
+
await connection.runSQL(attachCommand);
|
|
219343
|
+
logger.info(`Successfully attached DuckLake database in READ_ONLY mode: ${dbName}`);
|
|
219344
|
+
} catch (error) {
|
|
219345
|
+
if (error instanceof Error && (error.message.includes("already exists") || error.message.includes("already attached"))) {
|
|
219346
|
+
logger.info(`DuckLake database ${dbName} is already attached, skipping`);
|
|
219347
|
+
} else {
|
|
219348
|
+
throw error;
|
|
219349
|
+
}
|
|
219350
|
+
}
|
|
219351
|
+
}
|
|
219292
219352
|
async function attachCloudStorage(connection, attachedDb) {
|
|
219293
219353
|
const isGCS = attachedDb.type === "gcs";
|
|
219294
219354
|
const isS3 = attachedDb.type === "s3";
|
|
@@ -219374,10 +219434,23 @@ async function attachCloudStorage(connection, attachedDb) {
|
|
|
219374
219434
|
`;
|
|
219375
219435
|
}
|
|
219376
219436
|
}
|
|
219437
|
+
if (await doesSecretExistInDuckDB(connection, secretName)) {
|
|
219438
|
+
await connection.runSQL(`DETACH ${attachedDb.name};`).catch(() => {});
|
|
219439
|
+
}
|
|
219377
219440
|
await connection.runSQL(createSecretCommand);
|
|
219378
219441
|
logger.info(`Created ${storageType} secret: ${secretName}`);
|
|
219379
219442
|
logger.info(`${storageType} connection configured for: ${attachedDb.name}`);
|
|
219380
219443
|
}
|
|
219444
|
+
async function doesSecretExistInDuckDB(connection, secretName) {
|
|
219445
|
+
const escapedSecretName = escapeSQL(secretName);
|
|
219446
|
+
const result = await connection.runSQL(`
|
|
219447
|
+
SELECT COUNT(*) AS count
|
|
219448
|
+
FROM duckdb_secrets()
|
|
219449
|
+
WHERE name = '${escapedSecretName}';
|
|
219450
|
+
`);
|
|
219451
|
+
const rows = result.rows;
|
|
219452
|
+
return Number(rows?.[0]?.count ?? 0) > 0;
|
|
219453
|
+
}
|
|
219381
219454
|
async function attachDatabasesToDuckDB(duckdbConnection, attachedDatabases) {
|
|
219382
219455
|
const attachHandlers = {
|
|
219383
219456
|
bigquery: attachBigQuery,
|
|
@@ -219407,7 +219480,53 @@ async function attachDatabasesToDuckDB(duckdbConnection, attachedDatabases) {
|
|
|
219407
219480
|
}
|
|
219408
219481
|
}
|
|
219409
219482
|
}
|
|
219410
|
-
|
|
219483
|
+
|
|
219484
|
+
class DuckLakeConnection extends import_db_duckdb.DuckDBConnection {
|
|
219485
|
+
connectionName;
|
|
219486
|
+
constructor(connectionName, databasePath, workingDirectory) {
|
|
219487
|
+
super(connectionName, databasePath, workingDirectory);
|
|
219488
|
+
if (!databasePath.endsWith("_ducklake.duckdb")) {
|
|
219489
|
+
throw new Error(`DuckLakeConnection should only be used for DuckLake connections. ` + `Expected database path ending with '_ducklake.duckdb', got: ${databasePath}`);
|
|
219490
|
+
}
|
|
219491
|
+
this.connectionName = connectionName;
|
|
219492
|
+
}
|
|
219493
|
+
async fetchTableSchema(tableKey, tablePath) {
|
|
219494
|
+
const parts = tablePath.split(".");
|
|
219495
|
+
if (!tablePath.startsWith(this.connectionName) && (parts.length === 1 || parts.length === 2)) {
|
|
219496
|
+
const prefixedPath = `${this.connectionName}.${tablePath}`;
|
|
219497
|
+
logger.debug("Prefixing DuckLake table path", {
|
|
219498
|
+
original: tablePath,
|
|
219499
|
+
prefixed: prefixedPath,
|
|
219500
|
+
connectionName: this.connectionName
|
|
219501
|
+
});
|
|
219502
|
+
const result2 = await super.fetchTableSchema(tableKey, prefixedPath);
|
|
219503
|
+
if (!result2) {
|
|
219504
|
+
throw new Error(`Table ${prefixedPath} not found in connection ${this.connectionName}`);
|
|
219505
|
+
}
|
|
219506
|
+
return result2;
|
|
219507
|
+
}
|
|
219508
|
+
const result = await super.fetchTableSchema(tableKey, tablePath);
|
|
219509
|
+
if (!result) {
|
|
219510
|
+
throw new Error(`Table ${tablePath} not found in connection ${this.connectionName}`);
|
|
219511
|
+
}
|
|
219512
|
+
return result;
|
|
219513
|
+
}
|
|
219514
|
+
}
|
|
219515
|
+
async function deleteDuckLakeConnectionFile(connectionName, projectPath) {
|
|
219516
|
+
const ducklakePath = import_path.default.join(projectPath, `${connectionName}_ducklake.duckdb`);
|
|
219517
|
+
try {
|
|
219518
|
+
await import_promises.default.access(ducklakePath);
|
|
219519
|
+
await import_promises.default.rm(ducklakePath);
|
|
219520
|
+
logger.info(`Removed DuckLake connection file ${connectionName}_ducklake.duckdb from ${projectPath}`);
|
|
219521
|
+
} catch (error) {
|
|
219522
|
+
if (error.code === "ENOENT") {
|
|
219523
|
+
logger.debug(`DuckLake connection file ${connectionName}_ducklake.duckdb does not exist, skipping deletion`);
|
|
219524
|
+
} else {
|
|
219525
|
+
logger.error(`Failed to remove DuckLake connection file ${connectionName}_ducklake.duckdb from ${projectPath}`, { error });
|
|
219526
|
+
}
|
|
219527
|
+
}
|
|
219528
|
+
}
|
|
219529
|
+
async function createProjectConnections(connections = [], projectPath = "", isUpdateConnectionRequest = false) {
|
|
219411
219530
|
const connectionMap = new Map;
|
|
219412
219531
|
const processedConnections = new Set;
|
|
219413
219532
|
const apiConnections = [];
|
|
@@ -219577,6 +219696,18 @@ async function createProjectConnections(connections = [], projectPath = "") {
|
|
|
219577
219696
|
connection.attributes = getConnectionAttributes(motherduckConnection);
|
|
219578
219697
|
break;
|
|
219579
219698
|
}
|
|
219699
|
+
case "ducklake": {
|
|
219700
|
+
if (!connection.ducklakeConnection) {
|
|
219701
|
+
throw new Error("DuckLake connection configuration is missing.");
|
|
219702
|
+
}
|
|
219703
|
+
const ducklakeDuckdbConnection = new DuckLakeConnection(connection.name, import_path.default.join(projectPath, `${connection.name}_ducklake.duckdb`), projectPath);
|
|
219704
|
+
if (isUpdateConnectionRequest || !await isDatabaseAttached(ducklakeDuckdbConnection, connection.name)) {
|
|
219705
|
+
await attachDuckLake(ducklakeDuckdbConnection, connection.name, connection.ducklakeConnection);
|
|
219706
|
+
}
|
|
219707
|
+
connectionMap.set(connection.name, ducklakeDuckdbConnection);
|
|
219708
|
+
connection.attributes = getConnectionAttributes(ducklakeDuckdbConnection);
|
|
219709
|
+
break;
|
|
219710
|
+
}
|
|
219580
219711
|
default: {
|
|
219581
219712
|
throw new Error(`Unsupported connection type: ${connection.type}`);
|
|
219582
219713
|
}
|
|
@@ -219656,17 +219787,27 @@ ${failedAttachments.join(`
|
|
|
219656
219787
|
}
|
|
219657
219788
|
}
|
|
219658
219789
|
async function testConnectionConfig(connectionConfig) {
|
|
219790
|
+
let malloyConnections = null;
|
|
219659
219791
|
try {
|
|
219660
219792
|
if (!connectionConfig.name) {
|
|
219661
219793
|
throw new Error("Connection name is required");
|
|
219662
219794
|
}
|
|
219663
|
-
const
|
|
219795
|
+
const result = await createProjectConnections([connectionConfig]);
|
|
219796
|
+
malloyConnections = result.malloyConnections;
|
|
219664
219797
|
const connection = malloyConnections.get(connectionConfig.name);
|
|
219665
219798
|
if (!connection) {
|
|
219666
219799
|
throw new Error(`Failed to create connection: ${connectionConfig.name}`);
|
|
219667
219800
|
}
|
|
219668
219801
|
if (connectionConfig.type === "duckdb") {
|
|
219669
219802
|
await testDuckDBConnection(connection, connectionConfig);
|
|
219803
|
+
} else if (connectionConfig.type === "ducklake") {
|
|
219804
|
+
const duckConn = connection;
|
|
219805
|
+
const attached = await isDatabaseAttached(duckConn, connectionConfig.name);
|
|
219806
|
+
if (!attached) {
|
|
219807
|
+
throw new Error(`DuckLake connection test failed: Error attaching database '${connectionConfig.name}'`);
|
|
219808
|
+
}
|
|
219809
|
+
await duckConn.runSQL(`SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${connectionConfig.name}' LIMIT 1`);
|
|
219810
|
+
logger.info(`DuckLake connection test passed: ${connectionConfig.name}`);
|
|
219670
219811
|
} else {
|
|
219671
219812
|
await connection.test();
|
|
219672
219813
|
}
|
|
@@ -219684,6 +219825,22 @@ async function testConnectionConfig(connectionConfig) {
|
|
|
219684
219825
|
status: "failed",
|
|
219685
219826
|
errorMessage: error.message
|
|
219686
219827
|
};
|
|
219828
|
+
} finally {
|
|
219829
|
+
if (malloyConnections) {
|
|
219830
|
+
for (const [connName, conn] of malloyConnections) {
|
|
219831
|
+
try {
|
|
219832
|
+
if (conn && typeof conn.close === "function") {
|
|
219833
|
+
await conn.close();
|
|
219834
|
+
}
|
|
219835
|
+
} catch (closeError) {
|
|
219836
|
+
logger.warn(`Error closing connection ${connName} during test cleanup`, { error: closeError });
|
|
219837
|
+
} finally {
|
|
219838
|
+
if (connectionConfig.type === "ducklake") {
|
|
219839
|
+
await deleteDuckLakeConnectionFile(connName, process.cwd());
|
|
219840
|
+
}
|
|
219841
|
+
}
|
|
219842
|
+
}
|
|
219843
|
+
}
|
|
219687
219844
|
}
|
|
219688
219845
|
}
|
|
219689
219846
|
|
|
@@ -220246,6 +220403,25 @@ async function getSchemasForConnection(connection, malloyConnection) {
|
|
|
220246
220403
|
console.error(`Error getting schemas for MotherDuck connection ${connection.name}:`, error);
|
|
220247
220404
|
throw new Error(`Failed to get schemas for MotherDuck connection ${connection.name}: ${error.message}`);
|
|
220248
220405
|
}
|
|
220406
|
+
} else if (connection.type === "ducklake") {
|
|
220407
|
+
try {
|
|
220408
|
+
const catalogName = connection.name;
|
|
220409
|
+
const result = await malloyConnection.runSQL(`SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${catalogName}' ORDER BY schema_name`, { rowLimit: 1000 });
|
|
220410
|
+
const rows = standardizeRunSQLResult2(result);
|
|
220411
|
+
return rows.map((row) => {
|
|
220412
|
+
const typedRow = row;
|
|
220413
|
+
const schemaName = typedRow.schema_name;
|
|
220414
|
+
const shouldShow = schemaName === "main" || schemaName === "public";
|
|
220415
|
+
return {
|
|
220416
|
+
name: schemaName,
|
|
220417
|
+
isHidden: !shouldShow,
|
|
220418
|
+
isDefault: false
|
|
220419
|
+
};
|
|
220420
|
+
});
|
|
220421
|
+
} catch (error) {
|
|
220422
|
+
logger.error(`Error getting schemas for DuckLake connection ${connection.name}`, { error });
|
|
220423
|
+
throw new Error(`Failed to get schemas for DuckLake connection ${connection.name}: ${error.message}`);
|
|
220424
|
+
}
|
|
220249
220425
|
} else {
|
|
220250
220426
|
throw new Error(`Unsupported connection type: ${connection.type}`);
|
|
220251
220427
|
}
|
|
@@ -220265,6 +220441,12 @@ async function getTablesForSchema(connection, schemaName, malloyConnection) {
|
|
|
220265
220441
|
}
|
|
220266
220442
|
const fileKeys = await listDataFilesInDirectory(credentials, bucketName, directoryPath);
|
|
220267
220443
|
return await getCloudTablesWithColumns(malloyConnection, credentials, bucketName, fileKeys);
|
|
220444
|
+
} else if (connection.type === "ducklake") {
|
|
220445
|
+
if (schemaName.split(".").length == 2) {
|
|
220446
|
+
schemaName = `${connection.name}.${schemaName}`;
|
|
220447
|
+
} else if (schemaName.split(".").length === 1) {
|
|
220448
|
+
schemaName = `${connection.name}.${schemaName}`;
|
|
220449
|
+
}
|
|
220268
220450
|
}
|
|
220269
220451
|
const tableNames = await listTablesForSchema(connection, schemaName, malloyConnection);
|
|
220270
220452
|
const tableSourcePromises = tableNames.map(async (tableName) => {
|
|
@@ -220276,6 +220458,8 @@ async function getTablesForSchema(connection, schemaName, malloyConnection) {
|
|
|
220276
220458
|
} else {
|
|
220277
220459
|
tablePath = `${schemaName}.${tableName}`;
|
|
220278
220460
|
}
|
|
220461
|
+
} else if (connection.type === "ducklake") {
|
|
220462
|
+
tablePath = `${schemaName}.${tableName}`;
|
|
220279
220463
|
} else {
|
|
220280
220464
|
tablePath = `${schemaName}.${tableName}`;
|
|
220281
220465
|
}
|
|
@@ -220287,7 +220471,7 @@ async function getTablesForSchema(connection, schemaName, malloyConnection) {
|
|
|
220287
220471
|
};
|
|
220288
220472
|
} catch (error) {
|
|
220289
220473
|
logger.warn(`Failed to get schema for table ${tableName}`, {
|
|
220290
|
-
error,
|
|
220474
|
+
error: extractErrorDataFromError(error),
|
|
220291
220475
|
schemaName,
|
|
220292
220476
|
tableName
|
|
220293
220477
|
});
|
|
@@ -220476,10 +220660,39 @@ async function listTablesForSchema(connection, schemaName, malloyConnection) {
|
|
|
220476
220660
|
logger.error(`Error getting tables for MotherDuck schema ${schemaName} in connection ${connection.name}`, { error });
|
|
220477
220661
|
throw new Error(`Failed to get tables for MotherDuck schema ${schemaName} in connection ${connection.name}: ${error.message}`);
|
|
220478
220662
|
}
|
|
220663
|
+
} else if (connection.type === "ducklake") {
|
|
220664
|
+
const catalogName = schemaName.split(".")[0];
|
|
220665
|
+
const actualSchemaName = schemaName.split(".")[1];
|
|
220666
|
+
console.error("catalogName", catalogName);
|
|
220667
|
+
console.error("actualSchemaName", actualSchemaName);
|
|
220668
|
+
try {
|
|
220669
|
+
const result = await malloyConnection.runSQL(`SELECT table_name FROM information_schema.tables WHERE table_schema = '${actualSchemaName}' AND table_catalog = '${catalogName}' ORDER BY table_name`, { rowLimit: 1000 });
|
|
220670
|
+
const rows = standardizeRunSQLResult2(result);
|
|
220671
|
+
return rows.map((row) => {
|
|
220672
|
+
const typedRow = row;
|
|
220673
|
+
return typedRow.table_name;
|
|
220674
|
+
});
|
|
220675
|
+
} catch (error) {
|
|
220676
|
+
logger.error(`Error getting tables for DuckLake schema ${schemaName} in connection ${connection.name}`, { error });
|
|
220677
|
+
throw new Error(`Failed to get tables for DuckLake schema ${schemaName} in connection ${connection.name}: ${error.message}`);
|
|
220678
|
+
}
|
|
220479
220679
|
} else {
|
|
220480
220680
|
throw new Error(`Unsupported connection type: ${connection.type}`);
|
|
220481
220681
|
}
|
|
220482
220682
|
}
|
|
220683
|
+
function extractErrorDataFromError(error) {
|
|
220684
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
220685
|
+
const errorData = {
|
|
220686
|
+
error: errorMessage
|
|
220687
|
+
};
|
|
220688
|
+
if (error instanceof Error && logger.level === "debug") {
|
|
220689
|
+
errorData.stack = error.stack;
|
|
220690
|
+
}
|
|
220691
|
+
if (error && typeof error === "object" && "task" in error) {
|
|
220692
|
+
errorData.task = error.task;
|
|
220693
|
+
}
|
|
220694
|
+
return errorData;
|
|
220695
|
+
}
|
|
220483
220696
|
|
|
220484
220697
|
// src/controller/connection.controller.ts
|
|
220485
220698
|
class ConnectionController {
|
|
@@ -220547,9 +220760,21 @@ class ConnectionController {
|
|
|
220547
220760
|
const malloyConnection = await this.getMalloyConnection(projectName, connectionName);
|
|
220548
220761
|
return getConnectionTableSource(malloyConnection, tableKey, tablePath);
|
|
220549
220762
|
}
|
|
220550
|
-
async getTable(projectName, connectionName,
|
|
220763
|
+
async getTable(projectName, connectionName, schemaName, tablePath) {
|
|
220551
220764
|
const malloyConnection = await this.getMalloyConnection(projectName, connectionName);
|
|
220552
|
-
const
|
|
220765
|
+
const connection = await this.getConnection(projectName, connectionName);
|
|
220766
|
+
if (connection.type === "ducklake") {
|
|
220767
|
+
if (tablePath.split(".").length === 1) {
|
|
220768
|
+
tablePath = `${connectionName}.${schemaName}.${tablePath}`;
|
|
220769
|
+
} else if (tablePath.split(".").length === 2 && !tablePath.startsWith(connectionName)) {
|
|
220770
|
+
tablePath = `${connectionName}.${tablePath}`;
|
|
220771
|
+
}
|
|
220772
|
+
}
|
|
220773
|
+
const tableKey = tablePath.split(".").pop();
|
|
220774
|
+
if (!tableKey) {
|
|
220775
|
+
throw new Error(`Invalid tablePath: ${tablePath}`);
|
|
220776
|
+
}
|
|
220777
|
+
const tableSource = await getConnectionTableSource(malloyConnection, tableKey, tablePath);
|
|
220553
220778
|
return {
|
|
220554
220779
|
resource: tablePath,
|
|
220555
220780
|
columns: tableSource.columns,
|
|
@@ -228339,7 +228564,8 @@ class Project {
|
|
|
228339
228564
|
}
|
|
228340
228565
|
if (payload.connections) {
|
|
228341
228566
|
logger.info(`Updating ${payload.connections.length} connections for project ${this.projectName}`);
|
|
228342
|
-
const
|
|
228567
|
+
const isUpdateConnectionRequest = true;
|
|
228568
|
+
const { malloyConnections, apiConnections } = await createProjectConnections(payload.connections, this.projectPath, isUpdateConnectionRequest);
|
|
228343
228569
|
this.malloyConnections = malloyConnections;
|
|
228344
228570
|
this.apiConnections = apiConnections;
|
|
228345
228571
|
logger.info(`Successfully updated connections for project ${this.projectName}`, {
|
|
@@ -228609,8 +228835,11 @@ ${source}` : source;
|
|
|
228609
228835
|
this.malloyConnections.get(connectionName)?.close();
|
|
228610
228836
|
const isDeleted = this.malloyConnections.delete(connectionName);
|
|
228611
228837
|
const index = this.apiConnections.findIndex((conn) => conn.name === connectionName);
|
|
228612
|
-
|
|
228838
|
+
const connectionType = this.apiConnections[index]?.type;
|
|
228839
|
+
if (connectionType === "duckdb") {
|
|
228613
228840
|
await this.deleteDuckDBConnection(connectionName);
|
|
228841
|
+
} else if (connectionType === "ducklake") {
|
|
228842
|
+
await this.deleteDuckLakeConnection(connectionName);
|
|
228614
228843
|
}
|
|
228615
228844
|
if (index !== -1) {
|
|
228616
228845
|
this.apiConnections.splice(index, 1);
|
|
@@ -228653,6 +228882,10 @@ ${source}` : source;
|
|
|
228653
228882
|
logger.error(`Failed to remove DuckDB connection file ${connectionName} from project ${this.projectName}`, { error });
|
|
228654
228883
|
});
|
|
228655
228884
|
}
|
|
228885
|
+
async deleteDuckLakeConnection(connectionName) {
|
|
228886
|
+
await deleteDuckLakeConnectionFile(connectionName, this.projectPath);
|
|
228887
|
+
logger.info(`Removed DuckLake connection ${connectionName} from project ${this.projectName}`);
|
|
228888
|
+
}
|
|
228656
228889
|
}
|
|
228657
228890
|
async function extractPreamble(modelPath) {
|
|
228658
228891
|
try {
|
|
@@ -233541,6 +233774,7 @@ var databaseController = new DatabaseController(projectStore);
|
|
|
233541
233774
|
var queryController = new QueryController(projectStore);
|
|
233542
233775
|
var compileController = new CompileController(projectStore);
|
|
233543
233776
|
var mcpApp = import_express.default();
|
|
233777
|
+
registerHealthEndpoints(mcpApp);
|
|
233544
233778
|
mcpApp.use(MCP_ENDPOINT, import_express.default.json());
|
|
233545
233779
|
mcpApp.use(MCP_ENDPOINT, import_cors.default());
|
|
233546
233780
|
mcpApp.all(MCP_ENDPOINT, async (req, res) => {
|
package/package.json
CHANGED
|
@@ -155,17 +155,37 @@ export class ConnectionController {
|
|
|
155
155
|
public async getTable(
|
|
156
156
|
projectName: string,
|
|
157
157
|
connectionName: string,
|
|
158
|
-
|
|
158
|
+
schemaName: string,
|
|
159
159
|
tablePath: string,
|
|
160
160
|
): Promise<ApiTable> {
|
|
161
161
|
const malloyConnection = await this.getMalloyConnection(
|
|
162
162
|
projectName,
|
|
163
163
|
connectionName,
|
|
164
164
|
);
|
|
165
|
+
const connection = await this.getConnection(projectName, connectionName);
|
|
166
|
+
|
|
167
|
+
if (connection.type === "ducklake") {
|
|
168
|
+
if (tablePath.split(".").length === 1) {
|
|
169
|
+
// tablePath is just the table name, construct full path
|
|
170
|
+
tablePath = `${connectionName}.${schemaName}.${tablePath}`;
|
|
171
|
+
} else if (
|
|
172
|
+
tablePath.split(".").length === 2 &&
|
|
173
|
+
!tablePath.startsWith(connectionName)
|
|
174
|
+
) {
|
|
175
|
+
// tablePath is schemaName.tableName but missing connection prefix
|
|
176
|
+
tablePath = `${connectionName}.${tablePath}`;
|
|
177
|
+
}
|
|
178
|
+
// If tablePath already has 3+ parts or starts with connection name, use as-is
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const tableKey = tablePath.split(".").pop();
|
|
182
|
+
if (!tableKey) {
|
|
183
|
+
throw new Error(`Invalid tablePath: ${tablePath}`);
|
|
184
|
+
}
|
|
165
185
|
|
|
166
186
|
const tableSource = await getConnectionTableSource(
|
|
167
187
|
malloyConnection,
|
|
168
|
-
|
|
188
|
+
tableKey, // tableKey is the table name
|
|
169
189
|
tablePath,
|
|
170
190
|
);
|
|
171
191
|
|
package/src/server.ts
CHANGED
|
@@ -131,6 +131,9 @@ const compileController = new CompileController(projectStore);
|
|
|
131
131
|
|
|
132
132
|
export const mcpApp = express();
|
|
133
133
|
|
|
134
|
+
// Register health endpoints on mcpApp (for E2E tests)
|
|
135
|
+
registerHealthEndpoints(mcpApp);
|
|
136
|
+
|
|
134
137
|
mcpApp.use(MCP_ENDPOINT, express.json());
|
|
135
138
|
mcpApp.use(MCP_ENDPOINT, cors());
|
|
136
139
|
|
|
@@ -253,7 +256,8 @@ app.use(
|
|
|
253
256
|
);
|
|
254
257
|
app.use(bodyParser.json());
|
|
255
258
|
|
|
256
|
-
// Register health check endpoints
|
|
259
|
+
// Register health check endpoints on main app:
|
|
260
|
+
// - Required for production/Kubernetes monitoring (main server on PUBLISHER_PORT)
|
|
257
261
|
registerHealthEndpoints(app);
|
|
258
262
|
|
|
259
263
|
// Register Prometheus metrics endpoint
|
|
@@ -975,6 +975,111 @@ describe("connection integration tests", () => {
|
|
|
975
975
|
});
|
|
976
976
|
|
|
977
977
|
describe("error handling", () => {
|
|
978
|
+
describe("DuckLake connection type", () => {
|
|
979
|
+
it(
|
|
980
|
+
"should create DuckLake connection",
|
|
981
|
+
async () => {
|
|
982
|
+
if (!hasPostgresCredentials() || !hasS3Credentials()) {
|
|
983
|
+
console.log(
|
|
984
|
+
"Skipping: PostgreSQL and S3 credentials not configured",
|
|
985
|
+
);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const { malloyConnections } = await createProjectConnections(
|
|
990
|
+
[
|
|
991
|
+
{
|
|
992
|
+
name: "ducklake_test",
|
|
993
|
+
type: "ducklake",
|
|
994
|
+
ducklakeConnection: {
|
|
995
|
+
catalog: {
|
|
996
|
+
postgresConnection: {
|
|
997
|
+
host: process.env.POSTGRES_TEST_HOST,
|
|
998
|
+
port: parseInt(
|
|
999
|
+
process.env.POSTGRES_TEST_PORT || "5432",
|
|
1000
|
+
),
|
|
1001
|
+
userName: process.env.POSTGRES_TEST_USER!,
|
|
1002
|
+
password:
|
|
1003
|
+
process.env.POSTGRES_TEST_PASSWORD!,
|
|
1004
|
+
databaseName:
|
|
1005
|
+
process.env.POSTGRES_TEST_DATABASE,
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
1008
|
+
storage: {
|
|
1009
|
+
bucketUrl:
|
|
1010
|
+
process.env.S3_TEST_BUCKET_URL ||
|
|
1011
|
+
"s3://test-bucket",
|
|
1012
|
+
s3Connection: {
|
|
1013
|
+
accessKeyId:
|
|
1014
|
+
process.env.S3_TEST_ACCESS_KEY_ID!,
|
|
1015
|
+
secretAccessKey:
|
|
1016
|
+
process.env.S3_TEST_SECRET_ACCESS_KEY!,
|
|
1017
|
+
},
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
],
|
|
1022
|
+
testProjectPath,
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
const connection = malloyConnections.get(
|
|
1026
|
+
"ducklake_test",
|
|
1027
|
+
) as DuckDBConnection;
|
|
1028
|
+
createdConnections.push(connection);
|
|
1029
|
+
expect(connection).toBeDefined();
|
|
1030
|
+
|
|
1031
|
+
// Verify DuckLake database is attached
|
|
1032
|
+
const databases = await connection.runSQL("SHOW DATABASES");
|
|
1033
|
+
const dbNames = databases.rows.map(
|
|
1034
|
+
(row) => Object.values(row)[0],
|
|
1035
|
+
);
|
|
1036
|
+
expect(dbNames).toContain("ducklake_test");
|
|
1037
|
+
},
|
|
1038
|
+
{ timeout: 30000 },
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
it("should throw error if DuckLake catalog connection is missing", async () => {
|
|
1042
|
+
await expect(
|
|
1043
|
+
createProjectConnections(
|
|
1044
|
+
[
|
|
1045
|
+
{
|
|
1046
|
+
name: "ducklake_no_catalog",
|
|
1047
|
+
type: "ducklake",
|
|
1048
|
+
ducklakeConnection: {
|
|
1049
|
+
storage: {
|
|
1050
|
+
bucketUrl: "s3://test-bucket",
|
|
1051
|
+
s3Connection: {
|
|
1052
|
+
accessKeyId: "test",
|
|
1053
|
+
secretAccessKey: "test",
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
},
|
|
1057
|
+
} as ApiConnection,
|
|
1058
|
+
],
|
|
1059
|
+
testProjectPath,
|
|
1060
|
+
),
|
|
1061
|
+
).rejects.toThrow(
|
|
1062
|
+
/PostgreSQL connection configuration is required/,
|
|
1063
|
+
);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
it("should throw error if DuckLake connection config is missing", async () => {
|
|
1067
|
+
await expect(
|
|
1068
|
+
createProjectConnections(
|
|
1069
|
+
[
|
|
1070
|
+
{
|
|
1071
|
+
name: "ducklake_missing_config",
|
|
1072
|
+
type: "ducklake",
|
|
1073
|
+
},
|
|
1074
|
+
],
|
|
1075
|
+
testProjectPath,
|
|
1076
|
+
),
|
|
1077
|
+
).rejects.toThrow(
|
|
1078
|
+
/DuckLake connection configuration is missing/,
|
|
1079
|
+
);
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
|
|
978
1083
|
it("should throw error if DuckDB connection name conflicts with attached database", async () => {
|
|
979
1084
|
await expect(
|
|
980
1085
|
createProjectConnections(
|