@malloy-publisher/server 0.0.165 → 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 +143 -1
- package/dist/app/assets/HomePage-D2tUw_9U.js +1 -0
- package/dist/app/assets/{MainPage-DAyUfYba.js → MainPage-DBQW76L7.js} +2 -2
- package/dist/app/assets/{ModelPage-CrMryV1s.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-DZEVYGW3.js → WorkbookPage-FD_gmxeE.js} +1 -1
- package/dist/app/assets/{index-BvVmB5sv.js → index-D5QBYuLK.js} +150 -150
- package/dist/app/assets/{index-CsC07BYd.js → index-DNCvL_5f.js} +1 -1
- package/dist/app/assets/{index-DWhjtyBB.js → index-x9S1fsYn.js} +1 -1
- package/dist/app/assets/{index.umd-DvM-lTQa.js → index.umd-CTYdFEHH.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.js +85955 -88560
- package/dist/server.js +197441 -106276
- package/package.json +2 -1
- package/src/controller/compile.controller.ts +35 -0
- package/src/controller/connection.controller.ts +22 -2
- package/src/controller/model.controller.ts +20 -9
- package/src/health.ts +8 -0
- package/src/instrumentation.ts +123 -34
- package/src/server.ts +49 -3
- package/src/service/connection.spec.ts +1331 -0
- package/src/service/connection.ts +407 -29
- package/src/service/db_utils.ts +104 -45
- package/src/service/gcs_s3_utils.ts +115 -40
- package/src/service/model.ts +5 -5
- package/src/service/project.ts +140 -4
- package/src/service/project_compile.spec.ts +197 -0
- package/src/service/project_store.ts +49 -21
- package/src/storage/StorageManager.ts +4 -3
- package/src/storage/duckdb/schema.ts +6 -5
- package/tests/harness/e2e.ts +4 -0
- package/tests/harness/mcp_test_setup.ts +172 -28
- package/tests/unit/duckdb/attached_databases.test.ts +61 -3
- package/tests/unit/ducklake/ducklake.test.ts +950 -0
- package/dist/app/assets/HomePage-QekMXs8r.js +0 -1
- package/dist/app/assets/PackagePage-DDaABD2A.js +0 -1
- package/dist/app/assets/ProjectPage-FAYUFGhL.js +0 -1
- package/dist/app/assets/RouteError-BKYctANX.js +0 -1
|
@@ -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
|
}
|
|
@@ -858,9 +1088,107 @@ function getConnectionAttributes(
|
|
|
858
1088
|
};
|
|
859
1089
|
}
|
|
860
1090
|
|
|
1091
|
+
async function testDuckDBConnection(
|
|
1092
|
+
duckdbConnection: DuckDBConnection,
|
|
1093
|
+
connectionConfig: InternalConnection,
|
|
1094
|
+
): Promise<void> {
|
|
1095
|
+
// Test base DuckDB connection with a simple query
|
|
1096
|
+
try {
|
|
1097
|
+
await duckdbConnection.runSQL("SELECT 1 AS test");
|
|
1098
|
+
logger.info(
|
|
1099
|
+
`DuckDB base connection test passed for: ${connectionConfig.name}`,
|
|
1100
|
+
);
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
throw new Error(
|
|
1103
|
+
`DuckDB base connection test failed: ${(error as Error).message}`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Test each attached database if configured
|
|
1108
|
+
const attachedDatabases =
|
|
1109
|
+
connectionConfig.duckdbConnection?.attachedDatabases;
|
|
1110
|
+
if (!attachedDatabases || attachedDatabases.length === 0) {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const failedAttachments: string[] = [];
|
|
1115
|
+
|
|
1116
|
+
for (const attachedDb of attachedDatabases) {
|
|
1117
|
+
if (!attachedDb.name) {
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
try {
|
|
1122
|
+
// Test the attached database by querying its tables/schemas
|
|
1123
|
+
// Different database types require different test queries
|
|
1124
|
+
switch (attachedDb.type) {
|
|
1125
|
+
case "postgres": {
|
|
1126
|
+
// Test postgres attachment by listing schemas
|
|
1127
|
+
await duckdbConnection.runSQL(
|
|
1128
|
+
`SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${attachedDb.name}' LIMIT 1`,
|
|
1129
|
+
);
|
|
1130
|
+
logger.info(
|
|
1131
|
+
`Attached Postgres database test passed: ${attachedDb.name}`,
|
|
1132
|
+
);
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
case "bigquery": {
|
|
1136
|
+
// Test BigQuery attachment by listing datasets
|
|
1137
|
+
// BigQuery attached databases show as catalogs
|
|
1138
|
+
await duckdbConnection.runSQL(
|
|
1139
|
+
`SELECT database_name FROM duckdb_databases() WHERE database_name = '${attachedDb.name}'`,
|
|
1140
|
+
);
|
|
1141
|
+
logger.info(
|
|
1142
|
+
`Attached BigQuery database test passed: ${attachedDb.name}`,
|
|
1143
|
+
);
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
case "snowflake": {
|
|
1147
|
+
// Test Snowflake attachment by verifying database is attached
|
|
1148
|
+
await duckdbConnection.runSQL(
|
|
1149
|
+
`SELECT database_name FROM duckdb_databases() WHERE database_name = '${attachedDb.name}'`,
|
|
1150
|
+
);
|
|
1151
|
+
logger.info(
|
|
1152
|
+
`Attached Snowflake database test passed: ${attachedDb.name}`,
|
|
1153
|
+
);
|
|
1154
|
+
break;
|
|
1155
|
+
}
|
|
1156
|
+
case "gcs":
|
|
1157
|
+
case "s3": {
|
|
1158
|
+
// For cloud storage, verify the secret was created
|
|
1159
|
+
// Cloud storage doesn't attach as a database, it uses secrets for auth
|
|
1160
|
+
await duckdbConnection.runSQL(
|
|
1161
|
+
`SELECT name FROM duckdb_secrets() WHERE name LIKE '%${attachedDb.name}%' LIMIT 1`,
|
|
1162
|
+
);
|
|
1163
|
+
logger.info(
|
|
1164
|
+
`Cloud storage credentials test passed: ${attachedDb.name}`,
|
|
1165
|
+
);
|
|
1166
|
+
break;
|
|
1167
|
+
}
|
|
1168
|
+
default: {
|
|
1169
|
+
logger.warn(
|
|
1170
|
+
`Unknown attached database type: ${attachedDb.type}`,
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
const errorMessage = `Attached database '${attachedDb.name}' (${attachedDb.type}) test failed: ${(error as Error).message}`;
|
|
1176
|
+
logger.error(errorMessage);
|
|
1177
|
+
failedAttachments.push(errorMessage);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
if (failedAttachments.length > 0) {
|
|
1182
|
+
throw new Error(
|
|
1183
|
+
`DuckDB connection test failed for attached databases:\n${failedAttachments.join("\n")}`,
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
861
1188
|
export async function testConnectionConfig(
|
|
862
1189
|
connectionConfig: ApiConnection,
|
|
863
1190
|
): Promise<ApiConnectionStatus> {
|
|
1191
|
+
let malloyConnections: Map<string, BaseConnection> | null = null;
|
|
864
1192
|
try {
|
|
865
1193
|
// Validate that connection name is provided
|
|
866
1194
|
if (!connectionConfig.name) {
|
|
@@ -868,11 +1196,10 @@ export async function testConnectionConfig(
|
|
|
868
1196
|
}
|
|
869
1197
|
|
|
870
1198
|
// Use createProjectConnections to create the connection, then test it
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
const { malloyConnections } = await createProjectConnections(
|
|
1199
|
+
const result = await createProjectConnections(
|
|
874
1200
|
[connectionConfig], // Pass the single connection config
|
|
875
1201
|
);
|
|
1202
|
+
malloyConnections = result.malloyConnections;
|
|
876
1203
|
|
|
877
1204
|
// Get the created connection
|
|
878
1205
|
const connection = malloyConnections.get(connectionConfig.name);
|
|
@@ -882,16 +1209,42 @@ export async function testConnectionConfig(
|
|
|
882
1209
|
);
|
|
883
1210
|
}
|
|
884
1211
|
|
|
885
|
-
//
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1212
|
+
// Handle DuckDB connections specially since they have attached databases
|
|
1213
|
+
if (connectionConfig.type === "duckdb") {
|
|
1214
|
+
await testDuckDBConnection(
|
|
1215
|
+
connection as DuckDBConnection,
|
|
1216
|
+
connectionConfig as InternalConnection,
|
|
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
|
+
);
|
|
1237
|
+
} else {
|
|
1238
|
+
// Test other connection types using their test() method
|
|
1239
|
+
await (
|
|
1240
|
+
connection as
|
|
1241
|
+
| PostgresConnection
|
|
1242
|
+
| BigQueryConnection
|
|
1243
|
+
| SnowflakeConnection
|
|
1244
|
+
| TrinoConnection
|
|
1245
|
+
| MySQLConnection
|
|
1246
|
+
).test();
|
|
1247
|
+
}
|
|
895
1248
|
|
|
896
1249
|
return {
|
|
897
1250
|
status: "ok",
|
|
@@ -908,5 +1261,30 @@ export async function testConnectionConfig(
|
|
|
908
1261
|
status: "failed",
|
|
909
1262
|
errorMessage: (error as Error).message,
|
|
910
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
|
+
}
|
|
911
1289
|
}
|
|
912
1290
|
}
|