@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/build.ts +2 -0
- package/dist/app/api-doc.yaml +48 -4
- package/dist/app/assets/HomePage-Q4kzZesl.js +1 -0
- package/dist/app/assets/{MainPage-rmI25cDG.js → MainPage-BRd1ffqx.js} +1 -1
- package/dist/app/assets/ModelPage-_cFj4QLf.js +1 -0
- package/dist/app/assets/{PackagePage-C3oW8KXe.js → PackagePage-DI3p7cpq.js} +1 -1
- package/dist/app/assets/ProjectPage-4K_Nlx3j.js +1 -0
- package/dist/app/assets/{RouteError-BKfQ9y-k.js → RouteError-A5gz--Jr.js} +1 -1
- package/dist/app/assets/WorkbookPage-BF625z3S.js +1 -0
- package/dist/app/assets/{index-DUmIwFQ_.js → index-CGKwxuak.js} +110 -110
- package/dist/app/assets/{index-LGQlLlFF.js → index-DHOyIUsr.js} +1 -1
- package/dist/app/assets/{index-DJ4KYt-l.js → index-DHvz0hCo.js} +1 -1
- package/dist/app/assets/{index.umd-BCEVvpz1.js → index.umd-Dc3ElUSE.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.js +437 -28
- package/package.json +3 -1
- package/src/controller/connection.controller.ts +129 -1
- package/src/controller/watch-mode.controller.ts +2 -2
- package/src/service/connection.ts +230 -13
- package/src/service/db_utils.ts +288 -0
- package/src/service/project.ts +10 -4
- package/src/service/project_store.ts +59 -0
- package/dist/app/assets/HomePage-oXJcPThQ.js +0 -1
- package/dist/app/assets/ModelPage-DtywEBbr.js +0 -1
- package/dist/app/assets/ProjectPage-CvmjaWsE.js +0 -1
- package/dist/app/assets/WorkbookPage-NNNUEWM5.js +0 -1
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
|
|
221100
|
-
|
|
221101
|
-
|
|
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
|
|
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
|
-
|
|
230043
|
-
|
|
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
|
-
|
|
230323
|
-
|
|
230324
|
-
|
|
230325
|
-
|
|
230326
|
-
|
|
230327
|
-
|
|
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
|
|
230331
|
-
const
|
|
230332
|
-
|
|
230333
|
-
|
|
230334
|
-
|
|
230335
|
-
|
|
230336
|
-
|
|
230337
|
-
|
|
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 ??
|
|
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.
|
|
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",
|