@malloy-publisher/server 0.0.165 → 0.0.167
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 +107 -0
- package/dist/app/assets/{HomePage-QekMXs8r.js → HomePage-D76UaGFV.js} +1 -1
- package/dist/app/assets/{MainPage-DAyUfYba.js → MainPage-C9Fr5IN8.js} +1 -1
- package/dist/app/assets/{ModelPage-CrMryV1s.js → ModelPage-BkU6HAHA.js} +1 -1
- package/dist/app/assets/{PackagePage-DDaABD2A.js → PackagePage-BhE9Wi7b.js} +1 -1
- package/dist/app/assets/{ProjectPage-FAYUFGhL.js → ProjectPage-BatZLVap.js} +1 -1
- package/dist/app/assets/{RouteError-BKYctANX.js → RouteError-Bo5zJ8Xa.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DZEVYGW3.js → WorkbookPage-D3rUQZj6.js} +1 -1
- package/dist/app/assets/{index-BvVmB5sv.js → index-BLxl0XLH.js} +71 -71
- package/dist/app/assets/{index-DWhjtyBB.js → index-hkABoiMV.js} +1 -1
- package/dist/app/assets/{index-CsC07BYd.js → index-lhDwptrQ.js} +1 -1
- package/dist/app/assets/{index.umd-DvM-lTQa.js → index.umd-BkXQ-YAe.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.js +85955 -88560
- package/dist/server.js +197162 -106231
- package/package.json +2 -1
- package/src/controller/compile.controller.ts +35 -0
- package/src/controller/model.controller.ts +20 -9
- package/src/health.ts +8 -0
- package/src/instrumentation.ts +123 -34
- package/src/server.ts +44 -2
- package/src/service/connection.spec.ts +1226 -0
- package/src/service/connection.ts +114 -12
- package/src/service/db_utils.ts +19 -41
- package/src/service/gcs_s3_utils.ts +115 -40
- package/src/service/model.ts +5 -5
- package/src/service/project.ts +120 -1
- package/src/service/project_compile.spec.ts +197 -0
- package/src/service/project_store.ts +49 -21
- package/src/storage/StorageManager.ts +4 -3
- package/src/storage/duckdb/schema.ts +6 -5
- package/tests/harness/e2e.ts +4 -0
- package/tests/harness/mcp_test_setup.ts +6 -2
|
@@ -858,6 +858,103 @@ function getConnectionAttributes(
|
|
|
858
858
|
};
|
|
859
859
|
}
|
|
860
860
|
|
|
861
|
+
async function testDuckDBConnection(
|
|
862
|
+
duckdbConnection: DuckDBConnection,
|
|
863
|
+
connectionConfig: InternalConnection,
|
|
864
|
+
): Promise<void> {
|
|
865
|
+
// Test base DuckDB connection with a simple query
|
|
866
|
+
try {
|
|
867
|
+
await duckdbConnection.runSQL("SELECT 1 AS test");
|
|
868
|
+
logger.info(
|
|
869
|
+
`DuckDB base connection test passed for: ${connectionConfig.name}`,
|
|
870
|
+
);
|
|
871
|
+
} catch (error) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`DuckDB base connection test failed: ${(error as Error).message}`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Test each attached database if configured
|
|
878
|
+
const attachedDatabases =
|
|
879
|
+
connectionConfig.duckdbConnection?.attachedDatabases;
|
|
880
|
+
if (!attachedDatabases || attachedDatabases.length === 0) {
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const failedAttachments: string[] = [];
|
|
885
|
+
|
|
886
|
+
for (const attachedDb of attachedDatabases) {
|
|
887
|
+
if (!attachedDb.name) {
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
try {
|
|
892
|
+
// Test the attached database by querying its tables/schemas
|
|
893
|
+
// Different database types require different test queries
|
|
894
|
+
switch (attachedDb.type) {
|
|
895
|
+
case "postgres": {
|
|
896
|
+
// Test postgres attachment by listing schemas
|
|
897
|
+
await duckdbConnection.runSQL(
|
|
898
|
+
`SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${attachedDb.name}' LIMIT 1`,
|
|
899
|
+
);
|
|
900
|
+
logger.info(
|
|
901
|
+
`Attached Postgres database test passed: ${attachedDb.name}`,
|
|
902
|
+
);
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
case "bigquery": {
|
|
906
|
+
// Test BigQuery attachment by listing datasets
|
|
907
|
+
// BigQuery attached databases show as catalogs
|
|
908
|
+
await duckdbConnection.runSQL(
|
|
909
|
+
`SELECT database_name FROM duckdb_databases() WHERE database_name = '${attachedDb.name}'`,
|
|
910
|
+
);
|
|
911
|
+
logger.info(
|
|
912
|
+
`Attached BigQuery database test passed: ${attachedDb.name}`,
|
|
913
|
+
);
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
case "snowflake": {
|
|
917
|
+
// Test Snowflake attachment by verifying database is attached
|
|
918
|
+
await duckdbConnection.runSQL(
|
|
919
|
+
`SELECT database_name FROM duckdb_databases() WHERE database_name = '${attachedDb.name}'`,
|
|
920
|
+
);
|
|
921
|
+
logger.info(
|
|
922
|
+
`Attached Snowflake database test passed: ${attachedDb.name}`,
|
|
923
|
+
);
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
case "gcs":
|
|
927
|
+
case "s3": {
|
|
928
|
+
// For cloud storage, verify the secret was created
|
|
929
|
+
// Cloud storage doesn't attach as a database, it uses secrets for auth
|
|
930
|
+
await duckdbConnection.runSQL(
|
|
931
|
+
`SELECT name FROM duckdb_secrets() WHERE name LIKE '%${attachedDb.name}%' LIMIT 1`,
|
|
932
|
+
);
|
|
933
|
+
logger.info(
|
|
934
|
+
`Cloud storage credentials test passed: ${attachedDb.name}`,
|
|
935
|
+
);
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
default: {
|
|
939
|
+
logger.warn(
|
|
940
|
+
`Unknown attached database type: ${attachedDb.type}`,
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
} catch (error) {
|
|
945
|
+
const errorMessage = `Attached database '${attachedDb.name}' (${attachedDb.type}) test failed: ${(error as Error).message}`;
|
|
946
|
+
logger.error(errorMessage);
|
|
947
|
+
failedAttachments.push(errorMessage);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (failedAttachments.length > 0) {
|
|
952
|
+
throw new Error(
|
|
953
|
+
`DuckDB connection test failed for attached databases:\n${failedAttachments.join("\n")}`,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
861
958
|
export async function testConnectionConfig(
|
|
862
959
|
connectionConfig: ApiConnection,
|
|
863
960
|
): Promise<ApiConnectionStatus> {
|
|
@@ -868,8 +965,6 @@ export async function testConnectionConfig(
|
|
|
868
965
|
}
|
|
869
966
|
|
|
870
967
|
// Use createProjectConnections to create the connection, then test it
|
|
871
|
-
// TODO: Test duckdb connections?
|
|
872
|
-
|
|
873
968
|
const { malloyConnections } = await createProjectConnections(
|
|
874
969
|
[connectionConfig], // Pass the single connection config
|
|
875
970
|
);
|
|
@@ -882,16 +977,23 @@ export async function testConnectionConfig(
|
|
|
882
977
|
);
|
|
883
978
|
}
|
|
884
979
|
|
|
885
|
-
//
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
980
|
+
// Handle DuckDB connections specially since they have attached databases
|
|
981
|
+
if (connectionConfig.type === "duckdb") {
|
|
982
|
+
await testDuckDBConnection(
|
|
983
|
+
connection as DuckDBConnection,
|
|
984
|
+
connectionConfig as InternalConnection,
|
|
985
|
+
);
|
|
986
|
+
} else {
|
|
987
|
+
// Test other connection types using their test() method
|
|
988
|
+
await (
|
|
989
|
+
connection as
|
|
990
|
+
| PostgresConnection
|
|
991
|
+
| BigQueryConnection
|
|
992
|
+
| SnowflakeConnection
|
|
993
|
+
| TrinoConnection
|
|
994
|
+
| MySQLConnection
|
|
995
|
+
).test();
|
|
996
|
+
}
|
|
895
997
|
|
|
896
998
|
return {
|
|
897
999
|
status: "ok",
|
package/src/service/db_utils.ts
CHANGED
|
@@ -7,9 +7,8 @@ import {
|
|
|
7
7
|
CloudStorageCredentials,
|
|
8
8
|
gcsConnectionToCredentials,
|
|
9
9
|
getCloudTablesWithColumns,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
listFilesInCloudDirectory,
|
|
10
|
+
listCloudDirectorySchemas,
|
|
11
|
+
listDataFilesInDirectory,
|
|
13
12
|
parseCloudUri,
|
|
14
13
|
s3ConnectionToCredentials,
|
|
15
14
|
} from "./gcs_s3_utils";
|
|
@@ -340,23 +339,10 @@ export async function getSchemasForConnection(
|
|
|
340
339
|
: s3ConnectionToCredentials(attachedDb.s3Connection!);
|
|
341
340
|
|
|
342
341
|
try {
|
|
343
|
-
|
|
344
|
-
const scheme = dbType === "gcs" ? "gs" : "s3";
|
|
345
|
-
|
|
346
|
-
logger.info(
|
|
347
|
-
`Listed ${buckets.length} ${dbType.toUpperCase()} buckets for attached database ${attachedDb.name}`,
|
|
348
|
-
);
|
|
349
|
-
|
|
350
|
-
// Just return bucket URIs as schemas - fast!
|
|
351
|
-
// Files/directories will be listed when user selects a bucket
|
|
352
|
-
return buckets.map((bucket) => ({
|
|
353
|
-
name: `${scheme}://${bucket.name}`,
|
|
354
|
-
isHidden: false,
|
|
355
|
-
isDefault: false,
|
|
356
|
-
}));
|
|
342
|
+
return await listCloudDirectorySchemas(credentials);
|
|
357
343
|
} catch (cloudError) {
|
|
358
344
|
logger.warn(
|
|
359
|
-
`Failed to list ${dbType.toUpperCase()}
|
|
345
|
+
`Failed to list ${dbType.toUpperCase()} directory schemas for ${attachedDb.name}`,
|
|
360
346
|
{ error: cloudError },
|
|
361
347
|
);
|
|
362
348
|
return [];
|
|
@@ -444,19 +430,11 @@ export async function getTablesForSchema(
|
|
|
444
430
|
);
|
|
445
431
|
}
|
|
446
432
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
directoryPath,
|
|
453
|
-
);
|
|
454
|
-
fileKeys = fileNames.map((fileName) => `${directoryPath}/${fileName}`);
|
|
455
|
-
} else {
|
|
456
|
-
fileKeys = await listAllDataFilesInBucket(credentials, bucketName);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
console.log("File keys:", fileKeys);
|
|
433
|
+
const fileKeys = await listDataFilesInDirectory(
|
|
434
|
+
credentials,
|
|
435
|
+
bucketName,
|
|
436
|
+
directoryPath,
|
|
437
|
+
);
|
|
460
438
|
|
|
461
439
|
return await getCloudTablesWithColumns(
|
|
462
440
|
malloyConnection,
|
|
@@ -575,7 +553,7 @@ export async function getConnectionTableSource(
|
|
|
575
553
|
type: field.type,
|
|
576
554
|
};
|
|
577
555
|
});
|
|
578
|
-
logger.
|
|
556
|
+
logger.debug(`Successfully fetched schema for ${tablePath}`, {
|
|
579
557
|
fieldCount: fields.length,
|
|
580
558
|
});
|
|
581
559
|
return {
|
|
@@ -748,15 +726,15 @@ export async function listTablesForSchema(
|
|
|
748
726
|
}
|
|
749
727
|
|
|
750
728
|
try {
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
return
|
|
759
|
-
}
|
|
729
|
+
const fileKeys = await listDataFilesInDirectory(
|
|
730
|
+
credentials,
|
|
731
|
+
bucketName,
|
|
732
|
+
directoryPath,
|
|
733
|
+
);
|
|
734
|
+
return fileKeys.map((key) => {
|
|
735
|
+
const lastSlash = key.lastIndexOf("/");
|
|
736
|
+
return lastSlash > 0 ? key.substring(lastSlash + 1) : key;
|
|
737
|
+
});
|
|
760
738
|
} catch (error) {
|
|
761
739
|
logger.error(
|
|
762
740
|
`Error listing ${cloudType.toUpperCase()} objects in ${schemaName}`,
|
|
@@ -8,7 +8,6 @@ import { components } from "../api";
|
|
|
8
8
|
import { logger } from "../logger";
|
|
9
9
|
|
|
10
10
|
type ApiTable = components["schemas"]["Table"];
|
|
11
|
-
|
|
12
11
|
type CloudStorageType = "gcs" | "s3";
|
|
13
12
|
|
|
14
13
|
export interface CloudStorageCredentials {
|
|
@@ -29,7 +28,6 @@ interface CloudStorageObject {
|
|
|
29
28
|
key: string;
|
|
30
29
|
size?: number;
|
|
31
30
|
lastModified?: Date;
|
|
32
|
-
isFolder: boolean;
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
export function gcsConnectionToCredentials(gcsConnection: {
|
|
@@ -92,7 +90,7 @@ function createCloudStorageClient(
|
|
|
92
90
|
return client;
|
|
93
91
|
}
|
|
94
92
|
|
|
95
|
-
|
|
93
|
+
async function listCloudBuckets(
|
|
96
94
|
credentials: CloudStorageCredentials,
|
|
97
95
|
): Promise<CloudStorageBucket[]> {
|
|
98
96
|
const client = createCloudStorageClient(credentials);
|
|
@@ -143,7 +141,6 @@ async function listAllCloudFiles(
|
|
|
143
141
|
key: content.Key,
|
|
144
142
|
size: content.Size,
|
|
145
143
|
lastModified: content.LastModified,
|
|
146
|
-
isFolder: false,
|
|
147
144
|
});
|
|
148
145
|
}
|
|
149
146
|
}
|
|
@@ -182,6 +179,15 @@ function isDataFile(key: string): boolean {
|
|
|
182
179
|
);
|
|
183
180
|
}
|
|
184
181
|
|
|
182
|
+
function buildCloudUri(
|
|
183
|
+
type: CloudStorageType,
|
|
184
|
+
bucket: string,
|
|
185
|
+
key: string,
|
|
186
|
+
): string {
|
|
187
|
+
const scheme = type === "gcs" ? "gs" : "s3";
|
|
188
|
+
return `${scheme}://${bucket}/${key}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
185
191
|
function getFileType(key: string): string {
|
|
186
192
|
const lowerKey = key.toLowerCase();
|
|
187
193
|
if (lowerKey.endsWith(".csv")) return "csv";
|
|
@@ -192,23 +198,14 @@ function getFileType(key: string): string {
|
|
|
192
198
|
return "unknown";
|
|
193
199
|
}
|
|
194
200
|
|
|
195
|
-
function buildCloudUri(
|
|
196
|
-
type: CloudStorageType,
|
|
197
|
-
bucket: string,
|
|
198
|
-
key: string,
|
|
199
|
-
): string {
|
|
200
|
-
const scheme = type === "gcs" ? "gs" : "s3";
|
|
201
|
-
return `${scheme}://${bucket}/${key}`;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
201
|
function standardizeRunSQLResult(result: unknown): unknown[] {
|
|
205
202
|
return Array.isArray(result)
|
|
206
203
|
? result
|
|
207
204
|
: (result as { rows?: unknown[] }).rows || [];
|
|
208
205
|
}
|
|
209
206
|
|
|
210
|
-
// Batch size for parallel schema fetching to avoid overwhelming the connection
|
|
211
207
|
const SCHEMA_FETCH_BATCH_SIZE = 10;
|
|
208
|
+
const BUCKET_SCAN_BATCH_SIZE = 3;
|
|
212
209
|
|
|
213
210
|
async function getTableSchema(
|
|
214
211
|
malloyConnection: Connection,
|
|
@@ -268,11 +265,9 @@ export async function getCloudTablesWithColumns(
|
|
|
268
265
|
): Promise<ApiTable[]> {
|
|
269
266
|
const allTables: ApiTable[] = [];
|
|
270
267
|
|
|
271
|
-
// Process in batches to avoid overwhelming the connection
|
|
272
268
|
for (let i = 0; i < fileKeys.length; i += SCHEMA_FETCH_BATCH_SIZE) {
|
|
273
269
|
const batch = fileKeys.slice(i, i + SCHEMA_FETCH_BATCH_SIZE);
|
|
274
270
|
|
|
275
|
-
// Process batch in parallel
|
|
276
271
|
const batchResults = await Promise.all(
|
|
277
272
|
batch.map((fileKey) =>
|
|
278
273
|
getTableSchema(malloyConnection, credentials, bucketName, fileKey),
|
|
@@ -315,38 +310,118 @@ export function parseCloudUri(uri: string): {
|
|
|
315
310
|
return null;
|
|
316
311
|
}
|
|
317
312
|
|
|
318
|
-
export async function
|
|
313
|
+
export async function listDataFilesInDirectory(
|
|
319
314
|
credentials: CloudStorageCredentials,
|
|
320
315
|
bucketName: string,
|
|
321
316
|
directoryPath: string,
|
|
322
317
|
): Promise<string[]> {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
318
|
+
const prefix = directoryPath ? `${directoryPath}/` : "";
|
|
319
|
+
const client = createCloudStorageClient(credentials);
|
|
320
|
+
const storageType = credentials.type.toUpperCase();
|
|
321
|
+
const dataFiles: string[] = [];
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
let continuationToken: string | undefined;
|
|
325
|
+
|
|
326
|
+
do {
|
|
327
|
+
const response = await client.send(
|
|
328
|
+
new ListObjectsV2Command({
|
|
329
|
+
Bucket: bucketName,
|
|
330
|
+
Prefix: prefix,
|
|
331
|
+
Delimiter: "/",
|
|
332
|
+
ContinuationToken: continuationToken,
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
for (const content of response.Contents || []) {
|
|
337
|
+
if (content.Key && isDataFile(content.Key)) {
|
|
338
|
+
dataFiles.push(content.Key);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
341
|
|
|
342
|
-
|
|
342
|
+
continuationToken = response.IsTruncated
|
|
343
|
+
? response.NextContinuationToken
|
|
344
|
+
: undefined;
|
|
345
|
+
} while (continuationToken);
|
|
346
|
+
|
|
347
|
+
logger.info(
|
|
348
|
+
`Listed ${dataFiles.length} data files in ${storageType} ${bucketName}/${directoryPath}`,
|
|
349
|
+
);
|
|
350
|
+
return dataFiles;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
logger.error(
|
|
353
|
+
`Failed to list files in ${storageType} ${bucketName}/${directoryPath}`,
|
|
354
|
+
{ error },
|
|
355
|
+
);
|
|
356
|
+
throw new Error(
|
|
357
|
+
`Failed to list files in ${storageType} ${bucketName}/${directoryPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
343
360
|
}
|
|
344
361
|
|
|
345
|
-
|
|
346
|
-
|
|
362
|
+
/**
|
|
363
|
+
* Scans an entire bucket and returns unique directory paths that contain data files.
|
|
364
|
+
* Uses flat listing for efficiency — O(total_files / 1000) API calls.
|
|
365
|
+
*/
|
|
366
|
+
async function listDirectorySchemas(
|
|
347
367
|
credentials: CloudStorageCredentials,
|
|
348
368
|
bucketName: string,
|
|
349
369
|
): Promise<string[]> {
|
|
350
|
-
const
|
|
351
|
-
|
|
370
|
+
const allFiles = await listAllCloudFiles(credentials, bucketName);
|
|
371
|
+
const directories = new Set<string>();
|
|
372
|
+
|
|
373
|
+
for (const file of allFiles) {
|
|
374
|
+
if (!isDataFile(file.key)) continue;
|
|
375
|
+
|
|
376
|
+
const lastSlashIndex = file.key.lastIndexOf("/");
|
|
377
|
+
const dir =
|
|
378
|
+
lastSlashIndex > 0 ? file.key.substring(0, lastSlashIndex) : "";
|
|
379
|
+
directories.add(dir);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const scheme = credentials.type === "gcs" ? "gs" : "s3";
|
|
383
|
+
const sortedDirs = Array.from(directories).sort();
|
|
384
|
+
|
|
385
|
+
logger.info(
|
|
386
|
+
`Found ${sortedDirs.length} directories with data files in ${credentials.type.toUpperCase()} bucket ${bucketName}`,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
return sortedDirs.map((dir) =>
|
|
390
|
+
dir ? `${scheme}://${bucketName}/${dir}` : `${scheme}://${bucketName}`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function listCloudDirectorySchemas(
|
|
395
|
+
credentials: CloudStorageCredentials,
|
|
396
|
+
): Promise<{ name: string; isHidden: boolean; isDefault: boolean }[]> {
|
|
397
|
+
const storageType = credentials.type.toUpperCase();
|
|
398
|
+
const buckets = await listCloudBuckets(credentials);
|
|
399
|
+
|
|
400
|
+
logger.info(
|
|
401
|
+
`Listed ${buckets.length} ${storageType} buckets, scanning for directories...`,
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const allDirArrays: string[][] = [];
|
|
405
|
+
|
|
406
|
+
for (let i = 0; i < buckets.length; i += BUCKET_SCAN_BATCH_SIZE) {
|
|
407
|
+
const batch = buckets.slice(i, i + BUCKET_SCAN_BATCH_SIZE);
|
|
408
|
+
const batchResults = await Promise.all(
|
|
409
|
+
batch.map((bucket) =>
|
|
410
|
+
listDirectorySchemas(credentials, bucket.name).catch((err) => {
|
|
411
|
+
logger.warn(
|
|
412
|
+
`Failed to scan ${storageType} bucket ${bucket.name}`,
|
|
413
|
+
{ error: err },
|
|
414
|
+
);
|
|
415
|
+
return [] as string[];
|
|
416
|
+
}),
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
allDirArrays.push(...batchResults);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return allDirArrays.flat().map((dirUri) => ({
|
|
423
|
+
name: dirUri,
|
|
424
|
+
isHidden: false,
|
|
425
|
+
isDefault: false,
|
|
426
|
+
}));
|
|
352
427
|
}
|
package/src/service/model.ts
CHANGED
|
@@ -211,13 +211,13 @@ export class Model {
|
|
|
211
211
|
} catch (error) {
|
|
212
212
|
let computedError = error;
|
|
213
213
|
if (error instanceof Error && error.stack) {
|
|
214
|
-
|
|
214
|
+
logger.error("Error stack", error.stack);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
if (error instanceof MalloyError) {
|
|
218
218
|
const problems = error.problems;
|
|
219
219
|
for (const problem of problems) {
|
|
220
|
-
|
|
220
|
+
logger.error("Problem", problem);
|
|
221
221
|
}
|
|
222
222
|
computedError = new ModelCompilationError(error);
|
|
223
223
|
}
|
|
@@ -444,7 +444,7 @@ export class Model {
|
|
|
444
444
|
const notebookCells: ApiNotebookCell[] = (
|
|
445
445
|
this.runnableNotebookCells as RunnableNotebookCell[]
|
|
446
446
|
).map((cell) => {
|
|
447
|
-
|
|
447
|
+
logger.debug("cell.queryInfo", cell.queryInfo);
|
|
448
448
|
return {
|
|
449
449
|
type: cell.type,
|
|
450
450
|
text: cell.text,
|
|
@@ -550,9 +550,9 @@ export class Model {
|
|
|
550
550
|
text: cell.text,
|
|
551
551
|
};
|
|
552
552
|
} else {
|
|
553
|
-
|
|
553
|
+
logger.error("Error message: ", errorMessage);
|
|
554
554
|
}
|
|
555
|
-
|
|
555
|
+
logger.debug("Cell content: ", cellIndex, cell.type, cell.text);
|
|
556
556
|
throw new BadRequestError(`Cell execution failed: ${errorMessage}`);
|
|
557
557
|
}
|
|
558
558
|
}
|
package/src/service/project.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { LogMessage } from "@malloydata/malloy";
|
|
2
|
+
import { FixedConnectionMap, MalloyError, Runtime } from "@malloydata/malloy";
|
|
1
3
|
import { BaseConnection } from "@malloydata/malloy/connection";
|
|
2
4
|
import { Mutex } from "async-mutex";
|
|
3
5
|
import * as fs from "fs";
|
|
@@ -10,6 +12,7 @@ import {
|
|
|
10
12
|
ProjectNotFoundError,
|
|
11
13
|
} from "../errors";
|
|
12
14
|
import { logger } from "../logger";
|
|
15
|
+
import { URL_READER } from "../utils";
|
|
13
16
|
import { createProjectConnections, InternalConnection } from "./connection";
|
|
14
17
|
import { ApiConnection } from "./model";
|
|
15
18
|
import { Package } from "./package";
|
|
@@ -159,6 +162,71 @@ export class Project {
|
|
|
159
162
|
return this.metadata;
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
public async compileSource(
|
|
166
|
+
packageName: string,
|
|
167
|
+
modelName: string,
|
|
168
|
+
source: string,
|
|
169
|
+
includeSql: boolean = false,
|
|
170
|
+
): Promise<{ problems: LogMessage[]; sql?: string }> {
|
|
171
|
+
// Place the virtual file in the model's directory so relative imports resolve correctly.
|
|
172
|
+
const modelDir = path.dirname(
|
|
173
|
+
path.join(this.projectPath, packageName, modelName),
|
|
174
|
+
);
|
|
175
|
+
const virtualUri = `file://${path.join(modelDir, "__compile_check.malloy")}`;
|
|
176
|
+
const virtualUrl = new URL(virtualUri);
|
|
177
|
+
|
|
178
|
+
// Read the model file and extract its preamble (pragmas + imports) so that
|
|
179
|
+
// the user's query inherits the model's import context.
|
|
180
|
+
const modelPath = path.join(this.projectPath, packageName, modelName);
|
|
181
|
+
const preamble = await extractPreamble(modelPath);
|
|
182
|
+
const fullSource = preamble ? `${preamble}\n${source}` : source;
|
|
183
|
+
|
|
184
|
+
// Create a URL Reader that serves the source string for the virtual file,
|
|
185
|
+
// but falls back to the disk for everything else (imports).
|
|
186
|
+
const interceptingReader = {
|
|
187
|
+
readURL: async (url: URL) => {
|
|
188
|
+
if (url.toString() === virtualUri) {
|
|
189
|
+
return fullSource;
|
|
190
|
+
}
|
|
191
|
+
return URL_READER.readURL(url);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Initialize Runtime with the project's active connections
|
|
196
|
+
const runtime = new Runtime({
|
|
197
|
+
urlReader: interceptingReader,
|
|
198
|
+
connections: new FixedConnectionMap(this.malloyConnections, "duckdb"),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Attempt to compile
|
|
202
|
+
try {
|
|
203
|
+
const modelMaterializer = runtime.loadModel(virtualUrl);
|
|
204
|
+
const model = await modelMaterializer.getModel();
|
|
205
|
+
|
|
206
|
+
// If includeSql is requested and compilation succeeded, attempt to extract SQL
|
|
207
|
+
let sql: string | undefined;
|
|
208
|
+
if (includeSql) {
|
|
209
|
+
try {
|
|
210
|
+
const queryMaterializer = modelMaterializer.loadFinalQuery();
|
|
211
|
+
sql = await queryMaterializer.getSQL();
|
|
212
|
+
} catch {
|
|
213
|
+
// Source may not contain a runnable query (e.g. only source definitions),
|
|
214
|
+
// in which case we simply omit the sql field.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// If successful, return any non-fatal warnings
|
|
219
|
+
return { problems: model.problems, sql };
|
|
220
|
+
} catch (error) {
|
|
221
|
+
// If parsing/compilation fails, return the errors
|
|
222
|
+
if (error instanceof MalloyError) {
|
|
223
|
+
return { problems: error.problems };
|
|
224
|
+
}
|
|
225
|
+
// If it's a system error (e.g. file not found), throw it up
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
162
230
|
public listApiConnections(): ApiConnection[] {
|
|
163
231
|
return this.apiConnections;
|
|
164
232
|
}
|
|
@@ -244,11 +312,18 @@ export class Project {
|
|
|
244
312
|
// package multiple times.
|
|
245
313
|
let packageMutex = this.packageMutexes.get(packageName);
|
|
246
314
|
if (packageMutex?.isLocked()) {
|
|
315
|
+
logger.debug(
|
|
316
|
+
`Package ${packageName} is being loaded, waiting for unlock...`,
|
|
317
|
+
);
|
|
247
318
|
await packageMutex.waitForUnlock();
|
|
319
|
+
logger.debug(`Package ${packageName} unlocked`);
|
|
248
320
|
const existingPackage = this.packages.get(packageName);
|
|
249
321
|
if (existingPackage) {
|
|
322
|
+
logger.debug(`Package ${packageName} loaded by another request`);
|
|
250
323
|
return existingPackage;
|
|
251
324
|
}
|
|
325
|
+
// If package still doesn't exist after unlock, it might have failed to load
|
|
326
|
+
// Continue to try loading it ourselves
|
|
252
327
|
}
|
|
253
328
|
packageMutex = new Mutex();
|
|
254
329
|
this.packageMutexes.set(packageName, packageMutex);
|
|
@@ -264,19 +339,23 @@ export class Project {
|
|
|
264
339
|
this.setPackageStatus(packageName, PackageStatus.LOADING);
|
|
265
340
|
|
|
266
341
|
try {
|
|
342
|
+
logger.debug(`Loading package ${packageName}...`);
|
|
343
|
+
const packagePath = path.join(this.projectPath, packageName);
|
|
267
344
|
const _package = await Package.create(
|
|
268
345
|
this.projectName,
|
|
269
346
|
packageName,
|
|
270
|
-
|
|
347
|
+
packagePath,
|
|
271
348
|
this.malloyConnections,
|
|
272
349
|
);
|
|
273
350
|
this.packages.set(packageName, _package);
|
|
274
351
|
|
|
275
352
|
// Set package status to serving
|
|
276
353
|
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
354
|
+
logger.debug(`Successfully loaded package ${packageName}`);
|
|
277
355
|
|
|
278
356
|
return _package;
|
|
279
357
|
} catch (error) {
|
|
358
|
+
logger.error(`Failed to load package ${packageName}`, { error });
|
|
280
359
|
// Clean up on error - mutex will be automatically released by runExclusive
|
|
281
360
|
this.packages.delete(packageName);
|
|
282
361
|
this.packageStatuses.delete(packageName);
|
|
@@ -537,3 +616,43 @@ export class Project {
|
|
|
537
616
|
});
|
|
538
617
|
}
|
|
539
618
|
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Extracts the preamble from a Malloy model file — the leading block of
|
|
622
|
+
* `##!` pragmas, `import` statements, blank lines, and comments that appear
|
|
623
|
+
* before any `source:`, `query:`, or `run:` definition. This allows a
|
|
624
|
+
* submitted query to inherit the model's import context.
|
|
625
|
+
*/
|
|
626
|
+
export async function extractPreamble(modelPath: string): Promise<string> {
|
|
627
|
+
try {
|
|
628
|
+
const content = await fs.promises.readFile(modelPath, "utf8");
|
|
629
|
+
return extractPreambleFromSource(content);
|
|
630
|
+
} catch {
|
|
631
|
+
// If the model file can't be read, return empty preamble
|
|
632
|
+
// and let the compilation surface any import errors naturally.
|
|
633
|
+
return "";
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Extracts the preamble from Malloy source text. Exported for testing.
|
|
639
|
+
*/
|
|
640
|
+
export function extractPreambleFromSource(content: string): string {
|
|
641
|
+
const lines = content.split("\n");
|
|
642
|
+
const preambleLines: string[] = [];
|
|
643
|
+
|
|
644
|
+
for (const line of lines) {
|
|
645
|
+
const trimmed = line.trim();
|
|
646
|
+
// Stop at the first source/query/run definition
|
|
647
|
+
if (
|
|
648
|
+
trimmed.startsWith("source:") ||
|
|
649
|
+
trimmed.startsWith("query:") ||
|
|
650
|
+
trimmed.startsWith("run:")
|
|
651
|
+
) {
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
preambleLines.push(line);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return preambleLines.join("\n").trimEnd();
|
|
658
|
+
}
|