@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.
Files changed (34) hide show
  1. package/dist/app/api-doc.yaml +522 -1
  2. package/dist/app/assets/{HomePage-H1OH-VW5.js → HomePage-Di9MU3lS.js} +1 -1
  3. package/dist/app/assets/{MainPage-GL06aMke.js → MainPage-yZQo2HSL.js} +1 -1
  4. package/dist/app/assets/{ModelPage-Crau5hgZ.js → ModelPage-Dx2mHWeT.js} +1 -1
  5. package/dist/app/assets/{PackagePage-CbubRhgE.js → PackagePage-Q386Py9t.js} +1 -1
  6. package/dist/app/assets/{ProjectPage-DUlJkYJ4.js → ProjectPage-WR7wPQB-.js} +1 -1
  7. package/dist/app/assets/{RouteError-DrNXNihc.js → RouteError-stRGU4aW.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-CBBv7n5U.js → WorkbookPage-D3iX0djH.js} +1 -1
  9. package/dist/app/assets/{core-Dzx75uJR.es-DwnFZnyO.js → core-QH4HZQVz.es-CqlQLZdl.js} +1 -1
  10. package/dist/app/assets/{index-d5rvmoZ7.js → index-CVHzPJwN.js} +119 -119
  11. package/dist/app/assets/{index-CzjyS9cx.js → index-DavAceYD.js} +50 -50
  12. package/dist/app/assets/{index-HHdhLUpv.js → index-Y3Y-VRna.js} +1 -1
  13. package/dist/app/assets/{index.umd-CetYIBQY.js → index.umd-Bp8OIhfV.js} +46 -46
  14. package/dist/app/index.html +1 -1
  15. package/dist/server.mjs +1389 -984
  16. package/package.json +10 -10
  17. package/src/controller/connection.controller.ts +102 -27
  18. package/src/dto/connection.dto.spec.ts +4 -0
  19. package/src/dto/connection.dto.ts +46 -2
  20. package/src/server.ts +201 -2
  21. package/src/service/connection.spec.ts +250 -4
  22. package/src/service/connection.ts +326 -473
  23. package/src/service/connection_config.ts +514 -0
  24. package/src/service/connection_service.spec.ts +50 -0
  25. package/src/service/connection_service.ts +125 -32
  26. package/src/service/materialization_service.spec.ts +18 -12
  27. package/src/service/materialization_service.ts +54 -7
  28. package/src/service/model.ts +24 -27
  29. package/src/service/package.spec.ts +125 -1
  30. package/src/service/package.ts +86 -44
  31. package/src/service/project.ts +172 -94
  32. package/src/service/project_store.spec.ts +72 -0
  33. package/src/service/project_store.ts +98 -81
  34. 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 { MySQLConnection } from "@malloydata/db-mysql";
4
- import { PostgresConnection } from "@malloydata/db-postgres";
5
- import { SnowflakeConnection } from "@malloydata/db-snowflake";
6
- import { TrinoConnection } from "@malloydata/db-trino";
7
- import { Connection, TableSourceDef } from "@malloydata/malloy";
8
- import { BaseConnection } from "@malloydata/malloy/connection";
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
- connectionName: string,
784
- databasePath: string,
785
- workingDirectory: string,
705
+ options: PublisherDuckDBOptions,
786
706
  azureDatabases: AttachedDatabase[],
787
707
  ) {
788
- super(connectionName, databasePath, workingDirectory);
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
- connectionName: string,
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.endsWith("_ducklake.duckdb")) {
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 = 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 async function createProjectConnections(
919
- connections: ApiConnection[] = [],
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
- const connectionMap = new Map<string, BaseConnection>();
927
- const processedConnections = new Set<string>();
928
- const apiConnections: InternalConnection[] = [];
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
- if (!connection.snowflakeConnection.warehouse) {
1054
- throw new Error("Snowflake warehouse is required.");
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
- let privateKeyPath = undefined;
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
- if (connection.snowflakeConnection.privateKey) {
1060
- privateKeyPath = path.join(
1061
- TEMP_DIR_PATH,
1062
- `${connection.name}-${uuidv4()}-private-key.pem`,
1063
- );
1064
- const normalizedKey = normalizePrivateKey(
1065
- connection.snowflakeConnection.privateKey as string,
1066
- );
1067
- await fs.writeFile(privateKeyPath, normalizedKey);
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
- const snowflakeConnectionOptions = {
1071
- connOptions: {
1072
- account: connection.snowflakeConnection.account,
1073
- username: connection.snowflakeConnection.username,
1074
- warehouse: connection.snowflakeConnection.warehouse,
1075
- database: connection.snowflakeConnection.database,
1076
- schema: connection.snowflakeConnection.schema,
1077
- role: connection.snowflakeConnection.role,
1078
- ...(connection.snowflakeConnection.privateKey
1079
- ? {
1080
- privateKeyPath: privateKeyPath,
1081
- authenticator: "SNOWFLAKE_JWT",
1082
- privateKeyPass:
1083
- connection.snowflakeConnection.privateKeyPass ||
1084
- undefined,
1085
- }
1086
- : {
1087
- password:
1088
- connection.snowflakeConnection.password ||
1089
- undefined,
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
- case "trino": {
1110
- if (!connection.trinoConnection) {
1111
- throw new Error("Trino connection configuration is missing.");
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
- const trinoConnectionOptions = validateAndBuildTrinoConfig(
1115
- connection.trinoConnection,
1116
- );
1117
- const trinoConnection = new TrinoConnection(
1118
- connection.name,
1119
- {},
1120
- trinoConnectionOptions,
1121
- );
1122
- connectionMap.set(connection.name, trinoConnection);
1123
- connection.attributes = getConnectionAttributes(trinoConnection);
1124
- break;
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
- case "duckdb": {
1128
- if (!connection.duckdbConnection) {
1129
- throw new Error("DuckDB connection configuration is missing.");
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
- if (
1133
- connection.duckdbConnection.attachedDatabases?.some(
1134
- (database) => database.name === connection.name,
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
- if (connection.name === "duckdb") {
1143
- throw new Error("DuckDB connection name cannot be 'duckdb'");
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
- if (connection.duckdbConnection?.attachedDatabases?.length == 0) {
1147
- throw new Error(
1148
- "DuckDB connection must have at least one attached database",
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
- // Create DuckDB connection with project basePath as working directory
1153
- // This ensures relative paths in the project are resolved correctly
1154
- // Use unique memory database path to prevent sharing across connections
1155
- const attachedDatabases =
1156
- connection.duckdbConnection.attachedDatabases ?? [];
1157
- const hasAzureAttached = attachedDatabases.some(
1158
- (db) => db.type === "azure",
1159
- );
1160
- const duckdbConnection = hasAzureAttached
1161
- ? new AzureDuckDBConnection(
1162
- connection.name,
1163
- path.join(projectPath, `${connection.name}.duckdb`),
1164
- projectPath,
1165
- attachedDatabases,
1166
- )
1167
- : new DuckDBConnection(
1168
- connection.name,
1169
- path.join(projectPath, `${connection.name}.duckdb`),
1170
- projectPath,
1171
- );
1172
-
1173
- // Attach databases if configured
1174
- if (attachedDatabases.length > 0) {
1175
- await attachDatabasesToDuckDB(
1176
- duckdbConnection,
1177
- attachedDatabases,
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
- connectionMap.set(connection.name, duckdbConnection);
1182
- connection.attributes = getConnectionAttributes(duckdbConnection);
1183
- break;
1184
- }
1185
-
1186
- case "motherduck": {
1187
- if (!connection.motherduckConnection) {
1188
- throw new Error(
1189
- "MotherDuck connection configuration is missing.",
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 (!connection.motherduckConnection.accessToken) {
1194
- throw new Error("MotherDuck access token is required.");
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
- let databasePath = `md:`;
1198
- // Build the MotherDuck database path
1199
- if (connection.motherduckConnection.database) {
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
- // Create MotherDuck connection using DuckDBConnectionOptions interface
1204
- const motherduckConnection = new DuckDBConnection({
1205
- name: connection.name,
1206
- databasePath: databasePath,
1207
- motherDuckToken: connection.motherduckConnection.accessToken,
1208
- workingDirectory: projectPath,
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
- case "ducklake": {
1218
- if (!connection.ducklakeConnection) {
1219
- throw new Error("DuckLake connection configuration is missing.");
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
- default: {
1252
- throw new Error(`Unsupported connection type: ${connection.type}`);
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
- // Add the connection to apiConnections (this will be sanitized when returned)
1257
- apiConnections.push(connection);
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 malloyConnections: Map<string, BaseConnection> | null = null;
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
- // Use createProjectConnections to create the connection, then test it
1420
- const result = await createProjectConnections(
1421
- [connectionConfig], // Pass the single connection config
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
- // Cleanup: close all connections and remove ducklake files created during testing
1487
- if (malloyConnections) {
1488
- for (const [connName, conn] of malloyConnections) {
1489
- try {
1490
- // Close the connection
1491
- if (
1492
- conn &&
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
  }