@malloy-publisher/server 0.0.167 → 0.0.169
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 +74 -1
- package/dist/app/assets/HomePage-BWcnTdSg.js +1 -0
- package/dist/app/assets/{MainPage-C9Fr5IN8.js → MainPage-k-BDUTT_.js} +2 -2
- package/dist/app/assets/{ModelPage-BkU6HAHA.js → ModelPage-BbFrnQ1A.js} +1 -1
- package/dist/app/assets/PackagePage-CCwd7u2-.js +1 -0
- package/dist/app/assets/ProjectPage-CDHkneyO.js +1 -0
- package/dist/app/assets/RouteError-BZbAeXla.js +1 -0
- package/dist/app/assets/{WorkbookPage-D3rUQZj6.js → WorkbookPage-BcuqksYi.js} +1 -1
- package/dist/app/assets/{index-lhDwptrQ.js → index-BDS2El9V.js} +216 -216
- package/dist/app/assets/{index-BLxl0XLH.js → index-C-0P3N7Y.js} +150 -150
- package/dist/app/assets/index-EHh3Tsle.js +1237 -0
- package/dist/app/assets/{index.umd-BkXQ-YAe.js → index.umd-ClIgLTxW.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.js +2252 -964
- package/dist/server.js +2802 -1140
- package/package.json +10 -10
- package/src/controller/connection.controller.ts +22 -2
- package/src/controller/query.controller.ts +7 -0
- package/src/mcp/tools/execute_query_tool.ts +57 -29
- 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/model.ts +11 -8
- 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/assets/index-hkABoiMV.js +0 -1259
|
@@ -4,7 +4,7 @@ import { MySQLConnection } from "@malloydata/db-mysql";
|
|
|
4
4
|
import { PostgresConnection } from "@malloydata/db-postgres";
|
|
5
5
|
import { SnowflakeConnection } from "@malloydata/db-snowflake";
|
|
6
6
|
import { TrinoConnection } from "@malloydata/db-trino";
|
|
7
|
-
import { Connection } from "@malloydata/malloy";
|
|
7
|
+
import { Connection, TableSourceDef } from "@malloydata/malloy";
|
|
8
8
|
import { BaseConnection } from "@malloydata/malloy/connection";
|
|
9
9
|
import { AxiosError } from "axios";
|
|
10
10
|
import fs from "fs/promises";
|
|
@@ -29,6 +29,7 @@ export type InternalConnection = ApiConnection & {
|
|
|
29
29
|
trinoConnection?: components["schemas"]["TrinoConnection"];
|
|
30
30
|
mysqlConnection?: components["schemas"]["MysqlConnection"];
|
|
31
31
|
duckdbConnection?: components["schemas"]["DuckdbConnection"];
|
|
32
|
+
ducklakeConnection?: components["schemas"]["DucklakeConnection"];
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
function validateAndBuildTrinoConfig(
|
|
@@ -345,6 +346,40 @@ async function attachSnowflake(
|
|
|
345
346
|
logger.info(`Successfully attached Snowflake database: ${attachedDb.name}`);
|
|
346
347
|
}
|
|
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
|
+
|
|
348
383
|
async function attachPostgres(
|
|
349
384
|
connection: DuckDBConnection,
|
|
350
385
|
attachedDb: AttachedDatabase,
|
|
@@ -358,26 +393,82 @@ async function attachPostgres(
|
|
|
358
393
|
await installAndLoadExtension(connection, "postgres");
|
|
359
394
|
|
|
360
395
|
const config = attachedDb.postgresConnection;
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (config.connectionString) {
|
|
364
|
-
attachString = config.connectionString;
|
|
365
|
-
} else {
|
|
366
|
-
const parts: string[] = [];
|
|
367
|
-
if (config.host) parts.push(`host=${config.host}`);
|
|
368
|
-
if (config.port) parts.push(`port=${config.port}`);
|
|
369
|
-
if (config.databaseName) parts.push(`dbname=${config.databaseName}`);
|
|
370
|
-
if (config.userName) parts.push(`user=${config.userName}`);
|
|
371
|
-
if (config.password) parts.push(`password=${config.password}`);
|
|
372
|
-
if (process.env.PGSSLMODE === "no-verify") parts.push(`sslmode=disable`);
|
|
373
|
-
attachString = parts.join(" ");
|
|
374
|
-
}
|
|
396
|
+
const attachString: string = buildPgConnectionString(config);
|
|
375
397
|
|
|
376
|
-
const attachCommand = `ATTACH '${attachString}' AS ${attachedDb.name} (TYPE postgres, READ_ONLY);`;
|
|
398
|
+
const attachCommand = `ATTACH '${escapeSQL(attachString)}' AS ${attachedDb.name} (TYPE postgres, READ_ONLY);`;
|
|
377
399
|
await connection.runSQL(attachCommand);
|
|
378
400
|
logger.info(`Successfully attached PostgreSQL database: ${attachedDb.name}`);
|
|
379
401
|
}
|
|
380
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
|
+
|
|
381
472
|
async function attachCloudStorage(
|
|
382
473
|
connection: DuckDBConnection,
|
|
383
474
|
attachedDb: AttachedDatabase,
|
|
@@ -488,11 +579,30 @@ async function attachCloudStorage(
|
|
|
488
579
|
}
|
|
489
580
|
}
|
|
490
581
|
|
|
582
|
+
if (await doesSecretExistInDuckDB(connection, secretName)) {
|
|
583
|
+
// Force refresh attachments using this storage
|
|
584
|
+
await connection.runSQL(`DETACH ${attachedDb.name};`).catch(() => {});
|
|
585
|
+
}
|
|
491
586
|
await connection.runSQL(createSecretCommand);
|
|
587
|
+
|
|
492
588
|
logger.info(`Created ${storageType} secret: ${secretName}`);
|
|
493
589
|
logger.info(`${storageType} connection configured for: ${attachedDb.name}`);
|
|
494
590
|
}
|
|
495
591
|
|
|
592
|
+
async function doesSecretExistInDuckDB(
|
|
593
|
+
connection: DuckDBConnection,
|
|
594
|
+
secretName: string,
|
|
595
|
+
): Promise<boolean> {
|
|
596
|
+
const escapedSecretName = escapeSQL(secretName);
|
|
597
|
+
const result = await connection.runSQL(`
|
|
598
|
+
SELECT COUNT(*) AS count
|
|
599
|
+
FROM duckdb_secrets()
|
|
600
|
+
WHERE name = '${escapedSecretName}';
|
|
601
|
+
`);
|
|
602
|
+
const rows = result.rows;
|
|
603
|
+
return Number(rows?.[0]?.count ?? 0) > 0;
|
|
604
|
+
}
|
|
605
|
+
|
|
496
606
|
// Main attachment function
|
|
497
607
|
async function attachDatabasesToDuckDB(
|
|
498
608
|
duckdbConnection: DuckDBConnection,
|
|
@@ -540,9 +650,95 @@ async function attachDatabasesToDuckDB(
|
|
|
540
650
|
}
|
|
541
651
|
}
|
|
542
652
|
|
|
653
|
+
class DuckLakeConnection extends DuckDBConnection {
|
|
654
|
+
private connectionName: string;
|
|
655
|
+
|
|
656
|
+
constructor(
|
|
657
|
+
connectionName: string,
|
|
658
|
+
databasePath: string,
|
|
659
|
+
workingDirectory: string,
|
|
660
|
+
) {
|
|
661
|
+
super(connectionName, databasePath, workingDirectory);
|
|
662
|
+
|
|
663
|
+
// Validate that this is a DuckLake connection by checking the database path pattern
|
|
664
|
+
if (!databasePath.endsWith("_ducklake.duckdb")) {
|
|
665
|
+
throw new Error(
|
|
666
|
+
`DuckLakeConnection should only be used for DuckLake connections. ` +
|
|
667
|
+
`Expected database path ending with '_ducklake.duckdb', got: ${databasePath}`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
this.connectionName = connectionName;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async fetchTableSchema(
|
|
675
|
+
tableKey: string,
|
|
676
|
+
tablePath: string,
|
|
677
|
+
): Promise<TableSourceDef> {
|
|
678
|
+
// DuckLake-specific logic: prefix table path with connection name if needed
|
|
679
|
+
const parts = tablePath.split(".");
|
|
680
|
+
if (
|
|
681
|
+
!tablePath.startsWith(this.connectionName) &&
|
|
682
|
+
(parts.length === 1 || parts.length === 2)
|
|
683
|
+
) {
|
|
684
|
+
const prefixedPath = `${this.connectionName}.${tablePath}`;
|
|
685
|
+
logger.debug("Prefixing DuckLake table path", {
|
|
686
|
+
original: tablePath,
|
|
687
|
+
prefixed: prefixedPath,
|
|
688
|
+
connectionName: this.connectionName,
|
|
689
|
+
});
|
|
690
|
+
const result = await super.fetchTableSchema(tableKey, prefixedPath);
|
|
691
|
+
if (!result) {
|
|
692
|
+
throw new Error(
|
|
693
|
+
`Table ${prefixedPath} not found in connection ${this.connectionName}`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
return result;
|
|
697
|
+
}
|
|
698
|
+
// If already prefixed or has 3+ parts, use as-is;
|
|
699
|
+
// For attached databases, in the future
|
|
700
|
+
const result = await super.fetchTableSchema(tableKey, tablePath);
|
|
701
|
+
if (!result) {
|
|
702
|
+
throw new Error(
|
|
703
|
+
`Table ${tablePath} not found in connection ${this.connectionName}`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
return result;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export async function deleteDuckLakeConnectionFile(
|
|
711
|
+
connectionName: string,
|
|
712
|
+
projectPath: string,
|
|
713
|
+
): Promise<void> {
|
|
714
|
+
const ducklakePath = path.join(
|
|
715
|
+
projectPath,
|
|
716
|
+
`${connectionName}_ducklake.duckdb`,
|
|
717
|
+
);
|
|
718
|
+
try {
|
|
719
|
+
await fs.access(ducklakePath);
|
|
720
|
+
await fs.rm(ducklakePath);
|
|
721
|
+
logger.info(
|
|
722
|
+
`Removed DuckLake connection file ${connectionName}_ducklake.duckdb from ${projectPath}`,
|
|
723
|
+
);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
726
|
+
logger.debug(
|
|
727
|
+
`DuckLake connection file ${connectionName}_ducklake.duckdb does not exist, skipping deletion`,
|
|
728
|
+
);
|
|
729
|
+
} else {
|
|
730
|
+
logger.error(
|
|
731
|
+
`Failed to remove DuckLake connection file ${connectionName}_ducklake.duckdb from ${projectPath}`,
|
|
732
|
+
{ error },
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
543
738
|
export async function createProjectConnections(
|
|
544
739
|
connections: ApiConnection[] = [],
|
|
545
740
|
projectPath: string = "",
|
|
741
|
+
isUpdateConnectionRequest: boolean = false,
|
|
546
742
|
): Promise<{
|
|
547
743
|
malloyConnections: Map<string, BaseConnection>;
|
|
548
744
|
apiConnections: InternalConnection[];
|
|
@@ -826,6 +1022,40 @@ export async function createProjectConnections(
|
|
|
826
1022
|
break;
|
|
827
1023
|
}
|
|
828
1024
|
|
|
1025
|
+
case "ducklake": {
|
|
1026
|
+
if (!connection.ducklakeConnection) {
|
|
1027
|
+
throw new Error("DuckLake connection configuration is missing.");
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Creating one Connection per DuckLake connection to avoid conflicts with other connections and better isolation.
|
|
1031
|
+
const ducklakeDuckdbConnection = new DuckLakeConnection(
|
|
1032
|
+
connection.name,
|
|
1033
|
+
path.join(projectPath, `${connection.name}_ducklake.duckdb`),
|
|
1034
|
+
projectPath,
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
// Only attach DuckLake if it's not already attached or is it an update connection request
|
|
1038
|
+
if (
|
|
1039
|
+
isUpdateConnectionRequest ||
|
|
1040
|
+
!(await isDatabaseAttached(
|
|
1041
|
+
ducklakeDuckdbConnection,
|
|
1042
|
+
connection.name,
|
|
1043
|
+
))
|
|
1044
|
+
) {
|
|
1045
|
+
await attachDuckLake(
|
|
1046
|
+
ducklakeDuckdbConnection,
|
|
1047
|
+
connection.name,
|
|
1048
|
+
connection.ducklakeConnection,
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
connectionMap.set(connection.name, ducklakeDuckdbConnection);
|
|
1053
|
+
connection.attributes = getConnectionAttributes(
|
|
1054
|
+
ducklakeDuckdbConnection,
|
|
1055
|
+
);
|
|
1056
|
+
break;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
829
1059
|
default: {
|
|
830
1060
|
throw new Error(`Unsupported connection type: ${connection.type}`);
|
|
831
1061
|
}
|
|
@@ -958,6 +1188,7 @@ async function testDuckDBConnection(
|
|
|
958
1188
|
export async function testConnectionConfig(
|
|
959
1189
|
connectionConfig: ApiConnection,
|
|
960
1190
|
): Promise<ApiConnectionStatus> {
|
|
1191
|
+
let malloyConnections: Map<string, BaseConnection> | null = null;
|
|
961
1192
|
try {
|
|
962
1193
|
// Validate that connection name is provided
|
|
963
1194
|
if (!connectionConfig.name) {
|
|
@@ -965,9 +1196,10 @@ export async function testConnectionConfig(
|
|
|
965
1196
|
}
|
|
966
1197
|
|
|
967
1198
|
// Use createProjectConnections to create the connection, then test it
|
|
968
|
-
const
|
|
1199
|
+
const result = await createProjectConnections(
|
|
969
1200
|
[connectionConfig], // Pass the single connection config
|
|
970
1201
|
);
|
|
1202
|
+
malloyConnections = result.malloyConnections;
|
|
971
1203
|
|
|
972
1204
|
// Get the created connection
|
|
973
1205
|
const connection = malloyConnections.get(connectionConfig.name);
|
|
@@ -983,6 +1215,25 @@ export async function testConnectionConfig(
|
|
|
983
1215
|
connection as DuckDBConnection,
|
|
984
1216
|
connectionConfig as InternalConnection,
|
|
985
1217
|
);
|
|
1218
|
+
} else if (connectionConfig.type === "ducklake") {
|
|
1219
|
+
// DuckLake uses DuckDB internally — verify the database is attached
|
|
1220
|
+
const duckConn = connection as DuckDBConnection;
|
|
1221
|
+
const attached = await isDatabaseAttached(
|
|
1222
|
+
duckConn,
|
|
1223
|
+
connectionConfig.name as string,
|
|
1224
|
+
);
|
|
1225
|
+
if (!attached) {
|
|
1226
|
+
throw new Error(
|
|
1227
|
+
`DuckLake connection test failed: Error attaching database '${connectionConfig.name}'`,
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
await duckConn.runSQL(
|
|
1231
|
+
`SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${connectionConfig.name}' LIMIT 1`,
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
logger.info(
|
|
1235
|
+
`DuckLake connection test passed: ${connectionConfig.name}`,
|
|
1236
|
+
);
|
|
986
1237
|
} else {
|
|
987
1238
|
// Test other connection types using their test() method
|
|
988
1239
|
await (
|
|
@@ -1010,5 +1261,30 @@ export async function testConnectionConfig(
|
|
|
1010
1261
|
status: "failed",
|
|
1011
1262
|
errorMessage: (error as Error).message,
|
|
1012
1263
|
};
|
|
1264
|
+
} finally {
|
|
1265
|
+
// Cleanup: close all connections and remove ducklake files created during testing
|
|
1266
|
+
if (malloyConnections) {
|
|
1267
|
+
for (const [connName, conn] of malloyConnections) {
|
|
1268
|
+
try {
|
|
1269
|
+
// Close the connection
|
|
1270
|
+
if (
|
|
1271
|
+
conn &&
|
|
1272
|
+
typeof (conn as DuckDBConnection).close === "function"
|
|
1273
|
+
) {
|
|
1274
|
+
await (conn as DuckDBConnection).close();
|
|
1275
|
+
}
|
|
1276
|
+
} catch (closeError) {
|
|
1277
|
+
logger.warn(
|
|
1278
|
+
`Error closing connection ${connName} during test cleanup`,
|
|
1279
|
+
{ error: closeError },
|
|
1280
|
+
);
|
|
1281
|
+
} finally {
|
|
1282
|
+
// Remove ducklake files created during testing (only for ducklake connections)
|
|
1283
|
+
if (connectionConfig.type === "ducklake") {
|
|
1284
|
+
await deleteDuckLakeConnectionFile(connName, process.cwd());
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1013
1289
|
}
|
|
1014
1290
|
}
|
package/src/service/db_utils.ts
CHANGED
|
@@ -397,6 +397,38 @@ export async function getSchemasForConnection(
|
|
|
397
397
|
`Failed to get schemas for MotherDuck connection ${connection.name}: ${(error as Error).message}`,
|
|
398
398
|
);
|
|
399
399
|
}
|
|
400
|
+
} else if (connection.type === "ducklake") {
|
|
401
|
+
try {
|
|
402
|
+
// Filter by catalog_name to only get schemas from the attached DuckLake catalog
|
|
403
|
+
// The catalog is attached with the connection name (see attachDuckLake in connection.ts)
|
|
404
|
+
const catalogName = connection.name;
|
|
405
|
+
const result = await malloyConnection.runSQL(
|
|
406
|
+
`SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${catalogName}' ORDER BY schema_name`,
|
|
407
|
+
{ rowLimit: 1000 },
|
|
408
|
+
);
|
|
409
|
+
const rows = standardizeRunSQLResult(result);
|
|
410
|
+
|
|
411
|
+
return rows.map((row: unknown) => {
|
|
412
|
+
const typedRow = row as Record<string, unknown>;
|
|
413
|
+
const schemaName = typedRow.schema_name as string;
|
|
414
|
+
|
|
415
|
+
const shouldShow = schemaName === "main" || schemaName === "public";
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
name: schemaName,
|
|
419
|
+
isHidden: !shouldShow,
|
|
420
|
+
isDefault: false,
|
|
421
|
+
};
|
|
422
|
+
});
|
|
423
|
+
} catch (error) {
|
|
424
|
+
logger.error(
|
|
425
|
+
`Error getting schemas for DuckLake connection ${connection.name}`,
|
|
426
|
+
{ error },
|
|
427
|
+
);
|
|
428
|
+
throw new Error(
|
|
429
|
+
`Failed to get schemas for DuckLake connection ${connection.name}: ${(error as Error).message}`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
400
432
|
} else {
|
|
401
433
|
throw new Error(`Unsupported connection type: ${connection.type}`);
|
|
402
434
|
}
|
|
@@ -442,8 +474,13 @@ export async function getTablesForSchema(
|
|
|
442
474
|
bucketName,
|
|
443
475
|
fileKeys,
|
|
444
476
|
);
|
|
477
|
+
} else if (connection.type === "ducklake") {
|
|
478
|
+
if (schemaName.split(".").length == 2) {
|
|
479
|
+
schemaName = `${connection.name}.${schemaName}`;
|
|
480
|
+
} else if (schemaName.split(".").length === 1) {
|
|
481
|
+
schemaName = `${connection.name}.${schemaName}`;
|
|
482
|
+
}
|
|
445
483
|
}
|
|
446
|
-
|
|
447
484
|
const tableNames = await listTablesForSchema(
|
|
448
485
|
connection,
|
|
449
486
|
schemaName,
|
|
@@ -454,7 +491,6 @@ export async function getTablesForSchema(
|
|
|
454
491
|
const tableSourcePromises = tableNames.map(async (tableName) => {
|
|
455
492
|
try {
|
|
456
493
|
let tablePath: string;
|
|
457
|
-
|
|
458
494
|
if (connection.type === "trino") {
|
|
459
495
|
if (connection.trinoConnection?.catalog) {
|
|
460
496
|
tablePath = `${connection.trinoConnection?.catalog}.${schemaName}.${tableName}`;
|
|
@@ -462,6 +498,10 @@ export async function getTablesForSchema(
|
|
|
462
498
|
// Catalog name is included in the schema name
|
|
463
499
|
tablePath = `${schemaName}.${tableName}`;
|
|
464
500
|
}
|
|
501
|
+
} else if (connection.type === "ducklake") {
|
|
502
|
+
// For ducklake, schemaName already includes connection name prefix from above
|
|
503
|
+
// So tablePath should be schemaName.tableName (which is connectionName.schemaName.tableName)
|
|
504
|
+
tablePath = `${schemaName}.${tableName}`;
|
|
465
505
|
} else {
|
|
466
506
|
tablePath = `${schemaName}.${tableName}`;
|
|
467
507
|
}
|
|
@@ -475,14 +515,13 @@ export async function getTablesForSchema(
|
|
|
475
515
|
tableName,
|
|
476
516
|
tablePath,
|
|
477
517
|
);
|
|
478
|
-
|
|
479
518
|
return {
|
|
480
519
|
resource: tablePath,
|
|
481
520
|
columns: tableSource.columns,
|
|
482
521
|
};
|
|
483
522
|
} catch (error) {
|
|
484
523
|
logger.warn(`Failed to get schema for table ${tableName}`, {
|
|
485
|
-
error,
|
|
524
|
+
error: extractErrorDataFromError(error),
|
|
486
525
|
schemaName,
|
|
487
526
|
tableName,
|
|
488
527
|
});
|
|
@@ -795,7 +834,49 @@ export async function listTablesForSchema(
|
|
|
795
834
|
`Failed to get tables for MotherDuck schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
|
|
796
835
|
);
|
|
797
836
|
}
|
|
837
|
+
} else if (connection.type === "ducklake") {
|
|
838
|
+
const catalogName = schemaName.split(".")[0];
|
|
839
|
+
const actualSchemaName = schemaName.split(".")[1];
|
|
840
|
+
console.error("catalogName", catalogName);
|
|
841
|
+
console.error("actualSchemaName", actualSchemaName);
|
|
842
|
+
try {
|
|
843
|
+
const result = await malloyConnection.runSQL(
|
|
844
|
+
`SELECT table_name FROM information_schema.tables WHERE table_schema = '${actualSchemaName}' AND table_catalog = '${catalogName}' ORDER BY table_name`,
|
|
845
|
+
{ rowLimit: 1000 },
|
|
846
|
+
);
|
|
847
|
+
const rows = standardizeRunSQLResult(result);
|
|
848
|
+
return rows.map((row: unknown) => {
|
|
849
|
+
const typedRow = row as Record<string, unknown>;
|
|
850
|
+
return typedRow.table_name as string;
|
|
851
|
+
});
|
|
852
|
+
} catch (error) {
|
|
853
|
+
logger.error(
|
|
854
|
+
`Error getting tables for DuckLake schema ${schemaName} in connection ${connection.name}`,
|
|
855
|
+
{ error },
|
|
856
|
+
);
|
|
857
|
+
throw new Error(
|
|
858
|
+
`Failed to get tables for DuckLake schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
798
861
|
} else {
|
|
799
862
|
throw new Error(`Unsupported connection type: ${connection.type}`);
|
|
800
863
|
}
|
|
801
864
|
}
|
|
865
|
+
|
|
866
|
+
export function extractErrorDataFromError(error: unknown): {
|
|
867
|
+
error: string;
|
|
868
|
+
stack?: string;
|
|
869
|
+
task?: unknown;
|
|
870
|
+
} {
|
|
871
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
872
|
+
const errorData: { error: string; stack?: string; task?: unknown } = {
|
|
873
|
+
error: errorMessage,
|
|
874
|
+
};
|
|
875
|
+
if (error instanceof Error && logger.level === "debug") {
|
|
876
|
+
errorData.stack = error.stack;
|
|
877
|
+
}
|
|
878
|
+
if (error && typeof error === "object" && "task" in error) {
|
|
879
|
+
errorData.task = (error as { task?: unknown }).task;
|
|
880
|
+
}
|
|
881
|
+
return errorData;
|
|
882
|
+
}
|
package/src/service/model.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
modelDefToModelInfo,
|
|
11
11
|
ModelMaterializer,
|
|
12
12
|
NamedModelObject,
|
|
13
|
-
|
|
13
|
+
NamedQueryDef,
|
|
14
14
|
QueryData,
|
|
15
15
|
QueryMaterializer,
|
|
16
16
|
Runtime,
|
|
@@ -532,7 +532,7 @@ export class Model {
|
|
|
532
532
|
ROW_LIMIT;
|
|
533
533
|
const result = await cell.runnable.run({ rowLimit });
|
|
534
534
|
const query = (await cell.runnable.getPreparedQuery())._query;
|
|
535
|
-
queryName = (query as
|
|
535
|
+
queryName = (query as NamedQueryDef).as || query.name;
|
|
536
536
|
queryResult =
|
|
537
537
|
result?._queryResult &&
|
|
538
538
|
this.modelInfo &&
|
|
@@ -621,11 +621,12 @@ export class Model {
|
|
|
621
621
|
modelPath: string,
|
|
622
622
|
modelDef: ModelDef,
|
|
623
623
|
): ApiQuery[] {
|
|
624
|
-
const isNamedQuery = (
|
|
625
|
-
object
|
|
624
|
+
const isNamedQuery = (
|
|
625
|
+
object: NamedModelObject,
|
|
626
|
+
): object is NamedQueryDef => object.type === "query";
|
|
626
627
|
return Object.values(modelDef.contents)
|
|
627
628
|
.filter(isNamedQuery)
|
|
628
|
-
.map((queryObj:
|
|
629
|
+
.map((queryObj: NamedQueryDef) => ({
|
|
629
630
|
name: queryObj.as || queryObj.name,
|
|
630
631
|
// What to do when the source is not a string?
|
|
631
632
|
sourceName:
|
|
@@ -633,8 +634,10 @@ export class Model {
|
|
|
633
634
|
? queryObj.structRef
|
|
634
635
|
: undefined,
|
|
635
636
|
annotations: queryObj?.annotation?.blockNotes
|
|
636
|
-
?.filter((note
|
|
637
|
-
|
|
637
|
+
?.filter((note: { at: { url: string } }) =>
|
|
638
|
+
note.at.url.includes(modelPath),
|
|
639
|
+
)
|
|
640
|
+
.map((note: { text: string }) => note.text),
|
|
638
641
|
}));
|
|
639
642
|
}
|
|
640
643
|
|
|
@@ -828,7 +831,7 @@ export class Model {
|
|
|
828
831
|
let queryInfo: Malloy.QueryInfo | undefined = undefined;
|
|
829
832
|
try {
|
|
830
833
|
const preparedQuery = await runnable.getPreparedQuery();
|
|
831
|
-
const query = preparedQuery._query as
|
|
834
|
+
const query = preparedQuery._query as NamedQueryDef;
|
|
832
835
|
const queryName = query.as || query.name;
|
|
833
836
|
const anonymousQuery =
|
|
834
837
|
currentModelInfo.anonymous_queries[
|
package/src/service/project.ts
CHANGED
|
@@ -13,7 +13,11 @@ import {
|
|
|
13
13
|
} from "../errors";
|
|
14
14
|
import { logger } from "../logger";
|
|
15
15
|
import { URL_READER } from "../utils";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createProjectConnections,
|
|
18
|
+
deleteDuckLakeConnectionFile,
|
|
19
|
+
InternalConnection,
|
|
20
|
+
} from "./connection";
|
|
17
21
|
import { ApiConnection } from "./model";
|
|
18
22
|
import { Package } from "./package";
|
|
19
23
|
|
|
@@ -86,12 +90,13 @@ export class Project {
|
|
|
86
90
|
logger.info(
|
|
87
91
|
`Updating ${payload.connections.length} connections for project ${this.projectName}`,
|
|
88
92
|
);
|
|
89
|
-
|
|
93
|
+
const isUpdateConnectionRequest = true;
|
|
90
94
|
// Reload connections with full config
|
|
91
95
|
const { malloyConnections, apiConnections } =
|
|
92
96
|
await createProjectConnections(
|
|
93
97
|
payload.connections,
|
|
94
98
|
this.projectPath,
|
|
99
|
+
isUpdateConnectionRequest,
|
|
95
100
|
);
|
|
96
101
|
|
|
97
102
|
// Update the project's connection maps
|
|
@@ -536,8 +541,11 @@ export class Project {
|
|
|
536
541
|
(conn) => conn.name === connectionName,
|
|
537
542
|
);
|
|
538
543
|
|
|
539
|
-
|
|
544
|
+
const connectionType = this.apiConnections[index]?.type;
|
|
545
|
+
if (connectionType === "duckdb") {
|
|
540
546
|
await this.deleteDuckDBConnection(connectionName);
|
|
547
|
+
} else if (connectionType === "ducklake") {
|
|
548
|
+
await this.deleteDuckLakeConnection(connectionName);
|
|
541
549
|
}
|
|
542
550
|
|
|
543
551
|
if (index !== -1) {
|
|
@@ -615,6 +623,15 @@ export class Project {
|
|
|
615
623
|
);
|
|
616
624
|
});
|
|
617
625
|
}
|
|
626
|
+
|
|
627
|
+
public async deleteDuckLakeConnection(
|
|
628
|
+
connectionName: string,
|
|
629
|
+
): Promise<void> {
|
|
630
|
+
await deleteDuckLakeConnectionFile(connectionName, this.projectPath);
|
|
631
|
+
logger.info(
|
|
632
|
+
`Removed DuckLake connection ${connectionName} from project ${this.projectName}`,
|
|
633
|
+
);
|
|
634
|
+
}
|
|
618
635
|
}
|
|
619
636
|
|
|
620
637
|
/**
|