@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.
Files changed (33) hide show
  1. package/dist/app/api-doc.yaml +107 -0
  2. package/dist/app/assets/{HomePage-QekMXs8r.js → HomePage-D76UaGFV.js} +1 -1
  3. package/dist/app/assets/{MainPage-DAyUfYba.js → MainPage-C9Fr5IN8.js} +1 -1
  4. package/dist/app/assets/{ModelPage-CrMryV1s.js → ModelPage-BkU6HAHA.js} +1 -1
  5. package/dist/app/assets/{PackagePage-DDaABD2A.js → PackagePage-BhE9Wi7b.js} +1 -1
  6. package/dist/app/assets/{ProjectPage-FAYUFGhL.js → ProjectPage-BatZLVap.js} +1 -1
  7. package/dist/app/assets/{RouteError-BKYctANX.js → RouteError-Bo5zJ8Xa.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-DZEVYGW3.js → WorkbookPage-D3rUQZj6.js} +1 -1
  9. package/dist/app/assets/{index-BvVmB5sv.js → index-BLxl0XLH.js} +71 -71
  10. package/dist/app/assets/{index-DWhjtyBB.js → index-hkABoiMV.js} +1 -1
  11. package/dist/app/assets/{index-CsC07BYd.js → index-lhDwptrQ.js} +1 -1
  12. package/dist/app/assets/{index.umd-DvM-lTQa.js → index.umd-BkXQ-YAe.js} +1 -1
  13. package/dist/app/index.html +1 -1
  14. package/dist/instrumentation.js +85955 -88560
  15. package/dist/server.js +197162 -106231
  16. package/package.json +2 -1
  17. package/src/controller/compile.controller.ts +35 -0
  18. package/src/controller/model.controller.ts +20 -9
  19. package/src/health.ts +8 -0
  20. package/src/instrumentation.ts +123 -34
  21. package/src/server.ts +44 -2
  22. package/src/service/connection.spec.ts +1226 -0
  23. package/src/service/connection.ts +114 -12
  24. package/src/service/db_utils.ts +19 -41
  25. package/src/service/gcs_s3_utils.ts +115 -40
  26. package/src/service/model.ts +5 -5
  27. package/src/service/project.ts +120 -1
  28. package/src/service/project_compile.spec.ts +197 -0
  29. package/src/service/project_store.ts +49 -21
  30. package/src/storage/StorageManager.ts +4 -3
  31. package/src/storage/duckdb/schema.ts +6 -5
  32. package/tests/harness/e2e.ts +4 -0
  33. 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
- // Test the connection - cast to union type of connection classes that have test method
886
- await (
887
- connection as
888
- | PostgresConnection
889
- | BigQueryConnection
890
- | SnowflakeConnection
891
- | TrinoConnection
892
- | MySQLConnection
893
- | DuckDBConnection
894
- ).test();
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",
@@ -7,9 +7,8 @@ import {
7
7
  CloudStorageCredentials,
8
8
  gcsConnectionToCredentials,
9
9
  getCloudTablesWithColumns,
10
- listAllDataFilesInBucket,
11
- listCloudBuckets,
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
- const buckets = await listCloudBuckets(credentials);
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()} buckets for ${attachedDb.name}`,
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
- let fileKeys: string[];
448
- if (directoryPath) {
449
- const fileNames = await listFilesInCloudDirectory(
450
- credentials,
451
- bucketName,
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.info(`Successfully fetched schema for ${tablePath}`, {
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
- if (directoryPath) {
752
- return await listFilesInCloudDirectory(
753
- credentials,
754
- bucketName,
755
- directoryPath,
756
- );
757
- } else {
758
- return await listAllDataFilesInBucket(credentials, bucketName);
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
- export async function listCloudBuckets(
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 listFilesInCloudDirectory(
313
+ export async function listDataFilesInDirectory(
319
314
  credentials: CloudStorageCredentials,
320
315
  bucketName: string,
321
316
  directoryPath: string,
322
317
  ): Promise<string[]> {
323
- const files = await listAllCloudFiles(credentials, bucketName);
324
-
325
- const filesInDirectory = files
326
- .filter((obj) => {
327
- if (!isDataFile(obj.key)) return false;
328
-
329
- const lastSlashIndex = obj.key.lastIndexOf("/");
330
- const fileDir =
331
- lastSlashIndex > 0 ? obj.key.substring(0, lastSlashIndex) : "";
332
-
333
- return fileDir === directoryPath;
334
- })
335
- .map((obj) => {
336
- const lastSlashIndex = obj.key.lastIndexOf("/");
337
- return lastSlashIndex > 0
338
- ? obj.key.substring(lastSlashIndex + 1)
339
- : obj.key;
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
- return filesInDirectory;
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
- // List all data files in a bucket with their full relative paths
346
- export async function listAllDataFilesInBucket(
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 files = await listAllCloudFiles(credentials, bucketName);
351
- return files.filter((obj) => isDataFile(obj.key)).map((obj) => obj.key);
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
  }
@@ -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
- console.error("Error stack", error.stack);
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
- console.error("Problem", problem);
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
- console.log("cell.queryInfo", cell.queryInfo);
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
- console.log("Error message: ", errorMessage);
553
+ logger.error("Error message: ", errorMessage);
554
554
  }
555
- console.log("Cell content: ", cellIndex, cell.type, cell.text);
555
+ logger.debug("Cell content: ", cellIndex, cell.type, cell.text);
556
556
  throw new BadRequestError(`Cell execution failed: ${errorMessage}`);
557
557
  }
558
558
  }
@@ -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
- path.join(this.projectPath, packageName),
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
+ }