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