@malloy-publisher/server 0.0.192 → 0.0.194
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/build.ts +1 -0
- package/dist/app/api-doc.yaml +558 -1
- package/dist/app/assets/{HomePage-H1OH-VW5.js → HomePage-DbZS0N7G.js} +1 -1
- package/dist/app/assets/MainPage-CBuWkbmr.js +2 -0
- package/dist/app/assets/{ModelPage-Crau5hgZ.js → ModelPage-Bt37smot.js} +1 -1
- package/dist/app/assets/{PackagePage-CbubRhgE.js → PackagePage-DLZe50WG.js} +1 -1
- package/dist/app/assets/{ProjectPage-DUlJkYJ4.js → ProjectPage-FQTEPXP4.js} +1 -1
- package/dist/app/assets/{RouteError-DrNXNihc.js → RouteError-DefbDO7F.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CBBv7n5U.js → WorkbookPage-CkAo16ar.js} +1 -1
- package/dist/app/assets/{core-Dzx75uJR.es-DwnFZnyO.js → core-BrfQApxh.es-DnvCX4oH.js} +14 -14
- package/dist/app/assets/index-5eLCcNmP.css +1 -0
- package/dist/app/assets/{index-d5rvmoZ7.js → index-Bu0ub036.js} +119 -119
- package/dist/app/assets/index-CkzK3JIl.js +40 -0
- package/dist/app/assets/index-CoA6HIGS.js +1742 -0
- package/dist/app/assets/{index.umd-CetYIBQY.js → index.umd-B6Ms2PpL.js} +46 -46
- package/dist/app/index.html +2 -2
- package/dist/server.mjs +1529 -985
- package/package.json +11 -10
- package/src/config.ts +7 -2
- package/src/controller/connection.controller.ts +102 -27
- package/src/dto/connection.dto.spec.ts +55 -0
- package/src/dto/connection.dto.ts +87 -2
- package/src/server.ts +201 -2
- package/src/service/connection.spec.ts +250 -4
- package/src/service/connection.ts +328 -473
- package/src/service/connection_config.spec.ts +123 -0
- package/src/service/connection_config.ts +562 -0
- package/src/service/connection_service.spec.ts +50 -0
- package/src/service/connection_service.ts +125 -32
- package/src/service/db_utils.spec.ts +161 -0
- package/src/service/db_utils.ts +131 -0
- package/src/service/materialization_service.spec.ts +18 -12
- package/src/service/materialization_service.ts +54 -7
- package/src/service/model.ts +24 -27
- package/src/service/package.spec.ts +125 -1
- package/src/service/package.ts +86 -44
- package/src/service/project.ts +172 -94
- package/src/service/project_store.spec.ts +72 -0
- package/src/service/project_store.ts +98 -81
- package/tests/unit/duckdb/attached_databases.test.ts +1 -19
- package/dist/app/assets/MainPage-GL06aMke.js +0 -2
- package/dist/app/assets/index-CMlGQMcl.css +0 -1
- package/dist/app/assets/index-CzjyS9cx.js +0 -1276
- package/dist/app/assets/index-HHdhLUpv.js +0 -676
|
@@ -1,24 +1,48 @@
|
|
|
1
|
-
import { BigQueryConnection } from "@malloydata/db-bigquery";
|
|
1
|
+
import type { BigQueryConnection } from "@malloydata/db-bigquery";
|
|
2
|
+
import "@malloydata/db-bigquery";
|
|
2
3
|
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
4
|
+
import "@malloydata/db-duckdb/native";
|
|
5
|
+
import type { MySQLConnection } from "@malloydata/db-mysql";
|
|
6
|
+
import "@malloydata/db-mysql";
|
|
7
|
+
import type { PostgresConnection } from "@malloydata/db-postgres";
|
|
8
|
+
import "@malloydata/db-postgres";
|
|
9
|
+
import {
|
|
10
|
+
buildPoolOptions,
|
|
11
|
+
SnowflakeConnection,
|
|
12
|
+
} from "@malloydata/db-snowflake";
|
|
13
|
+
import type { TrinoConnection } from "@malloydata/db-trino";
|
|
14
|
+
import "@malloydata/db-trino";
|
|
15
|
+
import "@malloydata/db-databricks";
|
|
16
|
+
import {
|
|
17
|
+
Connection,
|
|
18
|
+
contextOverlay,
|
|
19
|
+
MalloyConfig,
|
|
20
|
+
TableSourceDef,
|
|
21
|
+
} from "@malloydata/malloy";
|
|
22
|
+
import type { LookupConnection } from "@malloydata/malloy/connection";
|
|
9
23
|
import { AxiosError } from "axios";
|
|
10
24
|
import fs from "fs/promises";
|
|
11
25
|
import path from "path";
|
|
12
|
-
import { v4 as uuidv4 } from "uuid";
|
|
13
26
|
import { components } from "../api";
|
|
14
|
-
import { TEMP_DIR_PATH } from "../constants";
|
|
15
27
|
import { logAxiosError, logger } from "../logger";
|
|
28
|
+
import {
|
|
29
|
+
assembleProjectConnections,
|
|
30
|
+
CoreConnectionEntry,
|
|
31
|
+
normalizeSnowflakePrivateKey,
|
|
32
|
+
ProjectConnectionMetadata,
|
|
33
|
+
} from "./connection_config";
|
|
16
34
|
import { CloudStorageCredentials } from "./gcs_s3_utils";
|
|
17
35
|
|
|
18
36
|
type AttachedDatabase = components["schemas"]["AttachedDatabase"];
|
|
19
37
|
type ApiConnection = components["schemas"]["Connection"];
|
|
20
38
|
type ApiConnectionAttributes = components["schemas"]["ConnectionAttributes"];
|
|
21
39
|
type ApiConnectionStatus = components["schemas"]["ConnectionStatus"];
|
|
40
|
+
type PublisherDuckDBOptions = {
|
|
41
|
+
name: string;
|
|
42
|
+
databasePath?: string;
|
|
43
|
+
workingDirectory?: string;
|
|
44
|
+
motherDuckToken?: string;
|
|
45
|
+
};
|
|
22
46
|
|
|
23
47
|
// Extends the public API connection with the internal connection objects
|
|
24
48
|
// which contains passwords and connection strings.
|
|
@@ -27,64 +51,12 @@ export type InternalConnection = ApiConnection & {
|
|
|
27
51
|
bigqueryConnection?: components["schemas"]["BigqueryConnection"];
|
|
28
52
|
snowflakeConnection?: components["schemas"]["SnowflakeConnection"];
|
|
29
53
|
trinoConnection?: components["schemas"]["TrinoConnection"];
|
|
54
|
+
databricksConnection?: components["schemas"]["DatabricksConnection"];
|
|
30
55
|
mysqlConnection?: components["schemas"]["MysqlConnection"];
|
|
31
56
|
duckdbConnection?: components["schemas"]["DuckdbConnection"];
|
|
32
57
|
ducklakeConnection?: components["schemas"]["DucklakeConnection"];
|
|
33
58
|
};
|
|
34
59
|
|
|
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
60
|
// Shared utilities
|
|
89
61
|
async function installAndLoadExtension(
|
|
90
62
|
connection: DuckDBConnection,
|
|
@@ -147,54 +119,6 @@ function handleAlreadyAttachedError(error: unknown, dbName: string): void {
|
|
|
147
119
|
}
|
|
148
120
|
}
|
|
149
121
|
|
|
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
122
|
// Database-specific attachment handlers
|
|
199
123
|
async function attachBigQuery(
|
|
200
124
|
connection: DuckDBConnection,
|
|
@@ -780,12 +704,10 @@ class AzureDuckDBConnection extends DuckDBConnection {
|
|
|
780
704
|
private azureDatabases: AttachedDatabase[];
|
|
781
705
|
|
|
782
706
|
constructor(
|
|
783
|
-
|
|
784
|
-
databasePath: string,
|
|
785
|
-
workingDirectory: string,
|
|
707
|
+
options: PublisherDuckDBOptions,
|
|
786
708
|
azureDatabases: AttachedDatabase[],
|
|
787
709
|
) {
|
|
788
|
-
super(
|
|
710
|
+
super(options);
|
|
789
711
|
this.azureDatabases = azureDatabases;
|
|
790
712
|
}
|
|
791
713
|
|
|
@@ -833,22 +755,18 @@ class AzureDuckDBConnection extends DuckDBConnection {
|
|
|
833
755
|
class DuckLakeConnection extends DuckDBConnection {
|
|
834
756
|
private connectionName: string;
|
|
835
757
|
|
|
836
|
-
constructor(
|
|
837
|
-
|
|
838
|
-
databasePath: string,
|
|
839
|
-
workingDirectory: string,
|
|
840
|
-
) {
|
|
841
|
-
super(connectionName, databasePath, workingDirectory);
|
|
758
|
+
constructor(options: PublisherDuckDBOptions) {
|
|
759
|
+
super(options);
|
|
842
760
|
|
|
843
761
|
// Validate that this is a DuckLake connection by checking the database path pattern
|
|
844
|
-
if (!databasePath
|
|
762
|
+
if (!options.databasePath?.endsWith("_ducklake.duckdb")) {
|
|
845
763
|
throw new Error(
|
|
846
764
|
`DuckLakeConnection should only be used for DuckLake connections. ` +
|
|
847
|
-
`Expected database path ending with '_ducklake.duckdb', got: ${databasePath}`,
|
|
765
|
+
`Expected database path ending with '_ducklake.duckdb', got: ${options.databasePath}`,
|
|
848
766
|
);
|
|
849
767
|
}
|
|
850
768
|
|
|
851
|
-
this.connectionName =
|
|
769
|
+
this.connectionName = options.name;
|
|
852
770
|
}
|
|
853
771
|
|
|
854
772
|
async fetchTableSchema(
|
|
@@ -915,351 +833,304 @@ export async function deleteDuckLakeConnectionFile(
|
|
|
915
833
|
}
|
|
916
834
|
}
|
|
917
835
|
|
|
918
|
-
export
|
|
919
|
-
|
|
920
|
-
projectPath: string = "",
|
|
921
|
-
isUpdateConnectionRequest: boolean = false,
|
|
922
|
-
): Promise<{
|
|
923
|
-
malloyConnections: Map<string, BaseConnection>;
|
|
836
|
+
export type ProjectMalloyConfig = {
|
|
837
|
+
malloyConfig: MalloyConfig;
|
|
924
838
|
apiConnections: InternalConnection[];
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
}
|
|
839
|
+
// Releases both core-managed connections and Publisher wrapper-managed
|
|
840
|
+
// DuckDB connections captured by wrapConnections.
|
|
841
|
+
releaseConnections: () => Promise<void>;
|
|
842
|
+
};
|
|
1052
843
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
844
|
+
function entryToDuckDBOptions(
|
|
845
|
+
name: string,
|
|
846
|
+
entry: CoreConnectionEntry,
|
|
847
|
+
workingDirectory?: string,
|
|
848
|
+
): PublisherDuckDBOptions {
|
|
849
|
+
const { is: _is, ...rest } = entry;
|
|
850
|
+
if (workingDirectory !== undefined) {
|
|
851
|
+
rest.workingDirectory = workingDirectory;
|
|
852
|
+
}
|
|
853
|
+
// `name` is always present; only the other fields may be undefined.
|
|
854
|
+
return { ...removeUndefined(rest), name };
|
|
855
|
+
}
|
|
1056
856
|
|
|
1057
|
-
|
|
857
|
+
function removeUndefined<T extends object>(value: T): Partial<T> {
|
|
858
|
+
return Object.fromEntries(
|
|
859
|
+
Object.entries(value).filter(
|
|
860
|
+
([, fieldValue]) => fieldValue !== undefined,
|
|
861
|
+
),
|
|
862
|
+
) as Partial<T>;
|
|
863
|
+
}
|
|
1058
864
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
865
|
+
function buildSnowflakePrivateKeyConnection(
|
|
866
|
+
metadata: ProjectConnectionMetadata,
|
|
867
|
+
): SnowflakeConnection {
|
|
868
|
+
const name = metadata.apiConnection.name!;
|
|
869
|
+
const snowflake = metadata.apiConnection.snowflakeConnection;
|
|
870
|
+
if (!snowflake?.privateKey) {
|
|
871
|
+
throw new Error(
|
|
872
|
+
`Snowflake private key is required for connection ${name}`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
if (!snowflake.account) {
|
|
876
|
+
throw new Error(`Snowflake account is required for connection ${name}`);
|
|
877
|
+
}
|
|
878
|
+
if (!snowflake.username) {
|
|
879
|
+
throw new Error(`Snowflake username is required for connection ${name}`);
|
|
880
|
+
}
|
|
881
|
+
if (!snowflake.warehouse) {
|
|
882
|
+
throw new Error(`Snowflake warehouse is required for connection ${name}`);
|
|
883
|
+
}
|
|
1069
884
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
}
|
|
885
|
+
return new SnowflakeConnection(name, {
|
|
886
|
+
connOptions: {
|
|
887
|
+
account: snowflake.account,
|
|
888
|
+
username: snowflake.username,
|
|
889
|
+
privateKey: normalizeSnowflakePrivateKey(snowflake.privateKey),
|
|
890
|
+
authenticator: "SNOWFLAKE_JWT",
|
|
891
|
+
warehouse: snowflake.warehouse,
|
|
892
|
+
...removeUndefined({
|
|
893
|
+
password: snowflake.password,
|
|
894
|
+
privateKeyPass: snowflake.privateKeyPass,
|
|
895
|
+
database: snowflake.database,
|
|
896
|
+
schema: snowflake.schema,
|
|
897
|
+
role: snowflake.role,
|
|
898
|
+
}),
|
|
899
|
+
},
|
|
900
|
+
timeoutMs: snowflake.responseTimeoutMilliseconds,
|
|
901
|
+
// Match the pool sizing used on the registry path (and in main's
|
|
902
|
+
// pre-MalloyConfig switch). Server-owned, not exposed via API.
|
|
903
|
+
poolOptions: buildPoolOptions({ poolMin: 1, poolMax: 20 }),
|
|
904
|
+
});
|
|
905
|
+
}
|
|
1108
906
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
907
|
+
function buildDuckLakeConnection(
|
|
908
|
+
metadata: ProjectConnectionMetadata,
|
|
909
|
+
entry: CoreConnectionEntry,
|
|
910
|
+
): DuckLakeConnection {
|
|
911
|
+
return new DuckLakeConnection(
|
|
912
|
+
entryToDuckDBOptions(
|
|
913
|
+
metadata.apiConnection.name!,
|
|
914
|
+
entry,
|
|
915
|
+
metadata.workingDirectory,
|
|
916
|
+
),
|
|
917
|
+
);
|
|
918
|
+
}
|
|
1113
919
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
920
|
+
function buildAzureDuckDBConnection(
|
|
921
|
+
metadata: ProjectConnectionMetadata,
|
|
922
|
+
entry: CoreConnectionEntry,
|
|
923
|
+
): AzureDuckDBConnection {
|
|
924
|
+
return new AzureDuckDBConnection(
|
|
925
|
+
entryToDuckDBOptions(
|
|
926
|
+
metadata.apiConnection.name!,
|
|
927
|
+
entry,
|
|
928
|
+
metadata.workingDirectory,
|
|
929
|
+
),
|
|
930
|
+
metadata.attachedDatabases,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
1126
933
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
934
|
+
function getMetadataForLookup(
|
|
935
|
+
metadata: Map<string, ProjectConnectionMetadata>,
|
|
936
|
+
name?: string,
|
|
937
|
+
): ProjectConnectionMetadata | undefined {
|
|
938
|
+
return name ? metadata.get(name) : undefined;
|
|
939
|
+
}
|
|
1131
940
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
throw new Error(
|
|
1138
|
-
`DuckDB attached databases names cannot conflict with connection name ${connection.name}`,
|
|
1139
|
-
);
|
|
1140
|
-
}
|
|
941
|
+
function isDuckDBConnection(
|
|
942
|
+
connection: Connection,
|
|
943
|
+
): connection is DuckDBConnection {
|
|
944
|
+
return connection instanceof DuckDBConnection;
|
|
945
|
+
}
|
|
1141
946
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
947
|
+
/**
|
|
948
|
+
* @param isUpdateConnectionRequest Forces a re-ATTACH on the DuckLake wrapper
|
|
949
|
+
* even when its catalog database is already attached on the cached
|
|
950
|
+
* connection. Set true when this build is replacing a prior generation due
|
|
951
|
+
* to a connection-config change, so the new generation rebinds against the
|
|
952
|
+
* updated DuckLake settings (catalog DSN, storage secrets) rather than
|
|
953
|
+
* trusting whatever attach state the prior generation left behind. On a
|
|
954
|
+
* fresh project the existing-attach check fails anyway, so the flag is a
|
|
955
|
+
* no-op there. Only the DuckLake branch consults it today.
|
|
956
|
+
*/
|
|
957
|
+
export function buildProjectMalloyConfig(
|
|
958
|
+
connections: ApiConnection[] = [],
|
|
959
|
+
projectPath: string = "",
|
|
960
|
+
isUpdateConnectionRequest: boolean = false,
|
|
961
|
+
): ProjectMalloyConfig {
|
|
962
|
+
const assembled = assembleProjectConnections(connections, projectPath);
|
|
963
|
+
// Cache the build Promise rather than the resolved Connection so two
|
|
964
|
+
// concurrent lookupConnection() calls for the same name share one build
|
|
965
|
+
// and we never leak a losing-instance pool/handle. Per-branch caches
|
|
966
|
+
// preserve the concrete subtype so use sites don't need narrowing casts.
|
|
967
|
+
const duckLakeCache = new Map<string, Promise<DuckLakeConnection>>();
|
|
968
|
+
const snowflakeJwtCache = new Map<string, Promise<SnowflakeConnection>>();
|
|
969
|
+
const azureDuckDBCache = new Map<string, Promise<AzureDuckDBConnection>>();
|
|
970
|
+
const attachPromises = new WeakMap<Connection, Promise<void>>();
|
|
971
|
+
|
|
972
|
+
const malloyConfig = new MalloyConfig(assembled.pojo, {
|
|
973
|
+
config: contextOverlay({ rootDirectory: projectPath }),
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
async function attachOnce(
|
|
977
|
+
connection: Connection,
|
|
978
|
+
metadata: ProjectConnectionMetadata,
|
|
979
|
+
): Promise<void> {
|
|
980
|
+
if (
|
|
981
|
+
metadata.attachedDatabases.length === 0 ||
|
|
982
|
+
!isDuckDBConnection(connection)
|
|
983
|
+
) {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
1145
986
|
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
987
|
+
let attachPromise = attachPromises.get(connection);
|
|
988
|
+
if (!attachPromise) {
|
|
989
|
+
// One ATTACH run per connection object per config generation.
|
|
990
|
+
attachPromise = attachDatabasesToDuckDB(
|
|
991
|
+
connection,
|
|
992
|
+
metadata.attachedDatabases,
|
|
993
|
+
);
|
|
994
|
+
attachPromises.set(connection, attachPromise);
|
|
995
|
+
}
|
|
996
|
+
await attachPromise;
|
|
997
|
+
}
|
|
1151
998
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
const
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
);
|
|
999
|
+
malloyConfig.wrapConnections(
|
|
1000
|
+
(base: LookupConnection<Connection>): LookupConnection<Connection> => ({
|
|
1001
|
+
lookupConnection: async (name?: string): Promise<Connection> => {
|
|
1002
|
+
const metadata = getMetadataForLookup(assembled.metadata, name);
|
|
1003
|
+
|
|
1004
|
+
if (metadata?.isDuckLake) {
|
|
1005
|
+
let connectionPromise = duckLakeCache.get(name!);
|
|
1006
|
+
if (!connectionPromise) {
|
|
1007
|
+
const entry = assembled.pojo.connections[name!];
|
|
1008
|
+
connectionPromise = Promise.resolve(
|
|
1009
|
+
buildDuckLakeConnection(metadata, entry),
|
|
1010
|
+
);
|
|
1011
|
+
duckLakeCache.set(name!, connectionPromise);
|
|
1012
|
+
}
|
|
1013
|
+
const connection = await connectionPromise;
|
|
1014
|
+
if (
|
|
1015
|
+
isUpdateConnectionRequest ||
|
|
1016
|
+
!(await isDatabaseAttached(connection, name!))
|
|
1017
|
+
) {
|
|
1018
|
+
await attachDuckLake(
|
|
1019
|
+
connection,
|
|
1020
|
+
name!,
|
|
1021
|
+
metadata.apiConnection.ducklakeConnection!,
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
return connection;
|
|
1179
1025
|
}
|
|
1180
1026
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
);
|
|
1027
|
+
if (metadata?.hasSnowflakePrivateKey) {
|
|
1028
|
+
let connectionPromise = snowflakeJwtCache.get(name!);
|
|
1029
|
+
if (!connectionPromise) {
|
|
1030
|
+
connectionPromise = Promise.resolve(
|
|
1031
|
+
buildSnowflakePrivateKeyConnection(metadata),
|
|
1032
|
+
);
|
|
1033
|
+
snowflakeJwtCache.set(name!, connectionPromise);
|
|
1034
|
+
}
|
|
1035
|
+
return connectionPromise;
|
|
1191
1036
|
}
|
|
1192
1037
|
|
|
1193
|
-
if (
|
|
1194
|
-
|
|
1038
|
+
if (metadata?.hasAzureAttachment) {
|
|
1039
|
+
let connectionPromise = azureDuckDBCache.get(name!);
|
|
1040
|
+
if (!connectionPromise) {
|
|
1041
|
+
const entry = assembled.pojo.connections[name!];
|
|
1042
|
+
connectionPromise = Promise.resolve(
|
|
1043
|
+
buildAzureDuckDBConnection(metadata, entry),
|
|
1044
|
+
);
|
|
1045
|
+
azureDuckDBCache.set(name!, connectionPromise);
|
|
1046
|
+
}
|
|
1047
|
+
const connection = await connectionPromise;
|
|
1048
|
+
await attachOnce(connection, metadata);
|
|
1049
|
+
return connection;
|
|
1195
1050
|
}
|
|
1196
1051
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
databasePath = `md:${connection.motherduckConnection.database}?attach_mode=single`;
|
|
1052
|
+
const connection = await base.lookupConnection(name);
|
|
1053
|
+
if (metadata) {
|
|
1054
|
+
await attachOnce(connection, metadata);
|
|
1201
1055
|
}
|
|
1056
|
+
return connection;
|
|
1057
|
+
},
|
|
1058
|
+
}),
|
|
1059
|
+
);
|
|
1202
1060
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1061
|
+
return {
|
|
1062
|
+
malloyConfig,
|
|
1063
|
+
apiConnections: assembled.apiConnections,
|
|
1064
|
+
releaseConnections: async () => {
|
|
1065
|
+
const wrapperPromises: Promise<Connection>[] = [
|
|
1066
|
+
...duckLakeCache.values(),
|
|
1067
|
+
...snowflakeJwtCache.values(),
|
|
1068
|
+
...azureDuckDBCache.values(),
|
|
1069
|
+
];
|
|
1070
|
+
const closeResults = await Promise.allSettled([
|
|
1071
|
+
malloyConfig.releaseConnections(),
|
|
1072
|
+
...wrapperPromises.map(async (promise) => {
|
|
1073
|
+
const connection = await promise;
|
|
1074
|
+
await connection.close();
|
|
1075
|
+
}),
|
|
1076
|
+
]);
|
|
1077
|
+
duckLakeCache.clear();
|
|
1078
|
+
snowflakeJwtCache.clear();
|
|
1079
|
+
azureDuckDBCache.clear();
|
|
1080
|
+
|
|
1081
|
+
const failures = closeResults.filter(
|
|
1082
|
+
(result): result is PromiseRejectedResult =>
|
|
1083
|
+
result.status === "rejected",
|
|
1084
|
+
);
|
|
1085
|
+
// Log every failure individually before throwing — Promise.allSettled
|
|
1086
|
+
// already let every branch run, and a single AggregateError otherwise
|
|
1087
|
+
// hides per-branch detail from callers that just log the error.
|
|
1088
|
+
for (const failure of failures) {
|
|
1089
|
+
logger.error("Failed to release project connection", {
|
|
1090
|
+
error: failure.reason,
|
|
1209
1091
|
});
|
|
1210
|
-
|
|
1211
|
-
connectionMap.set(connection.name, motherduckConnection);
|
|
1212
|
-
connection.attributes =
|
|
1213
|
-
getConnectionAttributes(motherduckConnection);
|
|
1214
|
-
break;
|
|
1215
1092
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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,
|
|
1093
|
+
if (failures.length > 0) {
|
|
1094
|
+
throw new AggregateError(
|
|
1095
|
+
failures.map((failure) => failure.reason),
|
|
1096
|
+
"Failed to release one or more project connections",
|
|
1247
1097
|
);
|
|
1248
|
-
break;
|
|
1249
1098
|
}
|
|
1099
|
+
},
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1250
1102
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1103
|
+
export async function createProjectConnections(
|
|
1104
|
+
connections: ApiConnection[] = [],
|
|
1105
|
+
projectPath: string = "",
|
|
1106
|
+
isUpdateConnectionRequest: boolean = false,
|
|
1107
|
+
): Promise<{
|
|
1108
|
+
malloyConnections: Map<string, Connection>;
|
|
1109
|
+
apiConnections: InternalConnection[];
|
|
1110
|
+
releaseConnections: () => Promise<void>;
|
|
1111
|
+
}> {
|
|
1112
|
+
const connectionMap = new Map<string, Connection>();
|
|
1113
|
+
const projectConfig = buildProjectMalloyConfig(
|
|
1114
|
+
connections,
|
|
1115
|
+
projectPath,
|
|
1116
|
+
isUpdateConnectionRequest,
|
|
1117
|
+
);
|
|
1255
1118
|
|
|
1256
|
-
|
|
1257
|
-
|
|
1119
|
+
for (const connection of projectConfig.apiConnections) {
|
|
1120
|
+
if (!connection.name) continue;
|
|
1121
|
+
logger.info(`Adding connection ${connection.name}`, { connection });
|
|
1122
|
+
const malloyConnection =
|
|
1123
|
+
await projectConfig.malloyConfig.connections.lookupConnection(
|
|
1124
|
+
connection.name,
|
|
1125
|
+
);
|
|
1126
|
+
connection.attributes = getConnectionAttributes(malloyConnection);
|
|
1127
|
+
connectionMap.set(connection.name, malloyConnection);
|
|
1258
1128
|
}
|
|
1259
1129
|
|
|
1260
1130
|
return {
|
|
1261
1131
|
malloyConnections: connectionMap,
|
|
1262
|
-
apiConnections: apiConnections,
|
|
1132
|
+
apiConnections: projectConfig.apiConnections,
|
|
1133
|
+
releaseConnections: projectConfig.releaseConnections,
|
|
1263
1134
|
};
|
|
1264
1135
|
}
|
|
1265
1136
|
|
|
@@ -1409,26 +1280,18 @@ async function testDuckDBConnection(
|
|
|
1409
1280
|
export async function testConnectionConfig(
|
|
1410
1281
|
connectionConfig: ApiConnection,
|
|
1411
1282
|
): Promise<ApiConnectionStatus> {
|
|
1412
|
-
let
|
|
1283
|
+
let projectConfig: ProjectMalloyConfig | null = null;
|
|
1413
1284
|
try {
|
|
1414
1285
|
// Validate that connection name is provided
|
|
1415
1286
|
if (!connectionConfig.name) {
|
|
1416
1287
|
throw new Error("Connection name is required");
|
|
1417
1288
|
}
|
|
1418
1289
|
|
|
1419
|
-
|
|
1420
|
-
const
|
|
1421
|
-
|
|
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}`,
|
|
1290
|
+
projectConfig = buildProjectMalloyConfig([connectionConfig]);
|
|
1291
|
+
const connection =
|
|
1292
|
+
await projectConfig.malloyConfig.connections.lookupConnection(
|
|
1293
|
+
connectionConfig.name,
|
|
1430
1294
|
);
|
|
1431
|
-
}
|
|
1432
1295
|
|
|
1433
1296
|
// Handle DuckDB connections specially since they have attached databases
|
|
1434
1297
|
if (connectionConfig.type === "duckdb") {
|
|
@@ -1483,29 +1346,21 @@ export async function testConnectionConfig(
|
|
|
1483
1346
|
errorMessage: (error as Error).message,
|
|
1484
1347
|
};
|
|
1485
1348
|
} finally {
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
}
|
|
1349
|
+
if (projectConfig) {
|
|
1350
|
+
try {
|
|
1351
|
+
await projectConfig.releaseConnections();
|
|
1352
|
+
} catch (closeError) {
|
|
1353
|
+
logger.warn("Error releasing temporary connection test config", {
|
|
1354
|
+
error: closeError,
|
|
1355
|
+
});
|
|
1508
1356
|
}
|
|
1509
1357
|
}
|
|
1358
|
+
|
|
1359
|
+
if (connectionConfig.type === "ducklake" && connectionConfig.name) {
|
|
1360
|
+
await deleteDuckLakeConnectionFile(
|
|
1361
|
+
connectionConfig.name,
|
|
1362
|
+
process.cwd(),
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1510
1365
|
}
|
|
1511
1366
|
}
|