@malloy-publisher/server 0.0.171 → 0.0.173

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/server.js CHANGED
@@ -220870,6 +220870,47 @@ async function attachCloudStorage(connection, attachedDb) {
220870
220870
  logger.info(`Created ${storageType} secret: ${secretName}`);
220871
220871
  logger.info(`${storageType} connection configured for: ${attachedDb.name}`);
220872
220872
  }
220873
+ async function attachAzureStorage(connection, attachedDb) {
220874
+ if (!attachedDb.azureConnection) {
220875
+ throw new Error(`Azure connection configuration missing for: ${attachedDb.name}`);
220876
+ }
220877
+ const config = attachedDb.azureConnection;
220878
+ const secretName = sanitizeSecretName(`azure_${attachedDb.name}`);
220879
+ let createSecretCommand;
220880
+ if (config.authType === "service_principal") {
220881
+ if (!config.tenantId || !config.clientId || !config.clientSecret || !config.accountName) {
220882
+ throw new Error(`Azure SPN auth requires tenantId, clientId, clientSecret, and accountName for: ${attachedDb.name}`);
220883
+ }
220884
+ const escapedTenantId = escapeSQL(config.tenantId);
220885
+ const escapedClientId = escapeSQL(config.clientId);
220886
+ const escapedClientSecret = escapeSQL(config.clientSecret);
220887
+ const escapedAccountName = escapeSQL(config.accountName);
220888
+ createSecretCommand = `
220889
+ CREATE OR REPLACE SECRET ${secretName} (
220890
+ TYPE azure,
220891
+ PROVIDER service_principal,
220892
+ TENANT_ID '${escapedTenantId}',
220893
+ CLIENT_ID '${escapedClientId}',
220894
+ CLIENT_SECRET '${escapedClientSecret}',
220895
+ ACCOUNT_NAME '${escapedAccountName}'
220896
+ );
220897
+ `;
220898
+ } else if (config.authType === "sas_token") {
220899
+ if (!config.sasUrl) {
220900
+ throw new Error(`Azure SAS token auth requires sasUrl for: ${attachedDb.name}`);
220901
+ }
220902
+ logger.info(`Azure SAS token configured for: ${attachedDb.name} (no secret needed, using direct URL)`);
220903
+ return;
220904
+ } else {
220905
+ throw new Error(`Unsupported Azure auth type: ${config.authType} for: ${attachedDb.name}`);
220906
+ }
220907
+ if (await doesSecretExistInDuckDB(connection, secretName)) {
220908
+ await connection.runSQL(`DETACH ${attachedDb.name};`).catch(() => {});
220909
+ }
220910
+ await connection.runSQL(createSecretCommand);
220911
+ logger.info(`Created Azure secret: ${secretName}`);
220912
+ logger.info(`Azure ADLS connection configured for: ${attachedDb.name}`);
220913
+ }
220873
220914
  async function doesSecretExistInDuckDB(connection, secretName) {
220874
220915
  const escapedSecretName = escapeSQL(secretName);
220875
220916
  const result = await connection.runSQL(`
@@ -220886,8 +220927,14 @@ async function attachDatabasesToDuckDB(duckdbConnection, attachedDatabases) {
220886
220927
  snowflake: attachSnowflake,
220887
220928
  postgres: attachPostgres,
220888
220929
  gcs: attachCloudStorage,
220889
- s3: attachCloudStorage
220930
+ s3: attachCloudStorage,
220931
+ azure: attachAzureStorage
220890
220932
  };
220933
+ const hasAzure = attachedDatabases.some((db) => db.type === "azure");
220934
+ if (hasAzure) {
220935
+ await installAndLoadExtension(duckdbConnection, "azure");
220936
+ await installAndLoadExtension(duckdbConnection, "httpfs");
220937
+ }
220891
220938
  for (const attachedDb of attachedDatabases) {
220892
220939
  try {
220893
220940
  if (await isDatabaseAttached(duckdbConnection, attachedDb.name || "")) {
@@ -220909,6 +220956,60 @@ async function attachDatabasesToDuckDB(duckdbConnection, attachedDatabases) {
220909
220956
  }
220910
220957
  }
220911
220958
  }
220959
+ function buildAzureFileUrl(azureConn, blobName) {
220960
+ if (azureConn.authType === "sas_token" && azureConn.sasUrl) {
220961
+ const qIdx = azureConn.sasUrl.indexOf("?");
220962
+ const baseUrl = qIdx >= 0 ? azureConn.sasUrl.substring(0, qIdx) : azureConn.sasUrl;
220963
+ const token = qIdx >= 0 ? azureConn.sasUrl.substring(qIdx) : "";
220964
+ if (/\.(parquet|csv|json|jsonl|ndjson)$/i.test(baseUrl)) {
220965
+ const dir = baseUrl.replace(/\/[^/]+$/, "");
220966
+ return `${dir}/${blobName}${token}`;
220967
+ }
220968
+ const cleanBase = baseUrl.replace(/\/\*[^/]*$/, "").replace(/\/+$/, "");
220969
+ return `${cleanBase}/${blobName}${token}`;
220970
+ } else if (azureConn.authType === "service_principal" && azureConn.fileUrl) {
220971
+ const url2 = azureConn.fileUrl;
220972
+ if (/\.(parquet|csv|json|jsonl|ndjson)$/i.test(url2)) {
220973
+ return url2.replace(/\/[^/]+$/, `/${blobName}`);
220974
+ }
220975
+ const base = url2.replace(/\*[^/]*$/, "").replace(/\/+$/, "");
220976
+ return `${base}/${blobName}`;
220977
+ }
220978
+ throw new Error(`Cannot build Azure file URL: missing sasUrl or fileUrl in config`);
220979
+ }
220980
+
220981
+ class AzureDuckDBConnection extends import_db_duckdb.DuckDBConnection {
220982
+ azureDatabases;
220983
+ constructor(connectionName, databasePath, workingDirectory, azureDatabases) {
220984
+ super(connectionName, databasePath, workingDirectory);
220985
+ this.azureDatabases = azureDatabases;
220986
+ }
220987
+ async fetchTableSchema(tableKey, tablePath) {
220988
+ const dotIdx = tablePath.indexOf(".");
220989
+ if (dotIdx > 0) {
220990
+ const schemaName = tablePath.substring(0, dotIdx);
220991
+ const blobName = tablePath.substring(dotIdx + 1);
220992
+ const azureDb = this.azureDatabases.find((db) => db.type === "azure" && db.name === schemaName && db.azureConnection);
220993
+ if (azureDb) {
220994
+ const azureUrl = buildAzureFileUrl(azureDb.azureConnection, blobName);
220995
+ logger.debug("Resolved Azure table path", {
220996
+ original: tablePath,
220997
+ resolved: azureUrl
220998
+ });
220999
+ const result2 = await super.fetchTableSchema(tableKey, azureUrl);
221000
+ if (!result2) {
221001
+ throw new Error(`Azure file not found: ${azureUrl}`);
221002
+ }
221003
+ return result2;
221004
+ }
221005
+ }
221006
+ const result = await super.fetchTableSchema(tableKey, tablePath);
221007
+ if (!result) {
221008
+ throw new Error(`Table ${tablePath} not found`);
221009
+ }
221010
+ return result;
221011
+ }
221012
+ }
220912
221013
 
220913
221014
  class DuckLakeConnection extends import_db_duckdb.DuckDBConnection {
220914
221015
  connectionName;
@@ -221096,9 +221197,11 @@ async function createProjectConnections(connections = [], projectPath = "", isUp
221096
221197
  if (connection.duckdbConnection?.attachedDatabases?.length == 0) {
221097
221198
  throw new Error("DuckDB connection must have at least one attached database");
221098
221199
  }
221099
- const duckdbConnection = new import_db_duckdb.DuckDBConnection(connection.name, import_path.default.join(projectPath, `${connection.name}.duckdb`), projectPath);
221100
- if (connection.duckdbConnection.attachedDatabases && Array.isArray(connection.duckdbConnection.attachedDatabases) && connection.duckdbConnection.attachedDatabases.length > 0) {
221101
- await attachDatabasesToDuckDB(duckdbConnection, connection.duckdbConnection.attachedDatabases);
221200
+ const attachedDatabases = connection.duckdbConnection.attachedDatabases ?? [];
221201
+ const hasAzureAttached = attachedDatabases.some((db) => db.type === "azure");
221202
+ const duckdbConnection = hasAzureAttached ? new AzureDuckDBConnection(connection.name, import_path.default.join(projectPath, `${connection.name}.duckdb`), projectPath, attachedDatabases) : new import_db_duckdb.DuckDBConnection(connection.name, import_path.default.join(projectPath, `${connection.name}.duckdb`), projectPath);
221203
+ if (attachedDatabases.length > 0) {
221204
+ await attachDatabasesToDuckDB(duckdbConnection, attachedDatabases);
221102
221205
  }
221103
221206
  connectionMap.set(connection.name, duckdbConnection);
221104
221207
  connection.attributes = getConnectionAttributes(duckdbConnection);
@@ -221193,12 +221296,29 @@ async function testDuckDBConnection(duckdbConnection, connectionConfig) {
221193
221296
  logger.info(`Attached Snowflake database test passed: ${attachedDb.name}`);
221194
221297
  break;
221195
221298
  }
221196
- case "gcs":
221299
+ case "gcs": {
221300
+ await duckdbConnection.runSQL(`SELECT name FROM duckdb_secrets() WHERE name LIKE '%${attachedDb.name}%' LIMIT 1`);
221301
+ logger.info(`Cloud storage credentials test passed: ${attachedDb.name}`);
221302
+ break;
221303
+ }
221197
221304
  case "s3": {
221198
221305
  await duckdbConnection.runSQL(`SELECT name FROM duckdb_secrets() WHERE name LIKE '%${attachedDb.name}%' LIMIT 1`);
221199
221306
  logger.info(`Cloud storage credentials test passed: ${attachedDb.name}`);
221200
221307
  break;
221201
221308
  }
221309
+ case "azure": {
221310
+ const azureConfig = attachedDb.azureConnection;
221311
+ if (azureConfig?.authType === "sas_token") {
221312
+ if (!azureConfig.sasUrl) {
221313
+ throw new Error(`Azure SAS token URL is missing for: ${attachedDb.name}`);
221314
+ }
221315
+ logger.info(`Azure SAS token URL present for: ${attachedDb.name}`);
221316
+ } else {
221317
+ await duckdbConnection.runSQL(`SELECT name FROM duckdb_secrets() WHERE name LIKE '%${attachedDb.name}%' LIMIT 1`);
221318
+ logger.info(`Azure SPN credentials test passed: ${attachedDb.name}`);
221319
+ }
221320
+ break;
221321
+ }
221202
221322
  default: {
221203
221323
  logger.warn(`Unknown attached database type: ${attachedDb.type}`);
221204
221324
  }
@@ -221349,6 +221469,8 @@ class ConnectionService {
221349
221469
  }
221350
221470
 
221351
221471
  // src/service/db_utils.ts
221472
+ var import_identity = require("@azure/identity");
221473
+ var import_storage_blob = require("@azure/storage-blob");
221352
221474
  var import_bigquery = __toESM(require_src121());
221353
221475
 
221354
221476
  // src/service/gcs_s3_utils.ts
@@ -221803,6 +221925,16 @@ async function getSchemasForConnection(connection, malloyConnection) {
221803
221925
  for (const cloudSchemas of cloudSchemaArrays) {
221804
221926
  schemas.push(...cloudSchemas);
221805
221927
  }
221928
+ const azureDatabases = attachedDatabases.filter((attachedDb) => attachedDb.type === "azure" && attachedDb.azureConnection);
221929
+ for (const attachedDb of azureDatabases) {
221930
+ if (attachedDb.name) {
221931
+ schemas.push({
221932
+ name: attachedDb.name,
221933
+ isHidden: false,
221934
+ isDefault: false
221935
+ });
221936
+ }
221937
+ }
221806
221938
  return schemas;
221807
221939
  } catch (error) {
221808
221940
  console.error(`Error getting schemas for DuckDB connection ${connection.name}:`, error);
@@ -221855,7 +221987,181 @@ async function getSchemasForConnection(connection, malloyConnection) {
221855
221987
  throw new Error(`Unsupported connection type: ${connection.type}`);
221856
221988
  }
221857
221989
  }
221990
+ function getFileType2(key) {
221991
+ const lowerKey = key.toLowerCase();
221992
+ if (lowerKey.endsWith(".csv"))
221993
+ return "csv";
221994
+ if (lowerKey.endsWith(".parquet"))
221995
+ return "parquet";
221996
+ if (lowerKey.endsWith(".json"))
221997
+ return "json";
221998
+ if (lowerKey.endsWith(".jsonl") || lowerKey.endsWith(".ndjson"))
221999
+ return "jsonl";
222000
+ return "unknown";
222001
+ }
222002
+ async function listAzureBlobs(fileUrl, azureConnection) {
222003
+ const queryStart = fileUrl.indexOf("?");
222004
+ const baseUrl = queryStart >= 0 ? fileUrl.substring(0, queryStart) : fileUrl;
222005
+ const sasToken = queryStart >= 0 ? fileUrl.substring(queryStart) : "";
222006
+ let accountUrl;
222007
+ let container;
222008
+ let blobPath;
222009
+ if (baseUrl.startsWith("abfss://")) {
222010
+ const withoutScheme = baseUrl.substring("abfss://".length);
222011
+ const parts = withoutScheme.split("/").filter(Boolean);
222012
+ if (parts[0].includes(".")) {
222013
+ const accountName = parts[0].split(".")[0];
222014
+ accountUrl = `https://${accountName}.blob.core.windows.net`;
222015
+ container = parts[1];
222016
+ blobPath = parts.slice(2).join("/");
222017
+ } else {
222018
+ if (!azureConnection?.accountName) {
222019
+ throw new Error("accountName is required to list blobs with abfss:// URLs");
222020
+ }
222021
+ accountUrl = `https://${azureConnection.accountName}.blob.core.windows.net`;
222022
+ container = parts[0];
222023
+ blobPath = parts.slice(1).join("/");
222024
+ }
222025
+ } else {
222026
+ const url2 = new URL(baseUrl);
222027
+ const pathParts = url2.pathname.split("/").filter(Boolean);
222028
+ container = pathParts[0];
222029
+ blobPath = pathParts.slice(1).join("/");
222030
+ accountUrl = `${url2.protocol}//${url2.host}`;
222031
+ }
222032
+ let prefix;
222033
+ let extensionFilter = "";
222034
+ let recursive = true;
222035
+ if (blobPath.endsWith("**")) {
222036
+ prefix = blobPath.slice(0, -2);
222037
+ recursive = true;
222038
+ } else if (blobPath.includes("*")) {
222039
+ const starIndex = blobPath.indexOf("*");
222040
+ prefix = blobPath.substring(0, starIndex);
222041
+ extensionFilter = blobPath.substring(starIndex + 1);
222042
+ recursive = false;
222043
+ } else {
222044
+ prefix = blobPath;
222045
+ recursive = true;
222046
+ }
222047
+ let containerClient;
222048
+ if (azureConnection?.authType === "service_principal" && azureConnection.tenantId && azureConnection.clientId && azureConnection.clientSecret) {
222049
+ const credential = new import_identity.ClientSecretCredential(azureConnection.tenantId, azureConnection.clientId, azureConnection.clientSecret);
222050
+ containerClient = new import_storage_blob.ContainerClient(`${accountUrl}/${container}`, credential);
222051
+ } else {
222052
+ const containerUrl = `${accountUrl}/${container}${sasToken}`;
222053
+ containerClient = new import_storage_blob.ContainerClient(containerUrl);
222054
+ }
222055
+ const matchingFiles = [];
222056
+ for await (const blob of containerClient.listBlobsFlat({
222057
+ prefix: prefix || undefined
222058
+ })) {
222059
+ if (extensionFilter && !blob.name.endsWith(extensionFilter))
222060
+ continue;
222061
+ if (!recursive) {
222062
+ const nameAfterPrefix = blob.name.substring(prefix.length);
222063
+ if (nameAfterPrefix.includes("/"))
222064
+ continue;
222065
+ }
222066
+ if (!isDataFile2(blob.name))
222067
+ continue;
222068
+ let url2;
222069
+ if (azureConnection?.authType === "service_principal") {
222070
+ const account = azureConnection.accountName || accountUrl.split("//")[1]?.split(".")[0];
222071
+ url2 = `abfss://${account}.dfs.core.windows.net/${container}/${blob.name}`;
222072
+ } else {
222073
+ url2 = `${accountUrl}/${container}/${blob.name}${sasToken}`;
222074
+ }
222075
+ matchingFiles.push({ url: url2, blobName: blob.name });
222076
+ }
222077
+ logger.info(`Listed ${matchingFiles.length} matching blobs in Azure container ${container} with prefix "${prefix}"`);
222078
+ return matchingFiles;
222079
+ }
222080
+ function isDataFile2(key) {
222081
+ const lowerKey = key.toLowerCase();
222082
+ return lowerKey.endsWith(".csv") || lowerKey.endsWith(".parquet") || lowerKey.endsWith(".json") || lowerKey.endsWith(".jsonl") || lowerKey.endsWith(".ndjson");
222083
+ }
222084
+ async function describeRemoteFile(malloyConnection, fileUri) {
222085
+ const pathWithoutQuery = fileUri.split("?")[0];
222086
+ const fileType = getFileType2(pathWithoutQuery);
222087
+ let describeQuery;
222088
+ switch (fileType) {
222089
+ case "csv":
222090
+ describeQuery = `DESCRIBE SELECT * FROM read_csv('${fileUri}', auto_detect=true) LIMIT 1`;
222091
+ break;
222092
+ case "parquet":
222093
+ describeQuery = `DESCRIBE SELECT * FROM read_parquet('${fileUri}') LIMIT 1`;
222094
+ break;
222095
+ case "json":
222096
+ describeQuery = `DESCRIBE SELECT * FROM read_json('${fileUri}', auto_detect=true) LIMIT 1`;
222097
+ break;
222098
+ case "jsonl":
222099
+ describeQuery = `DESCRIBE SELECT * FROM read_json('${fileUri}', format='newline_delimited', auto_detect=true) LIMIT 1`;
222100
+ break;
222101
+ default:
222102
+ logger.warn(`Unsupported file type for file: ${fileUri}`);
222103
+ return { resource: fileUri, columns: [] };
222104
+ }
222105
+ const result = await malloyConnection.runSQL(describeQuery);
222106
+ const rows = standardizeRunSQLResult2(result);
222107
+ const columns = rows.map((row) => {
222108
+ const typedRow = row;
222109
+ return {
222110
+ name: typedRow.column_name || typedRow.name,
222111
+ type: typedRow.column_type || typedRow.type
222112
+ };
222113
+ });
222114
+ const fileName = pathWithoutQuery.split("/").pop() || fileUri;
222115
+ return { resource: fileName, columns };
222116
+ }
222117
+ function isAzureSingleFileUrl(fileUri) {
222118
+ const pathWithoutQuery = fileUri.split("?")[0];
222119
+ if (pathWithoutQuery.includes("*"))
222120
+ return false;
222121
+ if (pathWithoutQuery.endsWith("/"))
222122
+ return false;
222123
+ const lastSegment = pathWithoutQuery.split("/").pop() || "";
222124
+ return isDataFile2(lastSegment);
222125
+ }
222126
+ async function describeAzureFile(malloyConnection, fileUri, azureConnection) {
222127
+ try {
222128
+ if (isAzureSingleFileUrl(fileUri)) {
222129
+ return [await describeRemoteFile(malloyConnection, fileUri)];
222130
+ }
222131
+ const blobs = await listAzureBlobs(fileUri, azureConnection);
222132
+ if (blobs.length === 0) {
222133
+ return [{ resource: fileUri, columns: [] }];
222134
+ }
222135
+ const results = await Promise.all(blobs.map(async ({ url: url2, blobName }) => {
222136
+ try {
222137
+ const table = await describeRemoteFile(malloyConnection, url2);
222138
+ return { ...table, resource: blobName };
222139
+ } catch (error) {
222140
+ logger.warn(`Failed to describe Azure blob: ${url2}`, { error });
222141
+ return { resource: blobName, columns: [] };
222142
+ }
222143
+ }));
222144
+ return results;
222145
+ } catch (error) {
222146
+ logger.error(`Failed to describe Azure file: ${fileUri}`, { error });
222147
+ throw new Error(`Failed to describe Azure file: ${error instanceof Error ? error.message : String(error)}`);
222148
+ }
222149
+ }
221858
222150
  async function getTablesForSchema(connection, schemaName, malloyConnection) {
222151
+ if (connection.type === "duckdb") {
222152
+ const attachedDbs = connection.duckdbConnection?.attachedDatabases || [];
222153
+ const azureDb = attachedDbs.find((db) => db.type === "azure" && db.name === schemaName && db.azureConnection);
222154
+ if (azureDb) {
222155
+ const azureConn = azureDb.azureConnection;
222156
+ const fileUrl = azureConn.authType === "sas_token" ? azureConn.sasUrl : azureConn.fileUrl;
222157
+ if (fileUrl) {
222158
+ return await describeAzureFile(malloyConnection, fileUrl, azureConn);
222159
+ }
222160
+ }
222161
+ }
222162
+ if (connection.type === "duckdb" && (schemaName.startsWith("abfss://") || schemaName.startsWith("https://") || schemaName.startsWith("az://"))) {
222163
+ return await describeAzureFile(malloyConnection, schemaName);
222164
+ }
221859
222165
  const parsedUri = parseCloudUri(schemaName);
221860
222166
  if (parsedUri && connection.type === "duckdb") {
221861
222167
  const {
@@ -222124,6 +222430,52 @@ function extractErrorDataFromError(error) {
222124
222430
  }
222125
222431
 
222126
222432
  // src/controller/connection.controller.ts
222433
+ var AZURE_SUPPORTED_SCHEMES = ["https://", "http://", "abfss://", "az://"];
222434
+ var AZURE_DATA_EXTENSIONS = [
222435
+ ".parquet",
222436
+ ".csv",
222437
+ ".json",
222438
+ ".jsonl",
222439
+ ".ndjson"
222440
+ ];
222441
+ function validateAzureUrl(url2, fieldName) {
222442
+ if (!AZURE_SUPPORTED_SCHEMES.some((s) => url2.startsWith(s))) {
222443
+ throw new BadRequestError(`Azure ${fieldName} must use one of: ${AZURE_SUPPORTED_SCHEMES.join(", ")}`);
222444
+ }
222445
+ const pathWithoutQuery = url2.split("?")[0];
222446
+ const stars = (pathWithoutQuery.match(/\*/g) || []).length;
222447
+ if (stars === 0) {
222448
+ const lower = pathWithoutQuery.toLowerCase();
222449
+ if (!AZURE_DATA_EXTENSIONS.some((ext) => lower.endsWith(ext))) {
222450
+ throw new BadRequestError(`Azure ${fieldName}: a single-file URL must end with a data file extension (${AZURE_DATA_EXTENSIONS.join(", ")})`);
222451
+ }
222452
+ } else if (pathWithoutQuery.endsWith("**")) {} else {
222453
+ const lastSegment = pathWithoutQuery.split("/").pop() || "";
222454
+ if (stars !== 1 || !lastSegment.startsWith("*")) {
222455
+ throw new BadRequestError(`Azure ${fieldName}: only three URL patterns are supported:
222456
+ ` + ` • Single file: path/file.parquet
222457
+ ` + ` • Directory glob: path/*.ext (direct children only)
222458
+ ` + ` • Recursive: path/** (all data files in subtree)
222459
+ ` + `Multi-level globs such as "sub_dir/*/*.parquet" are not supported.`);
222460
+ }
222461
+ }
222462
+ }
222463
+ function validateAzureAttachedDatabases(connectionConfig) {
222464
+ if (connectionConfig.type !== "duckdb")
222465
+ return;
222466
+ const attachedDbs = connectionConfig.duckdbConnection?.attachedDatabases || [];
222467
+ for (const db of attachedDbs) {
222468
+ if (db.type !== "azure" || !db.azureConnection)
222469
+ continue;
222470
+ const { authType, sasUrl, fileUrl } = db.azureConnection;
222471
+ if (authType === "sas_token" && sasUrl) {
222472
+ validateAzureUrl(sasUrl, `"${db.name}" sasUrl`);
222473
+ } else if (authType === "service_principal" && fileUrl) {
222474
+ validateAzureUrl(fileUrl, `"${db.name}" fileUrl`);
222475
+ }
222476
+ }
222477
+ }
222478
+
222127
222479
  class ConnectionController {
222128
222480
  projectStore;
222129
222481
  connectionService;
@@ -222191,7 +222543,8 @@ class ConnectionController {
222191
222543
  }
222192
222544
  async getTable(projectName, connectionName, schemaName, tablePath) {
222193
222545
  const malloyConnection = await this.getMalloyConnection(projectName, connectionName);
222194
- const connection = await this.getConnection(projectName, connectionName);
222546
+ const project = await this.projectStore.getProject(projectName, false);
222547
+ const connection = project.getApiConnection(connectionName);
222195
222548
  if (connection.type === "ducklake") {
222196
222549
  if (tablePath.split(".").length === 1) {
222197
222550
  tablePath = `${connectionName}.${schemaName}.${tablePath}`;
@@ -222199,6 +222552,28 @@ class ConnectionController {
222199
222552
  tablePath = `${connectionName}.${tablePath}`;
222200
222553
  }
222201
222554
  }
222555
+ if (connection.type === "duckdb") {
222556
+ const attachedDbs = connection.duckdbConnection?.attachedDatabases || [];
222557
+ const azureDb = attachedDbs.find((db) => db.type === "azure" && db.name === schemaName && db.azureConnection);
222558
+ if (azureDb && azureDb.azureConnection) {
222559
+ const azureConn = azureDb.azureConnection;
222560
+ const baseUrl = azureConn.authType === "sas_token" ? azureConn.sasUrl : azureConn.fileUrl;
222561
+ if (baseUrl) {
222562
+ const fileName = tablePath.includes(".") ? tablePath.split(".").slice(1).join(".") : tablePath;
222563
+ const urlParts = baseUrl.split("?");
222564
+ const basePath = urlParts[0];
222565
+ const queryString = urlParts[1] ? `?${urlParts[1]}` : "";
222566
+ const dirPath = basePath.substring(0, basePath.lastIndexOf("/") + 1);
222567
+ const fullFileUrl = `${dirPath}${fileName}${queryString}`;
222568
+ const tableSource2 = await getConnectionTableSource(malloyConnection, fileName, fullFileUrl);
222569
+ return {
222570
+ resource: tablePath,
222571
+ columns: tableSource2.columns,
222572
+ source: tableSource2.source
222573
+ };
222574
+ }
222575
+ }
222576
+ }
222202
222577
  const tableKey = tablePath.split(".").pop();
222203
222578
  if (!tableKey) {
222204
222579
  throw new Error(`Invalid tablePath: ${tablePath}`);
@@ -222239,6 +222614,9 @@ class ConnectionController {
222239
222614
  }
222240
222615
  }
222241
222616
  async testConnectionConfiguration(connectionConfig) {
222617
+ if (connectionConfig && "config" in connectionConfig && typeof connectionConfig.config === "object") {
222618
+ connectionConfig = connectionConfig.config;
222619
+ }
222242
222620
  if (!connectionConfig || typeof connectionConfig !== "object" || Object.keys(connectionConfig).length === 0) {
222243
222621
  throw new BadRequestError("Connection configuration is required and cannot be empty");
222244
222622
  }
@@ -222264,6 +222642,7 @@ class ConnectionController {
222264
222642
  if (!connectionConfig.type) {
222265
222643
  throw new BadRequestError("Connection type is required");
222266
222644
  }
222645
+ validateAzureAttachedDatabases(connectionConfig);
222267
222646
  logger.info(`Creating connection "${connectionName}" in project "${projectName}"`);
222268
222647
  await this.connectionService.addConnection(projectName, connectionName, connectionConfig);
222269
222648
  return {
@@ -222274,6 +222653,7 @@ class ConnectionController {
222274
222653
  if (!connection || typeof connection !== "object") {
222275
222654
  throw new BadRequestError("Connection payload is required");
222276
222655
  }
222656
+ validateAzureAttachedDatabases(connection);
222277
222657
  logger.info(`Updating connection "${connectionName}" in project "${projectName}"`);
222278
222658
  await this.connectionService.updateConnection(projectName, connectionName, connection);
222279
222659
  return {
@@ -230039,8 +230419,11 @@ class Project {
230039
230419
  const virtualUri = `file://${path7.join(modelDir, "__compile_check.malloy")}`;
230040
230420
  const virtualUrl = new URL(virtualUri);
230041
230421
  const modelPath = path7.join(this.projectPath, packageName, modelName);
230042
- const preamble = await extractPreamble(modelPath);
230043
- const fullSource = preamble ? `${preamble}
230422
+ let modelContent = "";
230423
+ try {
230424
+ modelContent = await fs6.promises.readFile(modelPath, "utf8");
230425
+ } catch {}
230426
+ const fullSource = modelContent ? `${modelContent}
230044
230427
  ${source}` : source;
230045
230428
  const interceptingReader = {
230046
230429
  readURL: async (url2) => {
@@ -230319,30 +230702,55 @@ ${source}` : source;
230319
230702
  logger.info(`Removed DuckLake connection ${connectionName} from project ${this.projectName}`);
230320
230703
  }
230321
230704
  }
230322
- async function extractPreamble(modelPath) {
230323
- try {
230324
- const content = await fs6.promises.readFile(modelPath, "utf8");
230325
- return extractPreambleFromSource(content);
230326
- } catch {
230327
- return "";
230705
+
230706
+ // src/service/project_store.ts
230707
+ var AZURE_SUPPORTED_SCHEMES2 = ["https://", "http://", "abfss://", "az://"];
230708
+ var AZURE_DATA_EXTENSIONS2 = [
230709
+ ".parquet",
230710
+ ".csv",
230711
+ ".json",
230712
+ ".jsonl",
230713
+ ".ndjson"
230714
+ ];
230715
+ function validateAzureUrl2(url2, fieldName) {
230716
+ if (!AZURE_SUPPORTED_SCHEMES2.some((s) => url2.startsWith(s))) {
230717
+ throw new BadRequestError(`Azure ${fieldName} must use one of: ${AZURE_SUPPORTED_SCHEMES2.join(", ")}`);
230718
+ }
230719
+ const pathWithoutQuery = url2.split("?")[0];
230720
+ const stars = (pathWithoutQuery.match(/\*/g) || []).length;
230721
+ if (stars === 0) {
230722
+ const lower = pathWithoutQuery.toLowerCase();
230723
+ if (!AZURE_DATA_EXTENSIONS2.some((ext) => lower.endsWith(ext))) {
230724
+ throw new BadRequestError(`Azure ${fieldName}: a single-file URL must end with a data file extension (${AZURE_DATA_EXTENSIONS2.join(", ")})`);
230725
+ }
230726
+ } else if (pathWithoutQuery.endsWith("**")) {} else {
230727
+ const lastSegment = pathWithoutQuery.split("/").pop() || "";
230728
+ if (stars !== 1 || !lastSegment.startsWith("*")) {
230729
+ throw new BadRequestError(`Azure ${fieldName}: only three URL patterns are supported:
230730
+ ` + ` • Single file: path/file.parquet
230731
+ ` + ` • Directory glob: path/*.ext (direct children only)
230732
+ ` + ` • Recursive: path/** (includes all data files in the container and all subdirectories)
230733
+ ` + `Multi-level globs such as "sub_dir/*/*.parquet" are not supported.`);
230734
+ }
230328
230735
  }
230329
230736
  }
230330
- function extractPreambleFromSource(content) {
230331
- const lines = content.split(`
230332
- `);
230333
- const preambleLines = [];
230334
- for (const line of lines) {
230335
- const trimmed2 = line.trim();
230336
- if (trimmed2.startsWith("source:") || trimmed2.startsWith("query:") || trimmed2.startsWith("run:")) {
230337
- break;
230737
+ function validateProjectAzureUrls(project) {
230738
+ for (const conn of project.connections || []) {
230739
+ if (conn.type !== "duckdb")
230740
+ continue;
230741
+ for (const db of conn.duckdbConnection?.attachedDatabases || []) {
230742
+ if (db.type !== "azure" || !db.azureConnection)
230743
+ continue;
230744
+ const { authType, sasUrl, fileUrl } = db.azureConnection;
230745
+ if (authType === "sas_token" && sasUrl) {
230746
+ validateAzureUrl2(sasUrl, `"${db.name}" sasUrl`);
230747
+ } else if (authType === "service_principal" && fileUrl) {
230748
+ validateAzureUrl2(fileUrl, `"${db.name}" fileUrl`);
230749
+ }
230338
230750
  }
230339
- preambleLines.push(line);
230340
230751
  }
230341
- return preambleLines.join(`
230342
- `).trimEnd();
230343
230752
  }
230344
230753
 
230345
- // src/service/project_store.ts
230346
230754
  class ProjectStore {
230347
230755
  serverRootPath;
230348
230756
  projects = new Map;
@@ -230800,6 +231208,7 @@ class ProjectStore {
230800
231208
  if (this.publisherConfigIsFrozen) {
230801
231209
  throw new FrozenConfigError;
230802
231210
  }
231211
+ validateProjectAzureUrls(project);
230803
231212
  const projectName = project.name;
230804
231213
  if (!projectName) {
230805
231214
  throw new Error("Project name is required");
@@ -231254,8 +231663,8 @@ class WatchModeController {
231254
231663
  getWatchStatus = async (_req, res) => {
231255
231664
  return res.json({
231256
231665
  enabled: !!this.watchingPath,
231257
- watchingPath: this.watchingPath,
231258
- projectName: this.watchingProjectName ?? undefined
231666
+ watchingPath: this.watchingPath ?? "",
231667
+ projectName: this.watchingProjectName ?? ""
231259
231668
  });
231260
231669
  };
231261
231670
  startWatching = async (req, res) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.171",
4
+ "version": "0.0.173",
5
5
  "main": "dist/server.js",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.js"
@@ -29,6 +29,8 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@aws-sdk/client-s3": "^3.958.0",
32
+ "@azure/identity": "^4.13.0",
33
+ "@azure/storage-blob": "^12.26.0",
32
34
  "@google-cloud/storage": "^7.16.0",
33
35
  "@malloydata/db-bigquery": "^0.0.358",
34
36
  "@malloydata/db-duckdb": "^0.0.358",