@malloy-publisher/server 0.9.5 → 0.9.7
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 +157 -21
- package/dist/app/assets/{EnvironmentPage-wGTml4WZ.js → EnvironmentPage-V6cQ_Kpr.js} +1 -1
- package/dist/app/assets/{HomePage-BPFJHBxN.js → HomePage-BM31X4Y8.js} +1 -1
- package/dist/app/assets/{MainPage-CNLgQje2.js → MainPage-CQW3P4wB.js} +1 -1
- package/dist/app/assets/{ModelPage-Br6I_s4J.js → ModelPage-dBHaHb6u.js} +2 -2
- package/dist/app/assets/{PackagePage-B6lmG1Uq.js → PackagePage-B-ruDCyR.js} +1 -1
- package/dist/app/assets/{RenderedResult-BUb2Xvr2.es-17Y63pbB.js → RenderedResult-CkUS04tl.es-CNz9JsEK.js} +1 -1
- package/dist/app/assets/{RouteError-B4yUUZFj.js → RouteError-DLJlCtRG.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CjK8EVnV.js → ThemeEditorPage-Ck4rJ1vv.js} +1 -1
- package/dist/app/assets/{ToggleButtonGroup-BKiI79mw.js → ToggleButtonGroup-Bwj7gIJm.js} +1 -1
- package/dist/app/assets/{documentRoutes-D_78AAbq.js → documentRoutes-DGm_-EUy.js} +1 -1
- package/dist/app/assets/{index-B3woRPfQ.js → index-BcsfsLm3.js} +1 -1
- package/dist/app/assets/{index-dQ3jNgKw.js → index-D3HZqxOW.js} +1 -1
- package/dist/app/assets/{index-BIqIsMFr.js → index-hnA8HjeH.js} +5 -5
- package/dist/app/assets/{index-CfX148tj.js → index-wawGd6k1.js} +1 -1
- package/dist/app/assets/{index.es-Bhhg6pWY.js → index.es-B2kJpnhe.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +119 -9
- package/dist/server.mjs +961 -199
- package/package.json +2 -2
- package/dist/sshcrypto-cqb52edz.node +0 -0
package/dist/server.mjs
CHANGED
|
@@ -205143,7 +205143,7 @@ function installRecordPath(environmentPath, packageName) {
|
|
|
205143
205143
|
function normalizeModelPath(p) {
|
|
205144
205144
|
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
205145
205145
|
}
|
|
205146
|
-
var API_PREFIX = "/api/v0", README_NAME = "README.md", PUBLISHER_CONFIG_NAME = "publisher.config.json", PACKAGE_MANIFEST_NAME = "publisher.json", PACKAGE_INSTALL_RECORDS_DIR = ".install-records", MODEL_FILE_SUFFIX = ".malloy", NOTEBOOK_FILE_SUFFIX = ".malloynb", INDEX_MODEL_NAME = "index.malloy", DEFAULT_QUERY_ROW_LIMIT = 1000, DEFAULT_MAX_QUERY_ROWS = 1e5, DEFAULT_MAX_RESPONSE_BYTES = 50000000, DEFAULT_QUERY_TIMEOUT_MS = 300000, DEFAULT_MAX_CONCURRENT_QUERIES = 32, TEMP_DIR_PATH, PUBLISHER_DATA_DIR = "publisher_data";
|
|
205146
|
+
var API_PREFIX = "/api/v0", README_NAME = "README.md", PUBLISHER_CONFIG_NAME = "publisher.config.json", PACKAGE_MANIFEST_NAME = "publisher.json", PACKAGE_INSTALL_RECORDS_DIR = ".install-records", MODEL_FILE_SUFFIX = ".malloy", NOTEBOOK_FILE_SUFFIX = ".malloynb", GIVENS_DOCS_URL = "https://github.com/malloydata/publisher/blob/main/docs/givens.md", INDEX_MODEL_NAME = "index.malloy", DEFAULT_QUERY_ROW_LIMIT = 1000, DEFAULT_MAX_QUERY_ROWS = 1e5, DEFAULT_MAX_RESPONSE_BYTES = 50000000, DEFAULT_QUERY_TIMEOUT_MS = 300000, DEFAULT_MAX_CONCURRENT_QUERIES = 32, DEFAULT_POSTGRES_POOL_MAX = 5, TEMP_DIR_PATH, PUBLISHER_DATA_DIR = "publisher_data";
|
|
205147
205147
|
var init_constants = __esm(() => {
|
|
205148
205148
|
TEMP_DIR_PATH = os.tmpdir();
|
|
205149
205149
|
});
|
|
@@ -205174,6 +205174,59 @@ function filesystemAccessFailure(error2) {
|
|
|
205174
205174
|
}
|
|
205175
205175
|
return;
|
|
205176
205176
|
}
|
|
205177
|
+
function isMysqlConfigFault(code) {
|
|
205178
|
+
return typeof code === "string" && (MYSQL_CONFIG_CODES.has(code) || code.startsWith("HANDSHAKE_"));
|
|
205179
|
+
}
|
|
205180
|
+
function isConnectionFailure(error2) {
|
|
205181
|
+
let current = error2;
|
|
205182
|
+
for (let depth = 0;depth < 8 && current instanceof Error; depth++) {
|
|
205183
|
+
const { code, fatal } = current;
|
|
205184
|
+
if (typeof code === "string") {
|
|
205185
|
+
if (NODE_CONNECTION_CODES.has(code))
|
|
205186
|
+
return true;
|
|
205187
|
+
if (POSTGRES_CONNECTION_SQLSTATE.test(code))
|
|
205188
|
+
return true;
|
|
205189
|
+
}
|
|
205190
|
+
if (fatal === true && !isMysqlConfigFault(code))
|
|
205191
|
+
return true;
|
|
205192
|
+
const message = current.message.trim();
|
|
205193
|
+
if (CODELESS_CONNECTION_MESSAGES.some((re) => re.test(message))) {
|
|
205194
|
+
return true;
|
|
205195
|
+
}
|
|
205196
|
+
current = current.cause;
|
|
205197
|
+
}
|
|
205198
|
+
return false;
|
|
205199
|
+
}
|
|
205200
|
+
function isCredentialRejection(error2) {
|
|
205201
|
+
let current = error2;
|
|
205202
|
+
for (let depth = 0;depth < 8 && current instanceof Error; depth++) {
|
|
205203
|
+
const { code } = current;
|
|
205204
|
+
if (typeof code === "string" || typeof code === "number") {
|
|
205205
|
+
const value = String(code);
|
|
205206
|
+
if (POSTGRES_AUTH_SQLSTATE.test(value))
|
|
205207
|
+
return true;
|
|
205208
|
+
if (MYSQL_AUTH_CODES.has(value))
|
|
205209
|
+
return true;
|
|
205210
|
+
if (SNOWFLAKE_AUTH_CODES.has(value))
|
|
205211
|
+
return true;
|
|
205212
|
+
}
|
|
205213
|
+
if (current.name === "BigQueryAuthenticationError")
|
|
205214
|
+
return true;
|
|
205215
|
+
current = current.cause;
|
|
205216
|
+
}
|
|
205217
|
+
return false;
|
|
205218
|
+
}
|
|
205219
|
+
function databaseAccessFailure(error2) {
|
|
205220
|
+
if (error2 instanceof ConnectionAuthError || error2 instanceof ConnectionFailedError || error2 instanceof UnconfiguredConnectionError) {
|
|
205221
|
+
return error2;
|
|
205222
|
+
}
|
|
205223
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
205224
|
+
if (isCredentialRejection(error2))
|
|
205225
|
+
return new ConnectionAuthError(message);
|
|
205226
|
+
if (isConnectionFailure(error2))
|
|
205227
|
+
return new ConnectionFailedError(message);
|
|
205228
|
+
return;
|
|
205229
|
+
}
|
|
205177
205230
|
function describeFilesystemAccessFailure(errno) {
|
|
205178
205231
|
const code = errno.code ?? "";
|
|
205179
205232
|
const target = errno.path ? ` '${errno.path}'` : "";
|
|
@@ -205243,7 +205296,13 @@ function internalErrorToHttpError(error2, { log = true } = {}) {
|
|
|
205243
205296
|
} else if (error2 instanceof DestinationNotFoundError) {
|
|
205244
205297
|
return httpError(422, error2.message);
|
|
205245
205298
|
} else if (error2 instanceof ConnectionAuthError) {
|
|
205246
|
-
|
|
205299
|
+
logInternal("Connection credentials rejected", error2, "warn");
|
|
205300
|
+
return httpError(424, GENERIC_AUTH_MESSAGE, "CONNECTION_AUTH_FAILED");
|
|
205301
|
+
} else if (error2 instanceof ConnectionFailedError) {
|
|
205302
|
+
logInternal("Database unreachable", error2, "warn");
|
|
205303
|
+
return httpError(502, GENERIC_UNREACHABLE_MESSAGE, "CONNECTION_FAILED");
|
|
205304
|
+
} else if (error2 instanceof UnconfiguredConnectionError) {
|
|
205305
|
+
return httpError(424, error2.message, "CONNECTION_NOT_FOUND");
|
|
205247
205306
|
} else if (error2 instanceof UnsupportedCatalogFormatError) {
|
|
205248
205307
|
return httpError(422, error2.message);
|
|
205249
205308
|
} else if (error2 instanceof MaterializationEligibilityError) {
|
|
@@ -205300,7 +205359,7 @@ function toQueryTextProblem(problem) {
|
|
|
205300
205359
|
...problem.at ? { at: { range: problem.at.range } } : {}
|
|
205301
205360
|
};
|
|
205302
205361
|
}
|
|
205303
|
-
var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", MAX_LOGGED_DETAIL_CHARS = 2000, FILESYSTEM_ACCESS_DESCRIPTIONS, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, RenderTagRefusedError, UnparseableTextError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, ChainedUpstreamMissingError, ChainedShapeNotCarriedError, PublisherConfigError, ServerConfigurationError, FrozenConfigError, AccessDeniedError, QueryCompileError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, WriteVerifyError, InvalidStateTransitionError, ServiceUnavailableError, PackageAdmissionRefusedError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
|
|
205362
|
+
var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", GENERIC_UNREACHABLE_MESSAGE = "The database connection is down: the database could not be reached, so the query did not run.", GENERIC_AUTH_MESSAGE = "The database rejected the connection's credentials. Check the connection's user, password, key or token.", MAX_LOGGED_DETAIL_CHARS = 2000, FILESYSTEM_ACCESS_DESCRIPTIONS, NODE_CONNECTION_CODES, POSTGRES_CONNECTION_SQLSTATE, CODELESS_CONNECTION_MESSAGES, MYSQL_CONFIG_CODES, POSTGRES_AUTH_SQLSTATE, MYSQL_AUTH_CODES, SNOWFLAKE_AUTH_CODES, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, RenderTagRefusedError, UnparseableTextError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, ConnectionFailedError, UnconfiguredConnectionError, ConnectionPoolExhaustedError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, ChainedUpstreamMissingError, ChainedShapeNotCarriedError, PublisherConfigError, ServerConfigurationError, FrozenConfigError, AccessDeniedError, QueryCompileError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, WriteVerifyError, InvalidStateTransitionError, ServiceUnavailableError, PackageAdmissionRefusedError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
|
|
205304
205363
|
var init_errors = __esm(() => {
|
|
205305
205364
|
init_constants();
|
|
205306
205365
|
init_logger();
|
|
@@ -205309,6 +205368,35 @@ var init_errors = __esm(() => {
|
|
|
205309
205368
|
EPERM: "operation not permitted",
|
|
205310
205369
|
EROFS: "read-only file system"
|
|
205311
205370
|
};
|
|
205371
|
+
NODE_CONNECTION_CODES = new Set([
|
|
205372
|
+
"ECONNREFUSED",
|
|
205373
|
+
"ECONNRESET",
|
|
205374
|
+
"ECONNABORTED",
|
|
205375
|
+
"EPIPE",
|
|
205376
|
+
"ETIMEDOUT",
|
|
205377
|
+
"EAI_AGAIN",
|
|
205378
|
+
"EHOSTUNREACH",
|
|
205379
|
+
"ENETUNREACH"
|
|
205380
|
+
]);
|
|
205381
|
+
POSTGRES_CONNECTION_SQLSTATE = /^(08[0-9A-Z]{3}|57P0[123])$/;
|
|
205382
|
+
CODELESS_CONNECTION_MESSAGES = [
|
|
205383
|
+
/^Connection terminated unexpectedly$/,
|
|
205384
|
+
/^(Error: )?Can't add new command when connection is in closed state$/,
|
|
205385
|
+
/^(Error: )?Connection lost: The server closed the connection\.$/,
|
|
205386
|
+
new RegExp(`^(Error: )?(connect|read|write|getaddrinfo) (${[
|
|
205387
|
+
...NODE_CONNECTION_CODES
|
|
205388
|
+
].join("|")})\\b`)
|
|
205389
|
+
];
|
|
205390
|
+
MYSQL_CONFIG_CODES = new Set([
|
|
205391
|
+
"ER_ACCESS_DENIED_ERROR",
|
|
205392
|
+
"ER_BAD_DB_ERROR",
|
|
205393
|
+
"ER_NOT_SUPPORTED_AUTH_MODE",
|
|
205394
|
+
"AUTH_SWITCH_PLUGIN_ERROR",
|
|
205395
|
+
"MYSQL_CLEAR_PASSWORD_NOT_ENABLED"
|
|
205396
|
+
]);
|
|
205397
|
+
POSTGRES_AUTH_SQLSTATE = /^28[0-9A-Z]{3}$/;
|
|
205398
|
+
MYSQL_AUTH_CODES = new Set(["ER_ACCESS_DENIED_ERROR"]);
|
|
205399
|
+
SNOWFLAKE_AUTH_CODES = new Set(["390100", "390144", "390195", "390318"]);
|
|
205312
205400
|
NotImplementedError = class NotImplementedError extends Error {
|
|
205313
205401
|
constructor(message) {
|
|
205314
205402
|
super(message);
|
|
@@ -205333,8 +205421,10 @@ var init_errors = __esm(() => {
|
|
|
205333
205421
|
}
|
|
205334
205422
|
};
|
|
205335
205423
|
EnvironmentNotFoundError = class EnvironmentNotFoundError extends Error {
|
|
205336
|
-
|
|
205424
|
+
lookup;
|
|
205425
|
+
constructor(message, lookup) {
|
|
205337
205426
|
super(message);
|
|
205427
|
+
this.lookup = lookup;
|
|
205338
205428
|
}
|
|
205339
205429
|
};
|
|
205340
205430
|
PackageNotFoundError = class PackageNotFoundError extends Error {
|
|
@@ -205369,6 +205459,19 @@ var init_errors = __esm(() => {
|
|
|
205369
205459
|
this.callerSafe = options?.callerSafe ?? false;
|
|
205370
205460
|
}
|
|
205371
205461
|
};
|
|
205462
|
+
ConnectionFailedError = class ConnectionFailedError extends ConnectionError {
|
|
205463
|
+
};
|
|
205464
|
+
UnconfiguredConnectionError = class UnconfiguredConnectionError extends Error {
|
|
205465
|
+
constructor(name, options) {
|
|
205466
|
+
super(`No connection named "${name}" found in config`, options);
|
|
205467
|
+
}
|
|
205468
|
+
};
|
|
205469
|
+
ConnectionPoolExhaustedError = class ConnectionPoolExhaustedError extends ConnectionError {
|
|
205470
|
+
constructor(message) {
|
|
205471
|
+
super(message, { callerSafe: true });
|
|
205472
|
+
this.name = "ConnectionPoolExhaustedError";
|
|
205473
|
+
}
|
|
205474
|
+
};
|
|
205372
205475
|
DestinationNotFoundError = class DestinationNotFoundError extends Error {
|
|
205373
205476
|
constructor(message) {
|
|
205374
205477
|
super(message);
|
|
@@ -206192,6 +206295,22 @@ function processConfigValue(value) {
|
|
|
206192
206295
|
}
|
|
206193
206296
|
return value;
|
|
206194
206297
|
}
|
|
206298
|
+
function sanitizeMcpConfig(raw) {
|
|
206299
|
+
if (raw === undefined)
|
|
206300
|
+
return;
|
|
206301
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
206302
|
+
logger.warn(`Invalid "mcp" in ${PUBLISHER_CONFIG_NAME}: expected an object. Ignoring.`);
|
|
206303
|
+
return;
|
|
206304
|
+
}
|
|
206305
|
+
const mcp = {};
|
|
206306
|
+
const value = raw.includeHiddenFilesAndSources;
|
|
206307
|
+
if (typeof value === "boolean") {
|
|
206308
|
+
mcp.includeHiddenFilesAndSources = value;
|
|
206309
|
+
} else if (value !== undefined) {
|
|
206310
|
+
logger.warn(`Invalid "mcp.includeHiddenFilesAndSources" in ${PUBLISHER_CONFIG_NAME}: expected a boolean (got ${JSON.stringify(value)}). Ignoring field.`);
|
|
206311
|
+
}
|
|
206312
|
+
return Object.keys(mcp).length > 0 ? mcp : undefined;
|
|
206313
|
+
}
|
|
206195
206314
|
function sanitizeTheme(raw, context) {
|
|
206196
206315
|
if (raw === undefined || raw === null)
|
|
206197
206316
|
return;
|
|
@@ -206423,6 +206542,14 @@ var BUNDLED_DEFAULT_CONFIG_PATH, PER_MODE_COLOR_KEYS, DEFAULT_HIGH_WATER_FRACTIO
|
|
|
206423
206542
|
throw new Error(`PUBLISHER_MAX_CONCURRENT_QUERIES must be a non-negative integer (got ${raw})`);
|
|
206424
206543
|
}
|
|
206425
206544
|
return raw;
|
|
206545
|
+
}, getPostgresPoolMax = () => {
|
|
206546
|
+
const raw = parseIntEnv("PUBLISHER_POSTGRES_POOL_MAX");
|
|
206547
|
+
if (raw === undefined)
|
|
206548
|
+
return DEFAULT_POSTGRES_POOL_MAX;
|
|
206549
|
+
if (raw < 1) {
|
|
206550
|
+
throw new Error(`PUBLISHER_POSTGRES_POOL_MAX must be a positive integer (got ${raw})`);
|
|
206551
|
+
}
|
|
206552
|
+
return raw;
|
|
206426
206553
|
}, getExtensionFetchPolicy = () => {
|
|
206427
206554
|
const raw = process.env.EXTENSION_FETCH_POLICY;
|
|
206428
206555
|
if (raw === undefined || raw.trim() === "")
|
|
@@ -206536,10 +206663,12 @@ var BUNDLED_DEFAULT_CONFIG_PATH, PER_MODE_COLOR_KEYS, DEFAULT_HIGH_WATER_FRACTIO
|
|
|
206536
206663
|
frozenConfig = Boolean(processedConfig.frozenConfig);
|
|
206537
206664
|
}
|
|
206538
206665
|
const instanceTheme = sanitizeTheme(processedConfig && typeof processedConfig === "object" && "theme" in processedConfig ? processedConfig.theme : undefined, "publisher.config.json");
|
|
206666
|
+
const mcp = sanitizeMcpConfig(processedConfig && typeof processedConfig === "object" && "mcp" in processedConfig ? processedConfig.mcp : undefined);
|
|
206539
206667
|
const retrieval = parseRetrievalConfig(processedConfig && typeof processedConfig === "object" ? processedConfig.retrieval : undefined);
|
|
206540
206668
|
return {
|
|
206541
206669
|
frozenConfig,
|
|
206542
206670
|
...instanceTheme ? { theme: instanceTheme } : {},
|
|
206671
|
+
...mcp ? { mcp } : {},
|
|
206543
206672
|
...retrieval ? { retrieval } : {},
|
|
206544
206673
|
environments
|
|
206545
206674
|
};
|
|
@@ -206551,6 +206680,13 @@ var BUNDLED_DEFAULT_CONFIG_PATH, PER_MODE_COLOR_KEYS, DEFAULT_HIGH_WATER_FRACTIO
|
|
|
206551
206680
|
logger.error(`Error checking if ${PUBLISHER_CONFIG_NAME} is frozen. Defaulting to false.`, { error: error2 instanceof Error ? error2.message : String(error2) });
|
|
206552
206681
|
return false;
|
|
206553
206682
|
}
|
|
206683
|
+
}, isMcpIncludeHiddenFilesAndSources = (serverRoot) => {
|
|
206684
|
+
try {
|
|
206685
|
+
return getPublisherConfig(serverRoot).mcp?.includeHiddenFilesAndSources === true;
|
|
206686
|
+
} catch (error2) {
|
|
206687
|
+
logger.error(`Error reading "mcp" from ${PUBLISHER_CONFIG_NAME}. Defaulting to off.`, { error: error2 instanceof Error ? error2.message : String(error2) });
|
|
206688
|
+
return false;
|
|
206689
|
+
}
|
|
206554
206690
|
}, convertConnectionsToApiConnections = (connections) => {
|
|
206555
206691
|
if (!Array.isArray(connections)) {
|
|
206556
206692
|
return [];
|
|
@@ -269114,11 +269250,6 @@ var require_utils93 = __commonJS((exports, module) => {
|
|
|
269114
269250
|
};
|
|
269115
269251
|
});
|
|
269116
269252
|
|
|
269117
|
-
// ../../node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
|
|
269118
|
-
var require_sshcrypto = __commonJS((exports, module) => {
|
|
269119
|
-
module.exports = __require("./sshcrypto-cqb52edz.node");
|
|
269120
|
-
});
|
|
269121
|
-
|
|
269122
269253
|
// ../../node_modules/ssh2/lib/protocol/crypto/poly1305.js
|
|
269123
269254
|
var require_poly1305 = __commonJS((exports, module) => {
|
|
269124
269255
|
var __dirname = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto", __filename = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto/poly1305.js";
|
|
@@ -269605,7 +269736,7 @@ var require_crypto = __commonJS((exports, module) => {
|
|
|
269605
269736
|
var ChaChaPolyDecipher;
|
|
269606
269737
|
var GenericDecipher;
|
|
269607
269738
|
try {
|
|
269608
|
-
binding =
|
|
269739
|
+
binding = (()=>{throw new Error("Cannot require module "+"./crypto/build/Release/sshcrypto.node");})();
|
|
269609
269740
|
({
|
|
269610
269741
|
AESGCMCipher,
|
|
269611
269742
|
ChaChaPolyCipher,
|
|
@@ -283563,6 +283694,26 @@ var init_query_metadata = __esm(() => {
|
|
|
283563
283694
|
import { createPrivateKey } from "crypto";
|
|
283564
283695
|
import { existsSync } from "fs";
|
|
283565
283696
|
import path4 from "path";
|
|
283697
|
+
function validatePostgresSslmode(subject, sslmode) {
|
|
283698
|
+
if (!PROXIED_SSLMODES.includes(sslmode)) {
|
|
283699
|
+
throw new Error(`${subject} has unsupported sslmode '${sslmode}' ` + `(expected ${PROXIED_SSLMODES.join(" | ")}).`);
|
|
283700
|
+
}
|
|
283701
|
+
if (sslmode === "verify-ca") {
|
|
283702
|
+
const caBundle = process.env.NODE_EXTRA_CA_CERTS;
|
|
283703
|
+
if (!caBundle || !existsSync(caBundle)) {
|
|
283704
|
+
throw new Error(`${subject} uses sslmode 'verify-ca' but no readable ` + `CA bundle is available (NODE_EXTRA_CA_CERTS is unset or points at a missing file). ` + `Add the CA bundle to the image or use sslmode 'no-verify'.`);
|
|
283705
|
+
}
|
|
283706
|
+
}
|
|
283707
|
+
}
|
|
283708
|
+
function postgresStatementTimeoutMs(name, pg) {
|
|
283709
|
+
const value = pg.statementTimeoutMilliseconds;
|
|
283710
|
+
if (value === undefined || value === null)
|
|
283711
|
+
return;
|
|
283712
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > POSTGRES_MAX_STATEMENT_TIMEOUT_MS) {
|
|
283713
|
+
throw new Error(`Connection '${name}' has an invalid statementTimeoutMilliseconds ` + `${JSON.stringify(value)} (expected an integer from 1 to ${POSTGRES_MAX_STATEMENT_TIMEOUT_MS}).`);
|
|
283714
|
+
}
|
|
283715
|
+
return value;
|
|
283716
|
+
}
|
|
283566
283717
|
function nullToUndefined(value) {
|
|
283567
283718
|
return value ?? undefined;
|
|
283568
283719
|
}
|
|
@@ -283657,7 +283808,7 @@ function getStaticConnectionAttributes(type) {
|
|
|
283657
283808
|
case "postgres":
|
|
283658
283809
|
return {
|
|
283659
283810
|
dialectName: "postgres",
|
|
283660
|
-
isPool:
|
|
283811
|
+
isPool: true,
|
|
283661
283812
|
canPersist: true,
|
|
283662
283813
|
canStream: true
|
|
283663
283814
|
};
|
|
@@ -283724,12 +283875,34 @@ function parseServiceAccountKey(json2) {
|
|
|
283724
283875
|
}
|
|
283725
283876
|
return keyData;
|
|
283726
283877
|
}
|
|
283727
|
-
function
|
|
283728
|
-
|
|
283878
|
+
function directSslParams(sslmode) {
|
|
283879
|
+
switch (sslmode) {
|
|
283880
|
+
case "disable":
|
|
283881
|
+
case "no-verify":
|
|
283882
|
+
case "verify-full":
|
|
283883
|
+
return { sslmode };
|
|
283884
|
+
case "verify-ca":
|
|
283885
|
+
return {
|
|
283886
|
+
uselibpqcompat: "true",
|
|
283887
|
+
sslmode: "verify-ca",
|
|
283888
|
+
sslrootcert: process.env.NODE_EXTRA_CA_CERTS ?? ""
|
|
283889
|
+
};
|
|
283890
|
+
}
|
|
283891
|
+
}
|
|
283892
|
+
function buildPostgresConnectionString(config2, { applySslmode }) {
|
|
283893
|
+
if (config2.connectionString) {
|
|
283729
283894
|
return config2.connectionString;
|
|
283730
283895
|
}
|
|
283731
283896
|
const params = new URLSearchParams;
|
|
283732
|
-
|
|
283897
|
+
if (applySslmode && config2.sslmode != null) {
|
|
283898
|
+
for (const [key, value] of Object.entries(directSslParams(config2.sslmode))) {
|
|
283899
|
+
params.set(key, value);
|
|
283900
|
+
}
|
|
283901
|
+
} else if (process.env.PGSSLMODE) {
|
|
283902
|
+
params.set("sslmode", process.env.PGSSLMODE);
|
|
283903
|
+
} else {
|
|
283904
|
+
return;
|
|
283905
|
+
}
|
|
283733
283906
|
const auth = config2.userName && config2.password ? `${encodeURIComponent(config2.userName)}:${encodeURIComponent(config2.password)}@` : config2.userName ? `${encodeURIComponent(config2.userName)}@` : "";
|
|
283734
283907
|
const host = config2.host ?? "localhost";
|
|
283735
283908
|
const port = config2.port ? `:${config2.port}` : "";
|
|
@@ -283800,23 +283973,21 @@ function validateConnectionShape(connection) {
|
|
|
283800
283973
|
}
|
|
283801
283974
|
const sslmode = connection.postgresConnection?.sslmode;
|
|
283802
283975
|
if (sslmode != null) {
|
|
283803
|
-
|
|
283804
|
-
throw new Error(`Connection proxy on '${connection.name}' has unsupported sslmode '${sslmode}' ` + `(expected ${PROXIED_SSLMODES.join(" | ")}).`);
|
|
283805
|
-
}
|
|
283806
|
-
if (sslmode === "verify-ca") {
|
|
283807
|
-
const caBundle = process.env.NODE_EXTRA_CA_CERTS;
|
|
283808
|
-
if (!caBundle || !existsSync(caBundle)) {
|
|
283809
|
-
throw new Error(`Connection proxy on '${connection.name}' uses sslmode 'verify-ca' but no readable ` + `CA bundle is available (NODE_EXTRA_CA_CERTS is unset or points at a missing file). ` + `Add the CA bundle to the image or use sslmode 'no-verify'.`);
|
|
283810
|
-
}
|
|
283811
|
-
}
|
|
283976
|
+
validatePostgresSslmode(`Connection proxy on '${connection.name}'`, sslmode);
|
|
283812
283977
|
}
|
|
283813
283978
|
const dbName = connection.postgresConnection?.databaseName;
|
|
283814
283979
|
if (dbName && decodeURI(encodeURIComponent(dbName)) !== dbName) {
|
|
283815
283980
|
throw new Error(`Connection proxy on '${connection.name}' has a database name with characters that can't ` + `be carried over a proxied connection (${JSON.stringify(dbName)}). Use a database name ` + `without URI-reserved characters (; , / ? : @ & = + $ #).`);
|
|
283816
283981
|
}
|
|
283817
283982
|
}
|
|
283818
|
-
if (!connection.proxy && connection.postgresConnection?.sslmode) {
|
|
283819
|
-
|
|
283983
|
+
if (!connection.proxy && connection.postgresConnection?.sslmode != null) {
|
|
283984
|
+
validatePostgresSslmode(`Connection '${connection.name}'`, connection.postgresConnection.sslmode);
|
|
283985
|
+
if (connection.postgresConnection.connectionString) {
|
|
283986
|
+
logger.warn(`Connection '${connection.name}' sets both postgresConnection.sslmode and ` + `connectionString; sslmode is ignored and the connectionString's own sslmode applies.`);
|
|
283987
|
+
}
|
|
283988
|
+
}
|
|
283989
|
+
if (connection.postgresConnection) {
|
|
283990
|
+
postgresStatementTimeoutMs(connection.name ?? "", connection.postgresConnection);
|
|
283820
283991
|
}
|
|
283821
283992
|
switch (connection.type) {
|
|
283822
283993
|
case "postgres":
|
|
@@ -284095,7 +284266,6 @@ function assembleEnvironmentConnections(connections = [], environmentPath = "")
|
|
|
284095
284266
|
metadata.set(connection.name, {
|
|
284096
284267
|
apiConnection,
|
|
284097
284268
|
attachedDatabases,
|
|
284098
|
-
setupSQL: connection.duckdbConnection?.setupSQL,
|
|
284099
284269
|
hasAzureAttachment: attachedDatabases.some((database) => database.type === "azure"),
|
|
284100
284270
|
hasSnowflakePrivateKey: connection.type === "snowflake" && !!connection.snowflakeConnection?.privateKey,
|
|
284101
284271
|
isDuckLake,
|
|
@@ -284113,7 +284283,9 @@ function assembleEnvironmentConnections(connections = [], environmentPath = "")
|
|
|
284113
284283
|
username: postgresConnection?.userName,
|
|
284114
284284
|
password: postgresConnection?.password,
|
|
284115
284285
|
databaseName: postgresConnection?.databaseName,
|
|
284116
|
-
connectionString: postgresConnection ? buildPostgresConnectionString(postgresConnection
|
|
284286
|
+
connectionString: postgresConnection ? buildPostgresConnectionString(postgresConnection, {
|
|
284287
|
+
applySslmode: !connection.proxy
|
|
284288
|
+
}) : undefined
|
|
284117
284289
|
};
|
|
284118
284290
|
break;
|
|
284119
284291
|
}
|
|
@@ -284252,7 +284424,7 @@ function validateAndBuildTrinoCoreConfig(trinoConfig) {
|
|
|
284252
284424
|
}
|
|
284253
284425
|
throw new Error(`Invalid Trino connection: expected "http://server:port" or "https://server:port".`);
|
|
284254
284426
|
}
|
|
284255
|
-
var PROXIED_SSLMODES, PUBLISHER_DUCKDB_API_FIELDS, DECLARABLE_STORAGE_DESTINATION_TYPES, STORAGE_DESTINATIONS_DIR = ".storage-destinations";
|
|
284427
|
+
var PROXIED_SSLMODES, POSTGRES_MAX_STATEMENT_TIMEOUT_MS = 2147483647, PUBLISHER_DUCKDB_API_FIELDS, DECLARABLE_STORAGE_DESTINATION_TYPES, STORAGE_DESTINATIONS_DIR = ".storage-destinations";
|
|
284256
284428
|
var init_connection_config = __esm(() => {
|
|
284257
284429
|
init_config();
|
|
284258
284430
|
init_errors();
|
|
@@ -294890,9 +295062,117 @@ async function attachSnowflake(connection, attachedDb) {
|
|
|
294890
295062
|
await connection.runSQL(attachCommand);
|
|
294891
295063
|
logger.info(`Successfully attached Snowflake database: ${attachedDb.name}`);
|
|
294892
295064
|
}
|
|
294893
|
-
function
|
|
295065
|
+
function mergeStatementTimeoutOption(existing, ms) {
|
|
295066
|
+
const tokens = existing.split(/(?<!\\)\s+/).filter((t2) => t2 !== "");
|
|
295067
|
+
const kept = [];
|
|
295068
|
+
let replaced = false;
|
|
295069
|
+
for (let i2 = 0;i2 < tokens.length; i2++) {
|
|
295070
|
+
if (tokens[i2] === "-c" && /^statement[_-]timeout=/i.test(tokens[i2 + 1] ?? "")) {
|
|
295071
|
+
i2++;
|
|
295072
|
+
replaced = true;
|
|
295073
|
+
} else if (STATEMENT_TIMEOUT_SWITCH.test(tokens[i2])) {
|
|
295074
|
+
replaced = true;
|
|
295075
|
+
} else {
|
|
295076
|
+
kept.push(tokens[i2]);
|
|
295077
|
+
}
|
|
295078
|
+
}
|
|
295079
|
+
kept.push("-c", `statement_timeout=${ms}`);
|
|
295080
|
+
return { value: kept.join(" "), replaced };
|
|
295081
|
+
}
|
|
295082
|
+
function conninfoPairs(conninfo) {
|
|
295083
|
+
const pairs = [];
|
|
295084
|
+
const isSpace = (c) => c !== undefined && /\s/.test(c);
|
|
295085
|
+
let i2 = 0;
|
|
295086
|
+
while (i2 < conninfo.length) {
|
|
295087
|
+
while (isSpace(conninfo[i2]))
|
|
295088
|
+
i2++;
|
|
295089
|
+
if (i2 >= conninfo.length)
|
|
295090
|
+
break;
|
|
295091
|
+
const start = i2;
|
|
295092
|
+
let key = "";
|
|
295093
|
+
while (i2 < conninfo.length && conninfo[i2] !== "=" && !isSpace(conninfo[i2])) {
|
|
295094
|
+
key += conninfo[i2++];
|
|
295095
|
+
}
|
|
295096
|
+
while (isSpace(conninfo[i2]))
|
|
295097
|
+
i2++;
|
|
295098
|
+
if (conninfo[i2] !== "=")
|
|
295099
|
+
break;
|
|
295100
|
+
i2++;
|
|
295101
|
+
while (isSpace(conninfo[i2]))
|
|
295102
|
+
i2++;
|
|
295103
|
+
let value = "";
|
|
295104
|
+
if (conninfo[i2] === "'") {
|
|
295105
|
+
i2++;
|
|
295106
|
+
while (i2 < conninfo.length && conninfo[i2] !== "'") {
|
|
295107
|
+
if (conninfo[i2] === "\\" && i2 + 1 < conninfo.length)
|
|
295108
|
+
i2++;
|
|
295109
|
+
value += conninfo[i2++];
|
|
295110
|
+
}
|
|
295111
|
+
i2++;
|
|
295112
|
+
} else {
|
|
295113
|
+
while (i2 < conninfo.length && !isSpace(conninfo[i2])) {
|
|
295114
|
+
if (conninfo[i2] === "\\" && i2 + 1 < conninfo.length)
|
|
295115
|
+
i2++;
|
|
295116
|
+
value += conninfo[i2++];
|
|
295117
|
+
}
|
|
295118
|
+
}
|
|
295119
|
+
pairs.push({ key, value, start, end: Math.min(i2, conninfo.length) });
|
|
295120
|
+
}
|
|
295121
|
+
return pairs;
|
|
295122
|
+
}
|
|
295123
|
+
function decodeUriComponentOrRaw(value) {
|
|
295124
|
+
try {
|
|
295125
|
+
return decodeURIComponent(value);
|
|
295126
|
+
} catch {
|
|
295127
|
+
return value;
|
|
295128
|
+
}
|
|
295129
|
+
}
|
|
295130
|
+
function withLibpqStatementTimeout(name, connectionString, pg) {
|
|
295131
|
+
const ms = postgresStatementTimeoutMs(name, pg);
|
|
295132
|
+
if (ms === undefined)
|
|
295133
|
+
return connectionString;
|
|
295134
|
+
const merged = (existing2) => {
|
|
295135
|
+
if (existing2 === undefined)
|
|
295136
|
+
return `-c statement_timeout=${ms}`;
|
|
295137
|
+
const { value, replaced } = mergeStatementTimeoutOption(existing2, ms);
|
|
295138
|
+
if (replaced) {
|
|
295139
|
+
logger.warn(`Connection '${name}' sets statementTimeoutMilliseconds and its connectionString's ` + `options also set statement_timeout; statementTimeoutMilliseconds applies.`);
|
|
295140
|
+
}
|
|
295141
|
+
return value;
|
|
295142
|
+
};
|
|
295143
|
+
if (/^postgres(ql)?:\/\//i.test(connectionString)) {
|
|
295144
|
+
const queryStart = connectionString.indexOf("?");
|
|
295145
|
+
if (queryStart === -1) {
|
|
295146
|
+
return `${connectionString}?options=${encodeURIComponent(merged(undefined))}`;
|
|
295147
|
+
}
|
|
295148
|
+
const params = connectionString.slice(queryStart + 1).split("&");
|
|
295149
|
+
const isOptions = (param) => decodeUriComponentOrRaw(param.split("=", 1)[0]) === "options";
|
|
295150
|
+
const last = params.map(isOptions).lastIndexOf(true);
|
|
295151
|
+
if (last === -1) {
|
|
295152
|
+
params.push(`options=${encodeURIComponent(merged(undefined))}`);
|
|
295153
|
+
} else {
|
|
295154
|
+
const raw = params[last].slice(params[last].indexOf("=") + 1);
|
|
295155
|
+
params[last] = `options=${encodeURIComponent(merged(decodeUriComponentOrRaw(raw)))}`;
|
|
295156
|
+
}
|
|
295157
|
+
return `${connectionString.slice(0, queryStart)}?${params.join("&")}`;
|
|
295158
|
+
}
|
|
295159
|
+
const existing = conninfoPairs(connectionString).filter((pair) => pair.key === "options").pop();
|
|
295160
|
+
if (!existing) {
|
|
295161
|
+
return `${connectionString} ${pgConninfoPair("options", merged(undefined))}`;
|
|
295162
|
+
}
|
|
295163
|
+
return connectionString.slice(0, existing.start) + pgConninfoPair("options", merged(existing.value)) + connectionString.slice(existing.end);
|
|
295164
|
+
}
|
|
295165
|
+
function buildPgConnectionString(pg, {
|
|
295166
|
+
name,
|
|
295167
|
+
applyConnectionOptions
|
|
295168
|
+
}) {
|
|
294894
295169
|
if (pg.connectionString) {
|
|
294895
|
-
|
|
295170
|
+
if (!applyConnectionOptions)
|
|
295171
|
+
return pg.connectionString;
|
|
295172
|
+
if (pg.sslmode != null) {
|
|
295173
|
+
logger.warn(`Connection '${name}' sets both postgresConnection.sslmode and ` + `connectionString; sslmode is ignored and the connectionString's own sslmode applies.`);
|
|
295174
|
+
}
|
|
295175
|
+
return withLibpqStatementTimeout(name, pg.connectionString, pg);
|
|
294896
295176
|
}
|
|
294897
295177
|
const parts = [];
|
|
294898
295178
|
if (pg.host)
|
|
@@ -294905,6 +295185,13 @@ function buildPgConnectionString(pg) {
|
|
|
294905
295185
|
parts.push(`user=${pg.userName}`);
|
|
294906
295186
|
if (pg.password)
|
|
294907
295187
|
parts.push(`password=${pg.password}`);
|
|
295188
|
+
if (applyConnectionOptions) {
|
|
295189
|
+
parts.push(...libpqStatementTimeoutParts(name, pg));
|
|
295190
|
+
if (pg.sslmode != null) {
|
|
295191
|
+
parts.push(...libpqSslmodeParts(`Connection '${name}'`, pg.sslmode, defaultProxiedAttachTrust()));
|
|
295192
|
+
return parts.join(" ");
|
|
295193
|
+
}
|
|
295194
|
+
}
|
|
294908
295195
|
const pgSSLMode = process.env.PGSSLMODE;
|
|
294909
295196
|
if (pgSSLMode) {
|
|
294910
295197
|
const mapping = {
|
|
@@ -294931,7 +295218,10 @@ async function attachPostgres(connection, attachedDb) {
|
|
|
294931
295218
|
}
|
|
294932
295219
|
await installAndLoadExtension(connection, "postgres");
|
|
294933
295220
|
const config2 = attachedDb.postgresConnection;
|
|
294934
|
-
const attachString = buildPgConnectionString(config2
|
|
295221
|
+
const attachString = buildPgConnectionString(config2, {
|
|
295222
|
+
name: attachedDb.name,
|
|
295223
|
+
applyConnectionOptions: true
|
|
295224
|
+
});
|
|
294935
295225
|
const attachCommand = `ATTACH '${escapeSQL(attachString)}' AS ${quoteIdentifier(attachedDb.name, "duckdb")} (TYPE postgres, READ_ONLY);`;
|
|
294936
295226
|
await connection.runSQL(attachCommand);
|
|
294937
295227
|
logger.info(`Successfully attached PostgreSQL database: ${attachedDb.name}`);
|
|
@@ -295026,7 +295316,10 @@ async function attachDuckLakeWithMode(connection, dbName, ducklakeConfig, option
|
|
|
295026
295316
|
});
|
|
295027
295317
|
}
|
|
295028
295318
|
const pg = ducklakeConfig.catalog.postgresConnection;
|
|
295029
|
-
const pgConnString = buildPgConnectionString(pg
|
|
295319
|
+
const pgConnString = buildPgConnectionString(pg, {
|
|
295320
|
+
name: dbName,
|
|
295321
|
+
applyConnectionOptions: false
|
|
295322
|
+
});
|
|
295030
295323
|
const mode = options.readOnly ? "READ_ONLY" : "READ_WRITE";
|
|
295031
295324
|
logger.debug(`pgConnString: ${redactPgSecrets(pgConnString)}`);
|
|
295032
295325
|
const escapedPgConnString = escapeSQL(pgConnString);
|
|
@@ -295193,7 +295486,10 @@ async function federatePostgres(connection, config2, deps = {}) {
|
|
|
295193
295486
|
});
|
|
295194
295487
|
attachString = buildProxiedPgAttachString(config2.name, pg, endpoint);
|
|
295195
295488
|
} else {
|
|
295196
|
-
attachString = buildPgConnectionString(pg
|
|
295489
|
+
attachString = buildPgConnectionString(pg, {
|
|
295490
|
+
name: config2.name,
|
|
295491
|
+
applyConnectionOptions: true
|
|
295492
|
+
});
|
|
295197
295493
|
}
|
|
295198
295494
|
logger.info(`Federating Postgres source for passthrough as alias '${alias}'${endpoint ? " through its SSH proxy" : ""}: ${redactPgSecrets(attachString)}`);
|
|
295199
295495
|
await connection.runSQL(`ATTACH OR REPLACE '${escapeSQL(attachString)}' AS ${quoteIdentifier(alias, "duckdb")} (TYPE postgres, READ_ONLY);`);
|
|
@@ -295256,30 +295552,39 @@ function buildProxiedPgAttachString(name, pg, endpoint, trust = defaultProxiedAt
|
|
|
295256
295552
|
parts.push(pgConninfoPair("user", pg.userName));
|
|
295257
295553
|
if (pg.password)
|
|
295258
295554
|
parts.push(pgConninfoPair("password", pg.password));
|
|
295259
|
-
|
|
295555
|
+
parts.push(...libpqSslmodeParts(`Connection proxy on '${name}'`, pg.sslmode ?? "no-verify", trust));
|
|
295556
|
+
parts.push(...libpqStatementTimeoutParts(name, pg));
|
|
295557
|
+
return parts.join(" ");
|
|
295558
|
+
}
|
|
295559
|
+
function libpqSslmodeParts(subject, mode, trust) {
|
|
295260
295560
|
switch (mode) {
|
|
295261
295561
|
case "disable":
|
|
295262
|
-
|
|
295263
|
-
break;
|
|
295562
|
+
return ["sslmode=disable"];
|
|
295264
295563
|
case "no-verify":
|
|
295265
|
-
|
|
295266
|
-
break;
|
|
295564
|
+
return ["sslmode=require"];
|
|
295267
295565
|
case "verify-ca": {
|
|
295268
295566
|
if (!trust.caBundle) {
|
|
295269
|
-
throw new Error(
|
|
295567
|
+
throw new Error(`${subject} uses sslmode 'verify-ca' but no trusted CA bundle is available (NODE_EXTRA_CA_CERTS is unset).`);
|
|
295270
295568
|
}
|
|
295271
|
-
|
|
295272
|
-
|
|
295569
|
+
return [
|
|
295570
|
+
"sslmode=verify-ca",
|
|
295571
|
+
pgConninfoPair("sslrootcert", trust.caBundle)
|
|
295572
|
+
];
|
|
295273
295573
|
}
|
|
295274
295574
|
case "verify-full":
|
|
295275
|
-
|
|
295276
|
-
|
|
295575
|
+
return [
|
|
295576
|
+
"sslmode=verify-full",
|
|
295577
|
+
pgConninfoPair("sslrootcert", trust.ambientBundle())
|
|
295578
|
+
];
|
|
295277
295579
|
default: {
|
|
295278
295580
|
const unhandled = mode;
|
|
295279
|
-
throw new Error(
|
|
295581
|
+
throw new Error(`${subject} has unsupported sslmode '${String(unhandled)}' (expected ${PROXIED_SSLMODES.join(" | ")}).`);
|
|
295280
295582
|
}
|
|
295281
295583
|
}
|
|
295282
|
-
|
|
295584
|
+
}
|
|
295585
|
+
function libpqStatementTimeoutParts(name, pg) {
|
|
295586
|
+
const ms = postgresStatementTimeoutMs(name, pg);
|
|
295587
|
+
return ms === undefined ? [] : [pgConninfoPair("options", `-c statement_timeout=${ms}`)];
|
|
295283
295588
|
}
|
|
295284
295589
|
function shouldRenewStorageSecret(opts) {
|
|
295285
295590
|
return opts.hasRenewer && !opts.alreadyRenewing && isExpiredCredentialError(opts.error);
|
|
@@ -295601,14 +295906,40 @@ function buildProxiedPostgresConnection(metadata, endpoint) {
|
|
|
295601
295906
|
const db = pg.databaseName ? `/${enc(pg.databaseName)}` : "";
|
|
295602
295907
|
const { query, ssl } = resolveProxiedTls(name, pg.host, pg.sslmode);
|
|
295603
295908
|
const connectionString = `postgresql://${auth}${endpoint.host}:${endpoint.port}${db}${query}`;
|
|
295909
|
+
const setupSQL = statementTimeoutSetupSQL(name, pg);
|
|
295604
295910
|
return new PooledPostgresConnection({
|
|
295605
295911
|
name,
|
|
295606
295912
|
connectionString,
|
|
295607
295913
|
...ssl ? { ssl } : {},
|
|
295914
|
+
...setupSQL ? { setupSQL } : {},
|
|
295608
295915
|
poolMin: 1,
|
|
295609
295916
|
poolMax: 20
|
|
295610
295917
|
});
|
|
295611
295918
|
}
|
|
295919
|
+
function buildEnvironmentPostgresConnection(metadata) {
|
|
295920
|
+
const name = metadata.apiConnection.name;
|
|
295921
|
+
const pg = metadata.apiConnection.postgresConnection;
|
|
295922
|
+
if (!pg) {
|
|
295923
|
+
throw new Error(`Connection '${name}' has type 'postgres' but no postgresConnection config.`);
|
|
295924
|
+
}
|
|
295925
|
+
const setupSQL = statementTimeoutSetupSQL(name, pg);
|
|
295926
|
+
return new EnvironmentPooledPostgresConnection({
|
|
295927
|
+
name,
|
|
295928
|
+
connectionString: buildPostgresConnectionString(pg, {
|
|
295929
|
+
applySslmode: true
|
|
295930
|
+
}),
|
|
295931
|
+
host: pg.host,
|
|
295932
|
+
port: pg.port,
|
|
295933
|
+
username: pg.userName,
|
|
295934
|
+
password: pg.password,
|
|
295935
|
+
databaseName: pg.databaseName,
|
|
295936
|
+
...setupSQL ? { setupSQL } : {}
|
|
295937
|
+
});
|
|
295938
|
+
}
|
|
295939
|
+
function statementTimeoutSetupSQL(name, pg) {
|
|
295940
|
+
const ms = postgresStatementTimeoutMs(name, pg);
|
|
295941
|
+
return ms === undefined ? undefined : `SET statement_timeout = ${ms}`;
|
|
295942
|
+
}
|
|
295612
295943
|
function buildDuckLakeConnection(metadata, entry) {
|
|
295613
295944
|
return new DuckLakeConnection(entryToDuckDBOptions(metadata.apiConnection.name, entry, metadata.workingDirectory));
|
|
295614
295945
|
}
|
|
@@ -295634,6 +295965,7 @@ function buildEnvironmentMalloyConfig(connections = [], environmentPath = "", is
|
|
|
295634
295965
|
const snowflakeJwtCache = new Map;
|
|
295635
295966
|
const azureDuckDBCache = new Map;
|
|
295636
295967
|
const proxyConnectionCache = new Map;
|
|
295968
|
+
const postgresConnectionCache = new Map;
|
|
295637
295969
|
const proxyEndpoints = new Map;
|
|
295638
295970
|
const attachPromises = new WeakMap;
|
|
295639
295971
|
const malloyConfig = new MalloyConfig(assembled.pojo, {
|
|
@@ -295724,6 +296056,21 @@ function buildEnvironmentMalloyConfig(connections = [], environmentPath = "", is
|
|
|
295724
296056
|
await attachOnce(connection2, metadata);
|
|
295725
296057
|
return connection2;
|
|
295726
296058
|
}
|
|
296059
|
+
if (metadata?.apiConnection.type === "postgres") {
|
|
296060
|
+
let connectionPromise = postgresConnectionCache.get(name);
|
|
296061
|
+
if (!connectionPromise) {
|
|
296062
|
+
connectionPromise = Promise.resolve(buildEnvironmentPostgresConnection(metadata));
|
|
296063
|
+
postgresConnectionCache.set(name, connectionPromise);
|
|
296064
|
+
connectionPromise.catch(() => {
|
|
296065
|
+
if (postgresConnectionCache.get(name) === connectionPromise) {
|
|
296066
|
+
postgresConnectionCache.delete(name);
|
|
296067
|
+
}
|
|
296068
|
+
});
|
|
296069
|
+
}
|
|
296070
|
+
const connection2 = await connectionPromise;
|
|
296071
|
+
await attachOnce(connection2, metadata);
|
|
296072
|
+
return connection2;
|
|
296073
|
+
}
|
|
295727
296074
|
const connection = await base.lookupConnection(name);
|
|
295728
296075
|
if (metadata) {
|
|
295729
296076
|
await attachOnce(connection, metadata);
|
|
@@ -295733,7 +296080,17 @@ function buildEnvironmentMalloyConfig(connections = [], environmentPath = "", is
|
|
|
295733
296080
|
return {
|
|
295734
296081
|
lookupConnection: async (name) => {
|
|
295735
296082
|
const metadata = getMetadataForLookup(assembled.metadata, name);
|
|
295736
|
-
|
|
296083
|
+
let connection;
|
|
296084
|
+
try {
|
|
296085
|
+
connection = await resolveConnection(name, metadata);
|
|
296086
|
+
} catch (error2) {
|
|
296087
|
+
if (name !== undefined && !Object.hasOwn(assembled.pojo.connections, name)) {
|
|
296088
|
+
throw new UnconfiguredConnectionError(name, {
|
|
296089
|
+
cause: error2
|
|
296090
|
+
});
|
|
296091
|
+
}
|
|
296092
|
+
throw error2;
|
|
296093
|
+
}
|
|
295737
296094
|
if (isDuckDBConnection(connection)) {
|
|
295738
296095
|
await applyExtensionSessionSettings(connection);
|
|
295739
296096
|
}
|
|
@@ -295749,7 +296106,8 @@ function buildEnvironmentMalloyConfig(connections = [], environmentPath = "", is
|
|
|
295749
296106
|
...duckLakeCache.values(),
|
|
295750
296107
|
...snowflakeJwtCache.values(),
|
|
295751
296108
|
...azureDuckDBCache.values(),
|
|
295752
|
-
...proxyConnectionCache.values()
|
|
296109
|
+
...proxyConnectionCache.values(),
|
|
296110
|
+
...postgresConnectionCache.values()
|
|
295753
296111
|
];
|
|
295754
296112
|
const closeResults = await Promise.allSettled([
|
|
295755
296113
|
malloyConfig.shutdown("close"),
|
|
@@ -295763,6 +296121,7 @@ function buildEnvironmentMalloyConfig(connections = [], environmentPath = "", is
|
|
|
295763
296121
|
snowflakeJwtCache.clear();
|
|
295764
296122
|
azureDuckDBCache.clear();
|
|
295765
296123
|
proxyConnectionCache.clear();
|
|
296124
|
+
postgresConnectionCache.clear();
|
|
295766
296125
|
proxyEndpoints.clear();
|
|
295767
296126
|
const failures = [...closeResults, ...endpointResults].filter((result) => result.status === "rejected");
|
|
295768
296127
|
for (const failure of failures) {
|
|
@@ -295945,7 +296304,7 @@ async function testConnectionConfig(connectionConfig) {
|
|
|
295945
296304
|
}
|
|
295946
296305
|
}
|
|
295947
296306
|
}
|
|
295948
|
-
var import_db_publisher, extensionSessionPinned, sessionLimitsApplied, ducklakePreflightSeq = 0, ambientTrustBundles, ambientTrustDir, AzureDuckDBConnection, DuckLakeConnection, CONFIG_SECRET_KEY;
|
|
296307
|
+
var import_db_publisher, extensionSessionPinned, sessionLimitsApplied, STATEMENT_TIMEOUT_SWITCH, ducklakePreflightSeq = 0, ambientTrustBundles, ambientTrustDir, AzureDuckDBConnection, DuckLakeConnection, POSTGRES_POOL_MAX_USES = 1, PG_POOL_WAIT_TIMEOUT_MESSAGE = "timeout exceeded when trying to connect", EnvironmentPooledPostgresConnection, CONFIG_SECRET_KEY;
|
|
295949
296308
|
var init_connection = __esm(() => {
|
|
295950
296309
|
init_axios2();
|
|
295951
296310
|
init_config();
|
|
@@ -295963,6 +296322,7 @@ var init_connection = __esm(() => {
|
|
|
295963
296322
|
import_db_publisher = __toESM(require_dist6(), 1);
|
|
295964
296323
|
extensionSessionPinned = new WeakSet;
|
|
295965
296324
|
sessionLimitsApplied = new WeakMap;
|
|
296325
|
+
STATEMENT_TIMEOUT_SWITCH = /^(?:-c|--)statement[_-]timeout=/i;
|
|
295966
296326
|
ambientTrustBundles = new Map;
|
|
295967
296327
|
AzureDuckDBConnection = class AzureDuckDBConnection extends DuckDBConnection {
|
|
295968
296328
|
azureDatabases;
|
|
@@ -296054,6 +296414,38 @@ var init_connection = __esm(() => {
|
|
|
296054
296414
|
return result;
|
|
296055
296415
|
}
|
|
296056
296416
|
};
|
|
296417
|
+
EnvironmentPooledPostgresConnection = class EnvironmentPooledPostgresConnection extends PooledPostgresConnection {
|
|
296418
|
+
poolLimits() {
|
|
296419
|
+
return { max: getPostgresPoolMax(), connectionTimeoutMillis: 30000 };
|
|
296420
|
+
}
|
|
296421
|
+
buildClientConfig(cfg) {
|
|
296422
|
+
const base = super.buildClientConfig(cfg);
|
|
296423
|
+
return {
|
|
296424
|
+
...base,
|
|
296425
|
+
...this.poolLimits(),
|
|
296426
|
+
maxUses: POSTGRES_POOL_MAX_USES,
|
|
296427
|
+
application_name: "malloy-publisher"
|
|
296428
|
+
};
|
|
296429
|
+
}
|
|
296430
|
+
async withTlsHint(op) {
|
|
296431
|
+
try {
|
|
296432
|
+
return await super.withTlsHint(op);
|
|
296433
|
+
} catch (err) {
|
|
296434
|
+
if (err instanceof Error && err.message === PG_POOL_WAIT_TIMEOUT_MESSAGE) {
|
|
296435
|
+
const { max, connectionTimeoutMillis } = this.poolLimits();
|
|
296436
|
+
const waitSeconds = connectionTimeoutMillis / 1000;
|
|
296437
|
+
logger.warn("Postgres connection pool exhausted", {
|
|
296438
|
+
connection: this.name,
|
|
296439
|
+
poolMax: max,
|
|
296440
|
+
waitSeconds,
|
|
296441
|
+
remedy: "fewer concurrent queries on this connection, or raise PUBLISHER_POSTGRES_POOL_MAX"
|
|
296442
|
+
});
|
|
296443
|
+
throw new ConnectionPoolExhaustedError(`Connection '${this.name}' has no free database session: this server opens at most ${max} at a time for it, and none came free within ${waitSeconds} s. Retry once fewer queries are running on this connection.`);
|
|
296444
|
+
}
|
|
296445
|
+
throw err;
|
|
296446
|
+
}
|
|
296447
|
+
}
|
|
296448
|
+
};
|
|
296057
296449
|
CONFIG_SECRET_KEY = /pass(word)?|secret|private_?key|service_?account|access_?key|token|peaka_?key/i;
|
|
296058
296450
|
});
|
|
296059
296451
|
|
|
@@ -315571,12 +315963,18 @@ function describeParseFailure(message) {
|
|
|
315571
315963
|
}
|
|
315572
315964
|
return `does not parse (${message})`;
|
|
315573
315965
|
}
|
|
315966
|
+
function unparsedTagMessage(parseError) {
|
|
315967
|
+
return `${TAG_UNPARSED_PREFIX}${describeParseFailure(parseError)}, ${TAG_DISCARDED}`;
|
|
315968
|
+
}
|
|
315969
|
+
function isUnparsedDashboardTagFinding(message) {
|
|
315970
|
+
return message.startsWith(TAG_UNPARSED_PREFIX) && message.endsWith(TAG_DISCARDED);
|
|
315971
|
+
}
|
|
315574
315972
|
function lintUndiscoveredDashboard(facts) {
|
|
315575
315973
|
const subject = dashboardSlug(facts.modelPath);
|
|
315576
315974
|
const messages = [
|
|
315577
315975
|
...motlyParseErrors(facts.modelAnnotations),
|
|
315578
315976
|
...facts.queries.flatMap((query) => motlyParseErrors(query.annotations))
|
|
315579
|
-
].map(
|
|
315977
|
+
].map(unparsedTagMessage);
|
|
315580
315978
|
const findings = Array.from(new Set(messages), (message) => ({ subject, message, severity: "error" }));
|
|
315581
315979
|
if (findings.length > 0)
|
|
315582
315980
|
return findings;
|
|
@@ -315707,7 +316105,7 @@ async function compileTileGivens(tiles, materializer, fallbackRegistry, gatesOf,
|
|
|
315707
316105
|
}
|
|
315708
316106
|
return compiled;
|
|
315709
316107
|
}
|
|
315710
|
-
var MAX_BUILDER_COLUMNS = 24, COMPONENT_FILE_SUFFIXES, MODEL_FILE_SUFFIX2 = ".malloy", PUBLISHER_OWNED_TAGS, RENDERER_CHILD_TAGS, UNKNOWN_RENDER_TAG, COMPOSITE_ARTIFACT_PROPERTIES, QUERY_ARTIFACT_PROPERTIES, ARTIFACT_PROPERTY_REPLACEMENTS, NAME_THE_TILES = `Name the views to show: tiles=["source -> view", …].`;
|
|
316108
|
+
var MAX_BUILDER_COLUMNS = 24, COMPONENT_FILE_SUFFIXES, MODEL_FILE_SUFFIX2 = ".malloy", PUBLISHER_OWNED_TAGS, RENDERER_CHILD_TAGS, UNKNOWN_RENDER_TAG, COMPOSITE_ARTIFACT_PROPERTIES, QUERY_ARTIFACT_PROPERTIES, ARTIFACT_PROPERTY_REPLACEMENTS, TAG_DISCARDED, TAG_UNPARSED_PREFIX = "Tag ", NAME_THE_TILES = `Name the views to show: tiles=["source -> view", …].`;
|
|
315711
316109
|
var init_dashboard = __esm(() => {
|
|
315712
316110
|
init_annotations();
|
|
315713
316111
|
init_authorize();
|
|
@@ -315731,6 +316129,7 @@ var init_dashboard = __esm(() => {
|
|
|
315731
316129
|
dashboard_columns: "A single query has no grid; a composite's width is `dashboard { columns=N }` beside its artifact tag.",
|
|
315732
316130
|
dashboard: "The grid width is a sibling of the artifact tag, not a property of it: " + "close the artifact braces first, as `## artifact { … } dashboard { columns=N }`."
|
|
315733
316131
|
};
|
|
316132
|
+
TAG_DISCARDED = "so the whole tag is discarded and this file is treated as a shared " + "include rather than a dashboard.";
|
|
315734
316133
|
});
|
|
315735
316134
|
|
|
315736
316135
|
// ../../node_modules/lunr/lunr.js
|
|
@@ -319941,10 +320340,10 @@ var init_model_info = () => {};
|
|
|
319941
320340
|
// src/service/filter.ts
|
|
319942
320341
|
function parseFilterAnnotation(annotation) {
|
|
319943
320342
|
const trimmed = annotation.trim();
|
|
319944
|
-
if (!trimmed.startsWith(
|
|
320343
|
+
if (!trimmed.startsWith(FILTER_ANNOTATION_PREFIX)) {
|
|
319945
320344
|
return null;
|
|
319946
320345
|
}
|
|
319947
|
-
const body = trimmed.slice(
|
|
320346
|
+
const body = trimmed.slice(FILTER_ANNOTATION_PREFIX.length).trim();
|
|
319948
320347
|
const tokens = tokenize2(body);
|
|
319949
320348
|
let name;
|
|
319950
320349
|
let dimension;
|
|
@@ -320113,7 +320512,7 @@ function tokenize2(input) {
|
|
|
320113
320512
|
}
|
|
320114
320513
|
return tokens;
|
|
320115
320514
|
}
|
|
320116
|
-
var VALID_FILTER_TYPES,
|
|
320515
|
+
var VALID_FILTER_TYPES, FILTER_ANNOTATION_PREFIX = "#(filter)", ISO_DATE_RE, ISO_TIMESTAMP_RE, FilterValidationError;
|
|
320117
320516
|
var init_filter = __esm(() => {
|
|
320118
320517
|
VALID_FILTER_TYPES = new Set([
|
|
320119
320518
|
"equal",
|
|
@@ -325889,6 +326288,18 @@ async function compileErrorOf(runnable) {
|
|
|
325889
326288
|
return translatorMalloyError(error2);
|
|
325890
326289
|
}
|
|
325891
326290
|
}
|
|
326291
|
+
async function runStatementCount(runnable) {
|
|
326292
|
+
let prepared;
|
|
326293
|
+
try {
|
|
326294
|
+
prepared = await runnable.getPreparedQuery();
|
|
326295
|
+
} catch {
|
|
326296
|
+
return;
|
|
326297
|
+
}
|
|
326298
|
+
return prepared?.model?.queries?.().unnamed;
|
|
326299
|
+
}
|
|
326300
|
+
function multipleRunStatementsError(count4) {
|
|
326301
|
+
return new InvalidArgumentError(`The query has ${count4} run: statements; only one runs per call, so the ` + "others would be ignored. Send each as its own request. (source: and " + "query: definitions before a single run: are fine.)");
|
|
326302
|
+
}
|
|
325892
326303
|
function bindingsAllowDegradeToLive(bindings) {
|
|
325893
326304
|
return bindings.length > 0 && bindings.every((b3) => b3.origin === "preaggregate" || b3.freshnessFallback === "live");
|
|
325894
326305
|
}
|
|
@@ -326127,13 +326538,34 @@ var init_model = __esm(() => {
|
|
|
326127
326538
|
}
|
|
326128
326539
|
this.modelInfo = modelInfo ?? (this.modelDef ? modelInfoOf(this.modelDef) : undefined);
|
|
326129
326540
|
if (this.filterMap.size > 0) {
|
|
326130
|
-
logger.warn(`Model "${packageName}/${modelPath}" uses deprecated #(filter) annotations. Migrate to given: — see
|
|
326541
|
+
logger.warn(`Model "${packageName}/${modelPath}" uses deprecated #(filter) annotations. Migrate to given: — see ${GIVENS_DOCS_URL}`, {
|
|
326131
326542
|
packageName,
|
|
326132
326543
|
modelPath,
|
|
326133
326544
|
filterSourceCount: this.filterMap.size
|
|
326134
326545
|
});
|
|
326135
326546
|
}
|
|
326136
326547
|
}
|
|
326548
|
+
declaredFilterSourceNames() {
|
|
326549
|
+
if (!this.modelDef)
|
|
326550
|
+
return [];
|
|
326551
|
+
const ownFile = (url3) => {
|
|
326552
|
+
try {
|
|
326553
|
+
return decodeURIComponent(new URL(url3).pathname).endsWith(`/${this.modelPath}`);
|
|
326554
|
+
} catch {
|
|
326555
|
+
return false;
|
|
326556
|
+
}
|
|
326557
|
+
};
|
|
326558
|
+
const names = [];
|
|
326559
|
+
for (const entry of Object.values(this.modelDef.contents)) {
|
|
326560
|
+
if (!isSourceDef9(entry))
|
|
326561
|
+
continue;
|
|
326562
|
+
const own2 = entry.annotations?.blockNotes ?? [];
|
|
326563
|
+
const declares = own2.some((note) => note.text.trim().startsWith(FILTER_ANNOTATION_PREFIX) && note.at !== undefined && ownFile(note.at.url));
|
|
326564
|
+
if (declares)
|
|
326565
|
+
names.push(entry.as || entry.name);
|
|
326566
|
+
}
|
|
326567
|
+
return names.sort();
|
|
326568
|
+
}
|
|
326137
326569
|
getFilters(sourceName) {
|
|
326138
326570
|
return this.filterMap.get(sourceName) ?? [];
|
|
326139
326571
|
}
|
|
@@ -326413,6 +326845,14 @@ var init_model = __esm(() => {
|
|
|
326413
326845
|
checkOnly: true
|
|
326414
326846
|
});
|
|
326415
326847
|
}
|
|
326848
|
+
unboundGateDenial(runnable, givens, fallbackLabel, gateGivenNames) {
|
|
326849
|
+
if (!this.queryHadRowLevelFilterAttached(runnable) || ![...gateGivenNames].some((name) => !(name in (givens ?? {})))) {
|
|
326850
|
+
return;
|
|
326851
|
+
}
|
|
326852
|
+
const unbound = this.rowLevelFilteredRunnables.get(runnable)?.find((graft) => graft.callerJoinPath && graft.givenNames.some((name) => !(name in (givens ?? {}))));
|
|
326853
|
+
recordRowLevelGateDecision("denied_by_gate", unbound ? "caller_join" : "entry_point");
|
|
326854
|
+
return new AccessDeniedError(`Access denied for source "${unbound?.label ?? fallbackLabel}".`);
|
|
326855
|
+
}
|
|
326416
326856
|
queryHadRowLevelFilterAttached(runnable) {
|
|
326417
326857
|
return this.rowLevelFilteredRunnables.has(runnable);
|
|
326418
326858
|
}
|
|
@@ -327278,7 +327718,7 @@ ${query}`;
|
|
|
327278
327718
|
const sourceInfos = data.sourceInfos;
|
|
327279
327719
|
const givens = data.givens;
|
|
327280
327720
|
const filterMap = data.filterMap ? new Map(data.filterMap) : undefined;
|
|
327281
|
-
for (const warning of data.authorizeWarnings ?? []) {
|
|
327721
|
+
for (const warning of options?.skipAuthorizeWarningLog ? [] : data.authorizeWarnings ?? []) {
|
|
327282
327722
|
logger.warn(warning, { packageName, modelPath: data.modelPath });
|
|
327283
327723
|
}
|
|
327284
327724
|
if (!modelDef) {
|
|
@@ -327292,8 +327732,10 @@ ${query}`;
|
|
|
327292
327732
|
model.compiledSourceText = data.modelSourceText;
|
|
327293
327733
|
return model;
|
|
327294
327734
|
}
|
|
327295
|
-
static fromCompilationError(packageName, modelPath, modelType, error2) {
|
|
327296
|
-
|
|
327735
|
+
static fromCompilationError(packageName, modelPath, modelType, error2, sourceText) {
|
|
327736
|
+
const model = new Model(packageName, modelPath, {}, modelType, undefined, undefined, undefined, undefined, undefined, undefined, error2);
|
|
327737
|
+
model.compiledSourceText = sourceText;
|
|
327738
|
+
return model;
|
|
327297
327739
|
}
|
|
327298
327740
|
static deserializeCompilationError = deserializeError;
|
|
327299
327741
|
getPath() {
|
|
@@ -328336,6 +328778,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
328336
328778
|
throw queryCompileError(compileError, query);
|
|
328337
328779
|
}
|
|
328338
328780
|
}
|
|
328781
|
+
const runCount = !sourceName && !queryName && query ? await runStatementCount(runnable) : undefined;
|
|
328339
328782
|
const compiledSource = await this.resolveAuthorizeSourceFromRunnable(runnable);
|
|
328340
328783
|
if (boundary === "deferred") {
|
|
328341
328784
|
this.assertQueryBoundaryCompiled(compiledSource, query);
|
|
@@ -328349,6 +328792,16 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
328349
328792
|
callerQueryText: query,
|
|
328350
328793
|
callerJoins: callerRegion && liveRunnable ? { runnable: liveRunnable, region: callerRegion } : undefined
|
|
328351
328794
|
});
|
|
328795
|
+
if (runCount !== undefined && runCount > 1) {
|
|
328796
|
+
this.queryExecutionHistogram.record(performance.now() - startTime, this.queryMetricAttributes({
|
|
328797
|
+
environment: queryMetadataInput?.environment,
|
|
328798
|
+
queryName,
|
|
328799
|
+
sourceName,
|
|
328800
|
+
status: "error",
|
|
328801
|
+
servedFrom
|
|
328802
|
+
}));
|
|
328803
|
+
throw this.unboundGateDenial(runnable, givens, compiledSource ?? sourceName ?? "unknown", (this.rowLevelFilteredRunnables.get(runnable) ?? []).flatMap((graft) => graft.givenNames)) ?? multipleRunStatementsError(runCount);
|
|
328804
|
+
}
|
|
328352
328805
|
this.assertFilterGivens(givens);
|
|
328353
328806
|
const maxRows = getMaxQueryRows();
|
|
328354
328807
|
const maxBytes = getMaxResponseBytes();
|
|
@@ -328393,15 +328846,14 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
328393
328846
|
status: "error",
|
|
328394
328847
|
servedFrom
|
|
328395
328848
|
}));
|
|
328396
|
-
|
|
328849
|
+
const gateDenial = isGivenBindingFailure(err) ? this.unboundGateDenial(runnable, givens, compiledSource ?? sourceName ?? "unknown", this.authorizeReferencedGivenNames) : undefined;
|
|
328850
|
+
if (gateDenial) {
|
|
328397
328851
|
logger.debug("Gate given unbound; denying opaquely", {
|
|
328398
328852
|
environmentName: this.packageName,
|
|
328399
328853
|
modelPath: this.modelPath,
|
|
328400
328854
|
error: err instanceof Error ? err.message : String(err)
|
|
328401
328855
|
});
|
|
328402
|
-
|
|
328403
|
-
recordRowLevelGateDecision("denied_by_gate", unbound ? "caller_join" : "entry_point");
|
|
328404
|
-
throw new AccessDeniedError(`Access denied for source "${unbound?.label ?? compiledSource ?? sourceName ?? "unknown"}".`);
|
|
328856
|
+
throw gateDenial;
|
|
328405
328857
|
}
|
|
328406
328858
|
const givenCode = err?.code;
|
|
328407
328859
|
if (typeof givenCode === "string" && givenCode.startsWith("runtime-given-")) {
|
|
@@ -328412,10 +328864,16 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
328412
328864
|
});
|
|
328413
328865
|
throw new BadRequestError(err instanceof Error ? err.message : String(err));
|
|
328414
328866
|
}
|
|
328867
|
+
if (err instanceof ConnectionError) {
|
|
328868
|
+
throw err;
|
|
328869
|
+
}
|
|
328415
328870
|
if (err instanceof MalloyError7) {
|
|
328416
328871
|
throw err;
|
|
328417
328872
|
}
|
|
328418
328873
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
328874
|
+
const accessFailure = databaseAccessFailure(err);
|
|
328875
|
+
if (accessFailure)
|
|
328876
|
+
throw accessFailure;
|
|
328419
328877
|
logger.error("Query execution error", {
|
|
328420
328878
|
error: err,
|
|
328421
328879
|
errorMessage,
|
|
@@ -328875,6 +329333,9 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
328875
329333
|
throw error2;
|
|
328876
329334
|
}
|
|
328877
329335
|
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
329336
|
+
const accessFailure = databaseAccessFailure(error2);
|
|
329337
|
+
if (accessFailure)
|
|
329338
|
+
throw accessFailure;
|
|
328878
329339
|
if (errorMessage.trim() === "Model has no queries.") {
|
|
328879
329340
|
return {
|
|
328880
329341
|
type: "code",
|
|
@@ -358133,7 +358594,8 @@ var POSTGRES = {
|
|
|
358133
358594
|
port: "scalar",
|
|
358134
358595
|
databaseName: "scalar",
|
|
358135
358596
|
userName: "scalar",
|
|
358136
|
-
sslmode: "scalar"
|
|
358597
|
+
sslmode: "scalar",
|
|
358598
|
+
statementTimeoutMilliseconds: "scalar"
|
|
358137
358599
|
};
|
|
358138
358600
|
var BIGQUERY = {
|
|
358139
358601
|
defaultProjectId: "scalar",
|
|
@@ -358727,7 +359189,7 @@ async function getSchemasForBigQuery(connection) {
|
|
|
358727
359189
|
}));
|
|
358728
359190
|
} catch (error2) {
|
|
358729
359191
|
logger.error(`Error getting schemas for BigQuery connection ${connection.name}`, { error: error2 });
|
|
358730
|
-
throw new Error(`Failed to get schemas for BigQuery connection ${connection.name}: ${error2.message}
|
|
359192
|
+
throw new Error(`Failed to get schemas for BigQuery connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
358731
359193
|
}
|
|
358732
359194
|
}
|
|
358733
359195
|
async function getSchemasForPostgres(connection, malloyConnection) {
|
|
@@ -358748,7 +359210,7 @@ async function getSchemasForPostgres(connection, malloyConnection) {
|
|
|
358748
359210
|
});
|
|
358749
359211
|
} catch (error2) {
|
|
358750
359212
|
logger.error(`Error getting schemas for Postgres connection ${connection.name}`, { error: error2 });
|
|
358751
|
-
throw new Error(`Failed to get schemas for Postgres connection ${connection.name}: ${error2.message}
|
|
359213
|
+
throw new Error(`Failed to get schemas for Postgres connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
358752
359214
|
}
|
|
358753
359215
|
}
|
|
358754
359216
|
async function getSchemasForMySQL(connection) {
|
|
@@ -358826,7 +359288,7 @@ async function getSchemasForSnowflake(connection, malloyConnection) {
|
|
|
358826
359288
|
if (error2 instanceof BadRequestError)
|
|
358827
359289
|
throw error2;
|
|
358828
359290
|
logger.error(`Error getting schemas for Snowflake connection ${connection.name}`, { error: error2 });
|
|
358829
|
-
throw new Error(`Failed to get schemas for Snowflake connection ${connection.name}: ${error2.message}
|
|
359291
|
+
throw new Error(`Failed to get schemas for Snowflake connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
358830
359292
|
}
|
|
358831
359293
|
}
|
|
358832
359294
|
async function getSchemasForTrino(connection, malloyConnection) {
|
|
@@ -358881,7 +359343,7 @@ async function getSchemasForTrino(connection, malloyConnection) {
|
|
|
358881
359343
|
if (error2 instanceof BadRequestError)
|
|
358882
359344
|
throw error2;
|
|
358883
359345
|
logger.error(`Error getting schemas for Trino connection ${connection.name}`, { error: error2 });
|
|
358884
|
-
throw new Error(`Failed to get schemas for Trino connection ${connection.name}: ${error2.message}
|
|
359346
|
+
throw new Error(`Failed to get schemas for Trino connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
358885
359347
|
}
|
|
358886
359348
|
}
|
|
358887
359349
|
async function getSchemasForDatabricks(connection, malloyConnection) {
|
|
@@ -358937,7 +359399,7 @@ async function getSchemasForDatabricks(connection, malloyConnection) {
|
|
|
358937
359399
|
if (error2 instanceof BadRequestError)
|
|
358938
359400
|
throw error2;
|
|
358939
359401
|
logger.error(`Error getting schemas for Databricks connection ${connection.name}`, { error: error2 });
|
|
358940
|
-
throw new Error(`Failed to get schemas for Databricks connection ${connection.name}: ${error2.message}
|
|
359402
|
+
throw new Error(`Failed to get schemas for Databricks connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
358941
359403
|
}
|
|
358942
359404
|
}
|
|
358943
359405
|
async function getSchemasForDuckDB(connection, malloyConnection) {
|
|
@@ -358992,7 +359454,7 @@ async function getSchemasForDuckDB(connection, malloyConnection) {
|
|
|
358992
359454
|
return schemas3;
|
|
358993
359455
|
} catch (error2) {
|
|
358994
359456
|
logger.error(`Error getting schemas for DuckDB connection ${connection.name}`, { error: error2 });
|
|
358995
|
-
throw new Error(`Failed to get schemas for DuckDB connection ${connection.name}: ${error2.message}
|
|
359457
|
+
throw new Error(`Failed to get schemas for DuckDB connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
358996
359458
|
}
|
|
358997
359459
|
}
|
|
358998
359460
|
async function getSchemasForMotherDuck(connection, malloyConnection) {
|
|
@@ -359015,7 +359477,7 @@ async function getSchemasForMotherDuck(connection, malloyConnection) {
|
|
|
359015
359477
|
});
|
|
359016
359478
|
} catch (error2) {
|
|
359017
359479
|
logger.error(`Error getting schemas for MotherDuck connection ${connection.name}`, { error: error2 });
|
|
359018
|
-
throw new Error(`Failed to get schemas for MotherDuck connection ${connection.name}: ${error2.message}
|
|
359480
|
+
throw new Error(`Failed to get schemas for MotherDuck connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
359019
359481
|
}
|
|
359020
359482
|
}
|
|
359021
359483
|
async function getSchemasForDuckLake(connection, malloyConnection) {
|
|
@@ -359035,7 +359497,7 @@ async function getSchemasForDuckLake(connection, malloyConnection) {
|
|
|
359035
359497
|
});
|
|
359036
359498
|
} catch (error2) {
|
|
359037
359499
|
logger.error(`Error getting schemas for DuckLake connection ${connection.name}`, { error: error2 });
|
|
359038
|
-
throw new Error(`Failed to get schemas for DuckLake connection ${connection.name}: ${error2.message}
|
|
359500
|
+
throw new Error(`Failed to get schemas for DuckLake connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
359039
359501
|
}
|
|
359040
359502
|
}
|
|
359041
359503
|
var PUBLISHER_INTROSPECTION_TIMEOUT_MS = 60000;
|
|
@@ -359067,7 +359529,7 @@ async function fetchFromPublisherDataplane(connection, pathSuffix) {
|
|
|
359067
359529
|
});
|
|
359068
359530
|
} catch (error2) {
|
|
359069
359531
|
const reason = error2?.name === "TimeoutError" ? `timed out after ${PUBLISHER_INTROSPECTION_TIMEOUT_MS}ms` : error2.message;
|
|
359070
|
-
throw new Error(`Publisher dataplane request to ${redactUrlCredentials(url3)} failed ` + `for connection "${connection.name}": ${reason}
|
|
359532
|
+
throw new Error(`Publisher dataplane request to ${redactUrlCredentials(url3)} failed ` + `for connection "${connection.name}": ${reason}`, { cause: error2 });
|
|
359071
359533
|
}
|
|
359072
359534
|
if (!response.ok) {
|
|
359073
359535
|
const body = await response.text().catch(() => "");
|
|
@@ -359339,7 +359801,7 @@ async function listTablesForBigQuery(connection, schemaName, malloyConnection, t
|
|
|
359339
359801
|
return results;
|
|
359340
359802
|
} catch (error2) {
|
|
359341
359803
|
logger.error(`Error getting tables for BigQuery schema ${schemaName} in connection ${connection.name}`, { error: error2 });
|
|
359342
|
-
throw new Error(`Failed to get tables for BigQuery schema ${schemaName} in connection ${connection.name}: ${error2.message}
|
|
359804
|
+
throw new Error(`Failed to get tables for BigQuery schema ${schemaName} in connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
359343
359805
|
}
|
|
359344
359806
|
}
|
|
359345
359807
|
async function listTablesForMySQL(connection, schemaName, malloyConnection, tableNames) {
|
|
@@ -359352,7 +359814,7 @@ async function listTablesForMySQL(connection, schemaName, malloyConnection, tabl
|
|
|
359352
359814
|
return groupColumnRowsIntoTables(rows, (t2) => `${schemaName}.${t2}`);
|
|
359353
359815
|
} catch (error2) {
|
|
359354
359816
|
logger.error(`Error getting tables for MySQL schema ${schemaName} in connection ${connection.name}`, { error: error2 });
|
|
359355
|
-
throw new Error(`Failed to get tables for MySQL schema ${schemaName} in connection ${connection.name}: ${error2.message}
|
|
359817
|
+
throw new Error(`Failed to get tables for MySQL schema ${schemaName} in connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
359356
359818
|
}
|
|
359357
359819
|
}
|
|
359358
359820
|
async function listTablesForPostgres(connection, schemaName, malloyConnection, tableNames) {
|
|
@@ -359365,7 +359827,7 @@ async function listTablesForPostgres(connection, schemaName, malloyConnection, t
|
|
|
359365
359827
|
return groupColumnRowsIntoTables(rows, (t2) => `${schemaName}.${t2}`);
|
|
359366
359828
|
} catch (error2) {
|
|
359367
359829
|
logger.error(`Error getting tables for Postgres schema ${schemaName} in connection ${connection.name}`, { error: error2 });
|
|
359368
|
-
throw new Error(`Failed to get tables for Postgres schema ${schemaName} in connection ${connection.name}: ${error2.message}
|
|
359830
|
+
throw new Error(`Failed to get tables for Postgres schema ${schemaName} in connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
359369
359831
|
}
|
|
359370
359832
|
}
|
|
359371
359833
|
async function listTablesForSnowflake(connection, schemaName, malloyConnection, tableNames) {
|
|
@@ -359395,7 +359857,7 @@ async function listTablesForSnowflake(connection, schemaName, malloyConnection,
|
|
|
359395
359857
|
logger.error(`Error getting tables for Snowflake schema ${schemaName} in connection ${connection.name}`, { error: error2 });
|
|
359396
359858
|
if (error2 instanceof BadRequestError)
|
|
359397
359859
|
throw error2;
|
|
359398
|
-
throw new Error(`Failed to get tables for Snowflake schema ${schemaName} in connection ${connection.name}: ${error2.message}
|
|
359860
|
+
throw new Error(`Failed to get tables for Snowflake schema ${schemaName} in connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
359399
359861
|
}
|
|
359400
359862
|
}
|
|
359401
359863
|
async function listTablesForTrino(connection, schemaName, malloyConnection, tableNames) {
|
|
@@ -359428,7 +359890,7 @@ async function listTablesForTrino(connection, schemaName, malloyConnection, tabl
|
|
|
359428
359890
|
logger.error(`Error getting tables for Trino schema ${schemaName} in connection ${connection.name}`, { error: error2 });
|
|
359429
359891
|
if (error2 instanceof BadRequestError)
|
|
359430
359892
|
throw error2;
|
|
359431
|
-
throw new Error(`Failed to get tables for Trino schema ${schemaName} in connection ${connection.name}: ${error2.message}
|
|
359893
|
+
throw new Error(`Failed to get tables for Trino schema ${schemaName} in connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
359432
359894
|
}
|
|
359433
359895
|
}
|
|
359434
359896
|
async function listTablesForDatabricks(connection, schemaName, malloyConnection, tableNames) {
|
|
@@ -359461,7 +359923,7 @@ async function listTablesForDatabricks(connection, schemaName, malloyConnection,
|
|
|
359461
359923
|
logger.error(`Error getting tables for Databricks schema ${schemaName} in connection ${connection.name}`, { error: error2 });
|
|
359462
359924
|
if (error2 instanceof BadRequestError)
|
|
359463
359925
|
throw error2;
|
|
359464
|
-
throw new Error(`Failed to get tables for Databricks schema ${schemaName} in connection ${connection.name}: ${error2.message}
|
|
359926
|
+
throw new Error(`Failed to get tables for Databricks schema ${schemaName} in connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
359465
359927
|
}
|
|
359466
359928
|
}
|
|
359467
359929
|
async function listTablesForDuckDB(connection, schemaName, malloyConnection, tableNames) {
|
|
@@ -359506,7 +359968,7 @@ async function listTablesForDuckDB(connection, schemaName, malloyConnection, tab
|
|
|
359506
359968
|
return groupColumnRowsIntoTables(rows, (t2) => `${schemaName}.${t2}`);
|
|
359507
359969
|
} catch (error2) {
|
|
359508
359970
|
logger.error(`Error getting tables for DuckDB schema ${schemaName} in connection ${connection.name}`, { error: error2 });
|
|
359509
|
-
throw new Error(`Failed to get tables for DuckDB schema ${schemaName} in connection ${connection.name}: ${error2.message}
|
|
359971
|
+
throw new Error(`Failed to get tables for DuckDB schema ${schemaName} in connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
359510
359972
|
}
|
|
359511
359973
|
}
|
|
359512
359974
|
async function listTablesForMotherDuck(connection, schemaName, malloyConnection, tableNames) {
|
|
@@ -359519,7 +359981,7 @@ async function listTablesForMotherDuck(connection, schemaName, malloyConnection,
|
|
|
359519
359981
|
return groupColumnRowsIntoTables(rows, (t2) => `${schemaName}.${t2}`);
|
|
359520
359982
|
} catch (error2) {
|
|
359521
359983
|
logger.error(`Error getting tables for MotherDuck schema ${schemaName} in connection ${connection.name}`, { error: error2 });
|
|
359522
|
-
throw new Error(`Failed to get tables for MotherDuck schema ${schemaName} in connection ${connection.name}: ${error2.message}
|
|
359984
|
+
throw new Error(`Failed to get tables for MotherDuck schema ${schemaName} in connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
359523
359985
|
}
|
|
359524
359986
|
}
|
|
359525
359987
|
async function listTablesForDuckLake(connection, schemaName, malloyConnection, tableNames) {
|
|
@@ -359534,7 +359996,7 @@ async function listTablesForDuckLake(connection, schemaName, malloyConnection, t
|
|
|
359534
359996
|
return groupColumnRowsIntoTables(rows, (t2) => `${schemaName}.${t2}`);
|
|
359535
359997
|
} catch (error2) {
|
|
359536
359998
|
logger.error(`Error getting tables for DuckLake schema ${schemaName} in connection ${connection.name}`, { error: error2 });
|
|
359537
|
-
throw new Error(`Failed to get tables for DuckLake schema ${schemaName} in connection ${connection.name}: ${error2.message}
|
|
359999
|
+
throw new Error(`Failed to get tables for DuckLake schema ${schemaName} in connection ${connection.name}: ${error2.message}`, { cause: error2 });
|
|
359538
360000
|
}
|
|
359539
360001
|
}
|
|
359540
360002
|
function extractErrorDataFromError(error2) {
|
|
@@ -359679,7 +360141,19 @@ function classifyDriverFailure(error2) {
|
|
|
359679
360141
|
return error2;
|
|
359680
360142
|
}
|
|
359681
360143
|
const message = error2 instanceof Error ? error2.message : typeof error2 === "string" ? error2 : JSON.stringify(error2);
|
|
359682
|
-
return driverErrorToPublisherError(message);
|
|
360144
|
+
return databaseAccessFailure(error2) ?? driverErrorToPublisherError(message);
|
|
360145
|
+
}
|
|
360146
|
+
function sqlRunFailure(error2) {
|
|
360147
|
+
if (error2 instanceof ConnectionError)
|
|
360148
|
+
return error2;
|
|
360149
|
+
return databaseAccessFailure(error2) ?? new ConnectionError(error2.message);
|
|
360150
|
+
}
|
|
360151
|
+
async function withDatabaseAccessClassified(list) {
|
|
360152
|
+
try {
|
|
360153
|
+
return await list();
|
|
360154
|
+
} catch (error2) {
|
|
360155
|
+
throw databaseAccessFailure(error2) ?? error2;
|
|
360156
|
+
}
|
|
359683
360157
|
}
|
|
359684
360158
|
|
|
359685
360159
|
class ConnectionController {
|
|
@@ -359716,6 +360190,16 @@ class ConnectionController {
|
|
|
359716
360190
|
}
|
|
359717
360191
|
}
|
|
359718
360192
|
async getMalloyConnection(environmentName, connectionName, packageName) {
|
|
360193
|
+
try {
|
|
360194
|
+
return await this.lookupMalloyConnection(environmentName, connectionName, packageName);
|
|
360195
|
+
} catch (error2) {
|
|
360196
|
+
if (error2 instanceof UnconfiguredConnectionError) {
|
|
360197
|
+
throw new ConnectionNotFoundError(`Connection ${connectionName} not found`);
|
|
360198
|
+
}
|
|
360199
|
+
throw databaseAccessFailure(error2) ?? error2;
|
|
360200
|
+
}
|
|
360201
|
+
}
|
|
360202
|
+
async lookupMalloyConnection(environmentName, connectionName, packageName) {
|
|
359719
360203
|
const environment = await this.environmentStore.getEnvironment(environmentName, false);
|
|
359720
360204
|
if (connectionName === "duckdb") {
|
|
359721
360205
|
const packages = await environment.listPackages();
|
|
@@ -359752,7 +360236,9 @@ class ConnectionController {
|
|
|
359752
360236
|
throw new TableNotFoundError(`Table ${tablePath} not found`);
|
|
359753
360237
|
}
|
|
359754
360238
|
if (typeof source === "string") {
|
|
359755
|
-
|
|
360239
|
+
const prefix = `Error fetching schema for ${tablePath}: `;
|
|
360240
|
+
const accessFailure = source.startsWith(prefix) ? databaseAccessFailure(new Error(source.slice(prefix.length))) : undefined;
|
|
360241
|
+
throw accessFailure ?? driverErrorToPublisherError(source);
|
|
359756
360242
|
}
|
|
359757
360243
|
return {
|
|
359758
360244
|
source: JSON.stringify(source),
|
|
@@ -359764,7 +360250,7 @@ class ConnectionController {
|
|
|
359764
360250
|
};
|
|
359765
360251
|
} catch (error2) {
|
|
359766
360252
|
const classified = classifyDriverFailure(error2);
|
|
359767
|
-
if (
|
|
360253
|
+
if (classified instanceof TableNotFoundError || classified instanceof InvalidArgumentError) {
|
|
359768
360254
|
logger.warn("table not resolvable", {
|
|
359769
360255
|
tableKey,
|
|
359770
360256
|
tablePath,
|
|
@@ -359772,6 +360258,8 @@ class ConnectionController {
|
|
|
359772
360258
|
});
|
|
359773
360259
|
throw classified;
|
|
359774
360260
|
}
|
|
360261
|
+
if (databaseAccessFailure(classified))
|
|
360262
|
+
throw classified;
|
|
359775
360263
|
logger.error("fetchTableSchema error", {
|
|
359776
360264
|
error: error2,
|
|
359777
360265
|
tableKey,
|
|
@@ -359795,13 +360283,13 @@ class ConnectionController {
|
|
|
359795
360283
|
const environment = await this.environmentStore.getEnvironment(environmentName, false);
|
|
359796
360284
|
const connection = this.getApiConnectionForLookup(environment, connectionName);
|
|
359797
360285
|
const malloyConnection = await this.getMalloyConnection(environmentName, connectionName, packageName);
|
|
359798
|
-
return getSchemasForConnection(connection, malloyConnection);
|
|
360286
|
+
return withDatabaseAccessClassified(() => getSchemasForConnection(connection, malloyConnection));
|
|
359799
360287
|
}
|
|
359800
360288
|
async listTables(environmentName, connectionName, schemaName, tableNames, packageName) {
|
|
359801
360289
|
const environment = await this.environmentStore.getEnvironment(environmentName, false);
|
|
359802
360290
|
const connection = this.getApiConnectionForLookup(environment, connectionName);
|
|
359803
360291
|
const malloyConnection = await this.getMalloyConnection(environmentName, connectionName, packageName);
|
|
359804
|
-
return listTablesForSchema(connection, schemaName, malloyConnection, tableNames);
|
|
360292
|
+
return withDatabaseAccessClassified(() => listTablesForSchema(connection, schemaName, malloyConnection, tableNames));
|
|
359805
360293
|
}
|
|
359806
360294
|
async getConnectionSqlSource(environmentName, connectionName, sqlStatement, packageName) {
|
|
359807
360295
|
const malloyConnection = await this.getMalloyConnection(environmentName, connectionName, packageName);
|
|
@@ -359919,9 +360407,9 @@ class ConnectionController {
|
|
|
359919
360407
|
try {
|
|
359920
360408
|
return await streamSqlWithBudget(malloyConnection, sqlStatement, optionsWithSignal, { maxRows, maxBytes });
|
|
359921
360409
|
} catch (error2) {
|
|
359922
|
-
if (error2 instanceof PayloadTooLargeError)
|
|
360410
|
+
if (error2 instanceof PayloadTooLargeError || error2 instanceof ConnectionError)
|
|
359923
360411
|
throw error2;
|
|
359924
|
-
throw
|
|
360412
|
+
throw sqlRunFailure(error2);
|
|
359925
360413
|
}
|
|
359926
360414
|
}, getQueryTimeoutMs());
|
|
359927
360415
|
return { data: JSON.stringify(streamed), queryCorrelationId };
|
|
@@ -359934,7 +360422,7 @@ class ConnectionController {
|
|
|
359934
360422
|
try {
|
|
359935
360423
|
return await malloyConnection.runSQL(sqlStatement, optionsWithSignal);
|
|
359936
360424
|
} catch (error2) {
|
|
359937
|
-
throw
|
|
360425
|
+
throw sqlRunFailure(error2);
|
|
359938
360426
|
}
|
|
359939
360427
|
}, getQueryTimeoutMs());
|
|
359940
360428
|
if (maxRows > 0 && result.rows.length > maxRows) {
|
|
@@ -359966,7 +360454,7 @@ class ConnectionController {
|
|
|
359966
360454
|
} catch (error2) {
|
|
359967
360455
|
if (signal.aborted)
|
|
359968
360456
|
throw error2;
|
|
359969
|
-
throw
|
|
360457
|
+
throw sqlRunFailure(error2);
|
|
359970
360458
|
}
|
|
359971
360459
|
}, getQueryTimeoutMs());
|
|
359972
360460
|
}
|
|
@@ -365101,6 +365589,15 @@ function getNotFoundError(resourceUriOrContext) {
|
|
|
365101
365589
|
suggestions
|
|
365102
365590
|
};
|
|
365103
365591
|
}
|
|
365592
|
+
function getEnvironmentNotFoundError(lookup) {
|
|
365593
|
+
const available = lookup.availableEnvironments.length > 0 ? `Available environments: ${lookup.availableEnvironments.join(", ")}.` : "This server has no environments loaded.";
|
|
365594
|
+
return {
|
|
365595
|
+
message: `Environment '${lookup.environmentName}' not found. ${available} Use a name from list_packages.`,
|
|
365596
|
+
suggestions: [
|
|
365597
|
+
"Environment names must match exactly, including case. Retrying with the same name fails the same way."
|
|
365598
|
+
]
|
|
365599
|
+
};
|
|
365600
|
+
}
|
|
365104
365601
|
function getInternalError(operation, error2) {
|
|
365105
365602
|
const baseMessage = `An unexpected internal error occurred during ${operation}.`;
|
|
365106
365603
|
const suggestions = [
|
|
@@ -365229,15 +365726,56 @@ function classifyToolError(operation, identifier, error2) {
|
|
|
365229
365726
|
]
|
|
365230
365727
|
};
|
|
365231
365728
|
}
|
|
365729
|
+
if (error2 instanceof EnvironmentNotFoundError && error2.lookup) {
|
|
365730
|
+
return getEnvironmentNotFoundError(error2.lookup);
|
|
365731
|
+
}
|
|
365232
365732
|
if (error2 instanceof EnvironmentNotFoundError || error2 instanceof PackageNotFoundError || error2 instanceof ModelNotFoundError || error2 instanceof NotQueryableError || error2 instanceof ConnectionNotFoundError) {
|
|
365233
365733
|
return getNotFoundError(identifier);
|
|
365234
365734
|
}
|
|
365735
|
+
if (error2 instanceof ConnectionFailedError) {
|
|
365736
|
+
logInternalFailure(`Database unreachable during ${operation}`, error2, "warn");
|
|
365737
|
+
return {
|
|
365738
|
+
message: `The database connection for ${identifier} is down: the database could not be reached, so the query never ran.`,
|
|
365739
|
+
suggestions: [
|
|
365740
|
+
"The query and the model are fine. Do not rewrite them.",
|
|
365741
|
+
"Retry once. If it fails again, report that the database connection is down rather than changing the query."
|
|
365742
|
+
]
|
|
365743
|
+
};
|
|
365744
|
+
}
|
|
365745
|
+
if (error2 instanceof UnconfiguredConnectionError) {
|
|
365746
|
+
return {
|
|
365747
|
+
message: `${error2.message}. The model for ${identifier} uses a connection this environment does not have, so the query never ran.`,
|
|
365748
|
+
suggestions: [
|
|
365749
|
+
"The query is fine. Do not rewrite it, and do not retry: it fails the same way until the connection exists.",
|
|
365750
|
+
"Report that the environment is missing this connection; it was likely deleted or renamed."
|
|
365751
|
+
]
|
|
365752
|
+
};
|
|
365753
|
+
}
|
|
365754
|
+
if (error2 instanceof ConnectionAuthError) {
|
|
365755
|
+
logInternalFailure(`Connection credentials rejected during ${operation}`, error2, "warn");
|
|
365756
|
+
return {
|
|
365757
|
+
message: `The database rejected the connection's credentials for ${identifier}. The query never ran.`,
|
|
365758
|
+
suggestions: [
|
|
365759
|
+
"The query and the model are fine. Do not rewrite them, and do not retry: it fails the same way until the credentials are fixed.",
|
|
365760
|
+
"Report that the connection's user, password, key or token needs updating."
|
|
365761
|
+
]
|
|
365762
|
+
};
|
|
365763
|
+
}
|
|
365235
365764
|
if (error2 instanceof ServiceUnavailableError) {
|
|
365236
365765
|
return {
|
|
365237
365766
|
message: error2.message,
|
|
365238
365767
|
suggestions: [...BACK_PRESSURE_SUGGESTIONS]
|
|
365239
365768
|
};
|
|
365240
365769
|
}
|
|
365770
|
+
if (error2 instanceof ConnectionPoolExhaustedError) {
|
|
365771
|
+
return {
|
|
365772
|
+
message: error2.message,
|
|
365773
|
+
suggestions: [
|
|
365774
|
+
"Retry shortly; this clears as other queries on the connection finish.",
|
|
365775
|
+
"If it persists, run fewer queries at once on this connection, or ask the operator to raise PUBLISHER_POSTGRES_POOL_MAX."
|
|
365776
|
+
]
|
|
365777
|
+
};
|
|
365778
|
+
}
|
|
365241
365779
|
if (error2 instanceof ResponseUnserializableError) {
|
|
365242
365780
|
return {
|
|
365243
365781
|
message: error2.message,
|
|
@@ -365285,7 +365823,7 @@ async function getModelForQuery(environmentStore, environmentName, packageName,
|
|
|
365285
365823
|
} catch (error2) {
|
|
365286
365824
|
let errorDetails;
|
|
365287
365825
|
if (error2 instanceof EnvironmentNotFoundError) {
|
|
365288
|
-
errorDetails =
|
|
365826
|
+
errorDetails = classifyToolError("executeQuery (load model)", `environment '${environmentName}'`, error2);
|
|
365289
365827
|
} else if (error2 instanceof PackageNotFoundError) {
|
|
365290
365828
|
errorDetails = getNotFoundError(`package '${packageName}' in environment '${environmentName}'`);
|
|
365291
365829
|
} else if (error2 instanceof ModelNotFoundError) {
|
|
@@ -377059,6 +377597,7 @@ init_notebook();
|
|
|
377059
377597
|
init_dashboard();
|
|
377060
377598
|
|
|
377061
377599
|
// src/service/notebook_lint.ts
|
|
377600
|
+
init_dashboard();
|
|
377062
377601
|
init_notebook();
|
|
377063
377602
|
init_motly();
|
|
377064
377603
|
var KNOWN_KINDS = ["notebook", "dashboard", "text"];
|
|
@@ -377517,6 +378056,9 @@ function lintNotebookText(modelPath, text) {
|
|
|
377517
378056
|
}
|
|
377518
378057
|
}
|
|
377519
378058
|
}
|
|
378059
|
+
function reportedByDashboardLint(finding, modelPath, dashboardFindings) {
|
|
378060
|
+
return finding.code === "notebook-artifact-unparsed" && dashboardFindings.some((f5) => f5.model === modelPath && isUnparsedDashboardTagFinding(f5.message ?? ""));
|
|
378061
|
+
}
|
|
377520
378062
|
function notebookLintProblems(modelPath, text, url3) {
|
|
377521
378063
|
return lintNotebookText(modelPath, text).map((finding) => {
|
|
377522
378064
|
const line = finding.line - 1;
|
|
@@ -378768,6 +379310,135 @@ class Package {
|
|
|
378768
379310
|
throw error2;
|
|
378769
379311
|
}
|
|
378770
379312
|
}
|
|
379313
|
+
static packageConfigFromOutcome(environmentName, packageName, outcome) {
|
|
379314
|
+
return {
|
|
379315
|
+
name: outcome.packageMetadata.name,
|
|
379316
|
+
description: outcome.packageMetadata.description,
|
|
379317
|
+
location: outcome.packageMetadata.location,
|
|
379318
|
+
resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}`,
|
|
379319
|
+
explores: outcome.packageMetadata.explores,
|
|
379320
|
+
queryableSources: outcome.packageMetadata.queryableSources,
|
|
379321
|
+
manifestLocation: outcome.packageMetadata.manifestLocation ?? null,
|
|
379322
|
+
materialization: outcome.packageMetadata.materialization ?? {
|
|
379323
|
+
schedule: null,
|
|
379324
|
+
freshness: null
|
|
379325
|
+
},
|
|
379326
|
+
queryMetadata: outcome.packageMetadata.materialization?.queryMetadata ?? null,
|
|
379327
|
+
scope: outcome.packageMetadata.scope ?? "package"
|
|
379328
|
+
};
|
|
379329
|
+
}
|
|
379330
|
+
static async hydrateWorkerModels(outcome, ctx, onCompileError, afterHydrate) {
|
|
379331
|
+
const { packageName, packagePath, malloyConfig, buildManifest } = ctx;
|
|
379332
|
+
const models = new Map;
|
|
379333
|
+
const renderTagWarnings = [];
|
|
379334
|
+
const placeholder = (sm, err) => Model.fromCompilationError(packageName, sm.modelPath, sm.modelType, err, sm.modelPath === ctx.replacement?.modelPath ? ctx.replacement.source : undefined);
|
|
379335
|
+
for (const sm of outcome.models) {
|
|
379336
|
+
if (sm.compilationError) {
|
|
379337
|
+
const err = Model.deserializeCompilationError(sm.compilationError);
|
|
379338
|
+
if (onCompileError === "throw") {
|
|
379339
|
+
logger.error("Model compilation failed", {
|
|
379340
|
+
packageName,
|
|
379341
|
+
modelPath: sm.modelPath,
|
|
379342
|
+
error: err.message
|
|
379343
|
+
});
|
|
379344
|
+
throw err;
|
|
379345
|
+
}
|
|
379346
|
+
if (!ctx.compile) {
|
|
379347
|
+
logger.warn("Model compilation failed during reload", {
|
|
379348
|
+
packageName,
|
|
379349
|
+
modelPath: sm.modelPath,
|
|
379350
|
+
error: err.message
|
|
379351
|
+
});
|
|
379352
|
+
}
|
|
379353
|
+
models.set(sm.modelPath, placeholder(sm, err));
|
|
379354
|
+
continue;
|
|
379355
|
+
}
|
|
379356
|
+
let model;
|
|
379357
|
+
try {
|
|
379358
|
+
model = Model.fromSerialized(packageName, packagePath, malloyConfig, sm, {
|
|
379359
|
+
buildManifest,
|
|
379360
|
+
skipAuthorizeWarningLog: ctx.compile
|
|
379361
|
+
});
|
|
379362
|
+
} catch (hydrateErr) {
|
|
379363
|
+
if (!ctx.compile)
|
|
379364
|
+
throw hydrateErr;
|
|
379365
|
+
logger.warn("Model hydration failed during compile", {
|
|
379366
|
+
packageName,
|
|
379367
|
+
modelPath: sm.modelPath,
|
|
379368
|
+
error: errMessage(hydrateErr)
|
|
379369
|
+
});
|
|
379370
|
+
renderTagWarnings.push({
|
|
379371
|
+
model: sm.modelPath,
|
|
379372
|
+
message: `The load-time checks could not read this model ` + `(${errMessage(hydrateErr)}), so its render-tag and dashboard findings ` + `are unknown rather than clean.`,
|
|
379373
|
+
severity: "error"
|
|
379374
|
+
});
|
|
379375
|
+
models.set(sm.modelPath, placeholder(sm, hydrateErr instanceof Error ? hydrateErr : new Error(String(hydrateErr))));
|
|
379376
|
+
continue;
|
|
379377
|
+
}
|
|
379378
|
+
let warnings;
|
|
379379
|
+
try {
|
|
379380
|
+
warnings = await model.validateRenderTags();
|
|
379381
|
+
} catch (renderErr) {
|
|
379382
|
+
if (onCompileError === "throw")
|
|
379383
|
+
throw renderErr;
|
|
379384
|
+
const err = renderErr instanceof Error ? renderErr : new Error(String(renderErr));
|
|
379385
|
+
logger.warn(`Render-tag validation failed during ${ctx.compile ? "compile" : "reload"}`, {
|
|
379386
|
+
packageName,
|
|
379387
|
+
modelPath: sm.modelPath,
|
|
379388
|
+
error: err.message
|
|
379389
|
+
});
|
|
379390
|
+
if (ctx.compile) {
|
|
379391
|
+
renderTagWarnings.push({
|
|
379392
|
+
model: sm.modelPath,
|
|
379393
|
+
message: `The render-tag check did not finish for this model, so ` + `its render-tag findings are unknown rather than clean.`,
|
|
379394
|
+
severity: "warn"
|
|
379395
|
+
});
|
|
379396
|
+
}
|
|
379397
|
+
models.set(sm.modelPath, placeholder(sm, err));
|
|
379398
|
+
continue;
|
|
379399
|
+
}
|
|
379400
|
+
for (const w2 of warnings) {
|
|
379401
|
+
renderTagWarnings.push({
|
|
379402
|
+
model: sm.modelPath,
|
|
379403
|
+
subject: w2.subject,
|
|
379404
|
+
message: w2.message,
|
|
379405
|
+
severity: w2.severity
|
|
379406
|
+
});
|
|
379407
|
+
}
|
|
379408
|
+
await afterHydrate?.(sm);
|
|
379409
|
+
models.set(sm.modelPath, model);
|
|
379410
|
+
}
|
|
379411
|
+
return { models, renderTagWarnings };
|
|
379412
|
+
}
|
|
379413
|
+
static async lintWorkerOutcome(environmentName, packageName, packagePath, malloyConfig, outcome, buildManifest, replacement) {
|
|
379414
|
+
const { models, renderTagWarnings } = await Package.hydrateWorkerModels(outcome, {
|
|
379415
|
+
packageName,
|
|
379416
|
+
packagePath,
|
|
379417
|
+
malloyConfig,
|
|
379418
|
+
buildManifest,
|
|
379419
|
+
compile: true,
|
|
379420
|
+
replacement
|
|
379421
|
+
}, "placeholder");
|
|
379422
|
+
const pkg = new Package(environmentName, packageName, packagePath, Package.packageConfigFromOutcome(environmentName, packageName, outcome), [], models, malloyConfig);
|
|
379423
|
+
try {
|
|
379424
|
+
await pkg.discoverDashboards({ dryRun: true });
|
|
379425
|
+
} catch (err) {
|
|
379426
|
+
logger.warn("Dashboard lint failed during compile", {
|
|
379427
|
+
packageName,
|
|
379428
|
+
error: errMessage(err)
|
|
379429
|
+
});
|
|
379430
|
+
return {
|
|
379431
|
+
renderTagWarnings,
|
|
379432
|
+
dashboardWarnings: [
|
|
379433
|
+
{
|
|
379434
|
+
message: "The dashboard checks did not run, so their findings " + "are unknown rather than clean. Reload the package to " + 'see them. The cause is in the server log under "Dashboard ' + 'lint failed during compile".',
|
|
379435
|
+
severity: "warn"
|
|
379436
|
+
}
|
|
379437
|
+
]
|
|
379438
|
+
};
|
|
379439
|
+
}
|
|
379440
|
+
return { renderTagWarnings, dashboardWarnings: pkg.dashboardWarnings };
|
|
379441
|
+
}
|
|
378771
379442
|
static async loadViaWorker(environmentName, packageName, packagePath, malloyConfig, startTime, manifestValidationTime) {
|
|
378772
379443
|
const pool = getPackageLoadPool();
|
|
378773
379444
|
const dispatchTime = performance.now();
|
|
@@ -378800,49 +379471,16 @@ class Package {
|
|
|
378800
379471
|
modelCount: outcome.models.length,
|
|
378801
379472
|
databaseCount: databases.length
|
|
378802
379473
|
});
|
|
378803
|
-
const packageConfig =
|
|
378804
|
-
|
|
378805
|
-
|
|
378806
|
-
location: outcome.packageMetadata.location,
|
|
378807
|
-
resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}`,
|
|
378808
|
-
explores: outcome.packageMetadata.explores,
|
|
378809
|
-
queryableSources: outcome.packageMetadata.queryableSources,
|
|
378810
|
-
manifestLocation: outcome.packageMetadata.manifestLocation ?? null,
|
|
378811
|
-
materialization: outcome.packageMetadata.materialization ?? {
|
|
378812
|
-
schedule: null,
|
|
378813
|
-
freshness: null
|
|
378814
|
-
},
|
|
378815
|
-
queryMetadata: outcome.packageMetadata.materialization?.queryMetadata ?? null,
|
|
378816
|
-
scope: outcome.packageMetadata.scope ?? "package"
|
|
378817
|
-
};
|
|
378818
|
-
const models = new Map;
|
|
378819
|
-
const renderTagWarnings = [];
|
|
379474
|
+
const packageConfig = Package.packageConfigFromOutcome(environmentName, packageName, outcome);
|
|
379475
|
+
let models;
|
|
379476
|
+
let renderTagWarnings;
|
|
378820
379477
|
try {
|
|
378821
|
-
|
|
378822
|
-
if (sm.compilationError) {
|
|
378823
|
-
const err = Model.deserializeCompilationError(sm.compilationError);
|
|
378824
|
-
logger.error("Model compilation failed", {
|
|
378825
|
-
packageName,
|
|
378826
|
-
modelPath: sm.modelPath,
|
|
378827
|
-
error: err.message
|
|
378828
|
-
});
|
|
378829
|
-
throw err;
|
|
378830
|
-
}
|
|
378831
|
-
const model = Model.fromSerialized(packageName, packagePath, malloyConfig, sm);
|
|
378832
|
-
for (const w2 of await model.validateRenderTags()) {
|
|
378833
|
-
renderTagWarnings.push({
|
|
378834
|
-
model: sm.modelPath,
|
|
378835
|
-
subject: w2.subject,
|
|
378836
|
-
message: w2.message,
|
|
378837
|
-
severity: w2.severity
|
|
378838
|
-
});
|
|
378839
|
-
}
|
|
379478
|
+
({ models, renderTagWarnings } = await Package.hydrateWorkerModels(outcome, { packageName, packagePath, malloyConfig }, "throw", async (sm) => {
|
|
378840
379479
|
if (sm.modelPath.endsWith(MODEL_FILE_SUFFIX)) {
|
|
378841
379480
|
const modelSource = await fs11.readFile(path13.join(packagePath, sm.modelPath), "utf-8");
|
|
378842
379481
|
assertPersistNamesQuoted(modelSource, sm.modelPath);
|
|
378843
379482
|
}
|
|
378844
|
-
|
|
378845
|
-
}
|
|
379483
|
+
}));
|
|
378846
379484
|
} catch (err) {
|
|
378847
379485
|
recordPackageLoadPhases(outcome.timings, packageLoadFailureStatus(err));
|
|
378848
379486
|
throw err;
|
|
@@ -379020,6 +379658,7 @@ class Package {
|
|
|
379020
379658
|
...this.droppedPersistWarnings(),
|
|
379021
379659
|
...this.persistRefusalWarnings(),
|
|
379022
379660
|
...this.emptyDiscoveryWarnings(),
|
|
379661
|
+
...this.legacyFormatWarnings(),
|
|
379023
379662
|
...this.brokenSurfaceWarnings(),
|
|
379024
379663
|
...this.surfaceWidenedWarning !== undefined ? [{ message: this.surfaceWidenedWarning }] : [],
|
|
379025
379664
|
...this.persistenceCollisionWarnings().map((message) => ({ message })),
|
|
@@ -379363,6 +380002,28 @@ class Package {
|
|
|
379363
380002
|
}
|
|
379364
380003
|
return warnings;
|
|
379365
380004
|
}
|
|
380005
|
+
legacyFormatWarnings() {
|
|
380006
|
+
const notebooks = [];
|
|
380007
|
+
const filters = [];
|
|
380008
|
+
for (const [modelPath, model] of this.models) {
|
|
380009
|
+
if (modelPath.endsWith(NOTEBOOK_FILE_SUFFIX)) {
|
|
380010
|
+
notebooks.push({
|
|
380011
|
+
model: modelPath,
|
|
380012
|
+
severity: "warn",
|
|
380013
|
+
message: `${modelPath} is a .malloynb notebook, a deprecated format. ` + `Convert it to a .malloy notebook under notebooks/.`
|
|
380014
|
+
});
|
|
380015
|
+
}
|
|
380016
|
+
for (const name of model.declaredFilterSourceNames()) {
|
|
380017
|
+
filters.push({
|
|
380018
|
+
model: modelPath,
|
|
380019
|
+
subject: name,
|
|
380020
|
+
severity: "warn",
|
|
380021
|
+
message: `Source ${name} uses deprecated #(filter) annotations. Replace them ` + `with given: runtime parameters (see ${GIVENS_DOCS_URL}).`
|
|
380022
|
+
});
|
|
380023
|
+
}
|
|
380024
|
+
}
|
|
380025
|
+
return [...notebooks, ...filters];
|
|
380026
|
+
}
|
|
379366
380027
|
brokenSurfaceWarnings() {
|
|
379367
380028
|
const exploreSet = this.exploreSet();
|
|
379368
380029
|
if (!exploreSet || exploreSet.size === 0)
|
|
@@ -379468,40 +380129,12 @@ class Package {
|
|
|
379468
380129
|
throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`, { cause: realError });
|
|
379469
380130
|
}
|
|
379470
380131
|
assertRequiredStagesAvailable(outcome.packageMetadata.retrieval ?? DEFAULT_PACKAGE_RETRIEVAL, llmConfigured());
|
|
379471
|
-
const nextModels =
|
|
379472
|
-
|
|
379473
|
-
|
|
379474
|
-
|
|
379475
|
-
|
|
379476
|
-
|
|
379477
|
-
packageName: this.packageName,
|
|
379478
|
-
modelPath: sm.modelPath,
|
|
379479
|
-
error: err.message
|
|
379480
|
-
});
|
|
379481
|
-
nextModels.set(sm.modelPath, Model.fromCompilationError(this.packageName, sm.modelPath, sm.modelType, err));
|
|
379482
|
-
} else {
|
|
379483
|
-
const model = Model.fromSerialized(this.packageName, this.packagePath, this.malloyConfig, sm, { buildManifest });
|
|
379484
|
-
try {
|
|
379485
|
-
for (const w2 of await model.validateRenderTags()) {
|
|
379486
|
-
renderTagWarnings.push({
|
|
379487
|
-
model: sm.modelPath,
|
|
379488
|
-
subject: w2.subject,
|
|
379489
|
-
message: w2.message,
|
|
379490
|
-
severity: w2.severity
|
|
379491
|
-
});
|
|
379492
|
-
}
|
|
379493
|
-
nextModels.set(sm.modelPath, model);
|
|
379494
|
-
} catch (renderErr) {
|
|
379495
|
-
const err = renderErr instanceof Error ? renderErr : new Error(String(renderErr));
|
|
379496
|
-
logger.warn("Render-tag validation failed during reload", {
|
|
379497
|
-
packageName: this.packageName,
|
|
379498
|
-
modelPath: sm.modelPath,
|
|
379499
|
-
error: err.message
|
|
379500
|
-
});
|
|
379501
|
-
nextModels.set(sm.modelPath, Model.fromCompilationError(this.packageName, sm.modelPath, sm.modelType, err));
|
|
379502
|
-
}
|
|
379503
|
-
}
|
|
379504
|
-
}
|
|
380132
|
+
const { models: nextModels, renderTagWarnings } = await Package.hydrateWorkerModels(outcome, {
|
|
380133
|
+
packageName: this.packageName,
|
|
380134
|
+
packagePath: this.packagePath,
|
|
380135
|
+
malloyConfig: this.malloyConfig,
|
|
380136
|
+
buildManifest
|
|
380137
|
+
}, "placeholder");
|
|
379505
380138
|
this.models = nextModels;
|
|
379506
380139
|
this.pushStorageServeBindingsToModels();
|
|
379507
380140
|
this.pushServeDestinationConfigToModels();
|
|
@@ -379571,9 +380204,9 @@ class Package {
|
|
|
379571
380204
|
return false;
|
|
379572
380205
|
return this.exploresDeclared();
|
|
379573
380206
|
}
|
|
379574
|
-
async claimsToBeADashboard(modelPath) {
|
|
380207
|
+
async claimsToBeADashboard(model, modelPath) {
|
|
379575
380208
|
try {
|
|
379576
|
-
const source = await fs11.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
|
|
380209
|
+
const source = model.getCompiledSourceText() ?? await fs11.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
|
|
379577
380210
|
return hasArtifactLineOutsideBlocks(source, ANY_ARTIFACT_NOTE);
|
|
379578
380211
|
} catch {
|
|
379579
380212
|
return false;
|
|
@@ -379587,13 +380220,14 @@ class Package {
|
|
|
379587
380220
|
}
|
|
379588
380221
|
if (!model.getCompilationError())
|
|
379589
380222
|
return untagged;
|
|
379590
|
-
const source = await fs11.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
|
|
380223
|
+
const source = model.getCompiledSourceText() ?? await fs11.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
|
|
379591
380224
|
return claimsToBeANotebook(source) ? documentKind(modelPath, artifactKindInText(source)) : untagged;
|
|
379592
380225
|
} catch {
|
|
379593
380226
|
return untagged;
|
|
379594
380227
|
}
|
|
379595
380228
|
}
|
|
379596
|
-
async discoverDashboards() {
|
|
380229
|
+
async discoverDashboards(options = {}) {
|
|
380230
|
+
const during = options.dryRun ? { during: "compile" } : {};
|
|
379597
380231
|
const discovered = new Map;
|
|
379598
380232
|
const factsByPath = new Map;
|
|
379599
380233
|
const allFacts = new Map;
|
|
@@ -379615,6 +380249,7 @@ class Package {
|
|
|
379615
380249
|
} catch (err) {
|
|
379616
380250
|
logger.warn("Reading a model's dashboard facts failed", {
|
|
379617
380251
|
packageName: this.packageName,
|
|
380252
|
+
...during,
|
|
379618
380253
|
modelPath,
|
|
379619
380254
|
error: errMessage(err)
|
|
379620
380255
|
});
|
|
@@ -379635,6 +380270,7 @@ class Package {
|
|
|
379635
380270
|
} catch (err) {
|
|
379636
380271
|
logger.warn("Notebook layout discovery failed", {
|
|
379637
380272
|
packageName: this.packageName,
|
|
380273
|
+
...during,
|
|
379638
380274
|
modelPath,
|
|
379639
380275
|
error: errMessage(err)
|
|
379640
380276
|
});
|
|
@@ -379649,6 +380285,7 @@ class Package {
|
|
|
379649
380285
|
} catch (err) {
|
|
379650
380286
|
logger.warn("Dashboard discovery failed", {
|
|
379651
380287
|
packageName: this.packageName,
|
|
380288
|
+
...during,
|
|
379652
380289
|
modelPath,
|
|
379653
380290
|
error: errMessage(err)
|
|
379654
380291
|
});
|
|
@@ -379664,10 +380301,11 @@ class Package {
|
|
|
379664
380301
|
}
|
|
379665
380302
|
} else {
|
|
379666
380303
|
const error2 = model.getCompilationError();
|
|
379667
|
-
if (!error2 || !await this.claimsToBeADashboard(modelPath)) {
|
|
379668
|
-
if (!error2 && await this.claimsToBeADashboard(modelPath)) {
|
|
380304
|
+
if (!error2 || !await this.claimsToBeADashboard(model, modelPath)) {
|
|
380305
|
+
if (!error2 && await this.claimsToBeADashboard(model, modelPath)) {
|
|
379669
380306
|
logger.warn("Dashboard file produced no facts and no error", {
|
|
379670
380307
|
packageName: this.packageName,
|
|
380308
|
+
...during,
|
|
379671
380309
|
modelPath
|
|
379672
380310
|
});
|
|
379673
380311
|
droppedByError.push({ modelPath, name });
|
|
@@ -379706,7 +380344,7 @@ class Package {
|
|
|
379706
380344
|
if (!model)
|
|
379707
380345
|
continue;
|
|
379708
380346
|
try {
|
|
379709
|
-
dashboardFileText.set(manifest.entryFile, await model.getFileText(this.packagePath));
|
|
380347
|
+
dashboardFileText.set(manifest.entryFile, model.getCompiledSourceText() ?? await model.getFileText(this.packagePath));
|
|
379710
380348
|
} catch {
|
|
379711
380349
|
dashboardFileText.set(manifest.entryFile, "");
|
|
379712
380350
|
}
|
|
@@ -379725,10 +380363,6 @@ class Package {
|
|
|
379725
380363
|
}
|
|
379726
380364
|
this.notebookFileText = notebookFileText;
|
|
379727
380365
|
this.applyQueryBoundaryToModels();
|
|
379728
|
-
this.notebookWarnings = [
|
|
379729
|
-
...this.attachNotebookCells(),
|
|
379730
|
-
...await this.lintNotebookFiles()
|
|
379731
|
-
];
|
|
379732
380366
|
this.lintInputs = {
|
|
379733
380367
|
factsByPath,
|
|
379734
380368
|
allFacts,
|
|
@@ -379737,7 +380371,13 @@ class Package {
|
|
|
379737
380371
|
droppedByError,
|
|
379738
380372
|
shadowed
|
|
379739
380373
|
};
|
|
379740
|
-
await this.relintDashboards();
|
|
380374
|
+
await this.relintDashboards({ log: !options.dryRun });
|
|
380375
|
+
if (!options.dryRun) {
|
|
380376
|
+
this.notebookWarnings = [
|
|
380377
|
+
...this.attachNotebookCells(),
|
|
380378
|
+
...await this.lintNotebookFiles()
|
|
380379
|
+
];
|
|
380380
|
+
}
|
|
379741
380381
|
}
|
|
379742
380382
|
attachNotebookCells() {
|
|
379743
380383
|
const warnings = [];
|
|
@@ -379782,7 +380422,7 @@ class Package {
|
|
|
379782
380422
|
}
|
|
379783
380423
|
}
|
|
379784
380424
|
for (const finding of lintNotebookText(modelPath, text)) {
|
|
379785
|
-
if (finding
|
|
380425
|
+
if (reportedByDashboardLint(finding, modelPath, this.dashboardWarnings))
|
|
379786
380426
|
continue;
|
|
379787
380427
|
logger.warn("Notebook lint", {
|
|
379788
380428
|
packageName: this.packageName,
|
|
@@ -379799,11 +380439,13 @@ class Package {
|
|
|
379799
380439
|
return warnings;
|
|
379800
380440
|
}
|
|
379801
380441
|
lintInputs;
|
|
379802
|
-
async relintDashboards() {
|
|
380442
|
+
async relintDashboards(options = {}) {
|
|
379803
380443
|
const inputs = this.lintInputs;
|
|
379804
380444
|
if (!inputs)
|
|
379805
380445
|
return;
|
|
379806
380446
|
this.dashboardWarnings = await this.lintDashboards(inputs.factsByPath, inputs.allFacts, inputs.unconventionalSlugs, inputs.dashboardSlugs, inputs.droppedByError, inputs.shadowed);
|
|
380447
|
+
if (options.log === false)
|
|
380448
|
+
return;
|
|
379807
380449
|
for (const warning of this.dashboardWarnings) {
|
|
379808
380450
|
logger.warn("Dashboard lint", {
|
|
379809
380451
|
packageName: this.packageName,
|
|
@@ -380457,6 +381099,7 @@ ${source}` : source ?? "";
|
|
|
380457
381099
|
problems.push(tagged);
|
|
380458
381100
|
}
|
|
380459
381101
|
};
|
|
381102
|
+
const { renderTagWarnings, dashboardWarnings } = await Package.lintWorkerOutcome(this.environmentName, packageName, packagePath, pkg.getMalloyConfig(), outcome, boundManifestEntries, source === undefined ? undefined : { modelPath: modelName, source });
|
|
380460
381103
|
for (const compiled of outcome.models) {
|
|
380461
381104
|
if (compiled.problems) {
|
|
380462
381105
|
collect(compiled.problems, compiled.modelPath);
|
|
@@ -380481,9 +381124,20 @@ ${source}` : source ?? "";
|
|
|
380481
381124
|
return;
|
|
380482
381125
|
}));
|
|
380483
381126
|
if (lintText !== undefined) {
|
|
380484
|
-
collect(notebookLintProblems(compiled.modelPath, lintText, pathToFileURL3(path14.join(packagePath, compiled.modelPath)).toString()), compiled.modelPath);
|
|
381127
|
+
collect(notebookLintProblems(compiled.modelPath, lintText, pathToFileURL3(path14.join(packagePath, compiled.modelPath)).toString()).filter((problem) => !reportedByDashboardLint(problem, compiled.modelPath, dashboardWarnings)), compiled.modelPath);
|
|
380485
381128
|
}
|
|
380486
381129
|
}
|
|
381130
|
+
const asProblem = (warning, code) => ({
|
|
381131
|
+
severity: warning.severity ?? "warn",
|
|
381132
|
+
message: warning.subject ? `${warning.subject}: ${warning.message}` : warning.message,
|
|
381133
|
+
code
|
|
381134
|
+
});
|
|
381135
|
+
for (const warning of renderTagWarnings) {
|
|
381136
|
+
collect([asProblem(warning, "render-tag")], warning.model);
|
|
381137
|
+
}
|
|
381138
|
+
for (const warning of dashboardWarnings) {
|
|
381139
|
+
collect([asProblem(warning, "dashboard-lint")], warning.model);
|
|
381140
|
+
}
|
|
380487
381141
|
if (source !== undefined && outcome.replacementMatchedExisting === false) {
|
|
380488
381142
|
collect([
|
|
380489
381143
|
{
|
|
@@ -381636,7 +382290,7 @@ ${query}`);
|
|
|
381636
382290
|
// src/service/environment_store.ts
|
|
381637
382291
|
init_google_credentials();
|
|
381638
382292
|
// package.json
|
|
381639
|
-
var version2 = "0.9.
|
|
382293
|
+
var version2 = "0.9.7";
|
|
381640
382294
|
|
|
381641
382295
|
// src/version.ts
|
|
381642
382296
|
var SERVER_VERSION = version2;
|
|
@@ -381869,6 +382523,7 @@ class EnvironmentStore {
|
|
|
381869
382523
|
initError = null;
|
|
381870
382524
|
environmentMutexes = new Map;
|
|
381871
382525
|
publisherConfigIsFrozen;
|
|
382526
|
+
mcpIncludeHiddenFilesAndSources;
|
|
381872
382527
|
finishedInitialization;
|
|
381873
382528
|
isInitialized = false;
|
|
381874
382529
|
storageManager;
|
|
@@ -381881,6 +382536,7 @@ class EnvironmentStore {
|
|
|
381881
382536
|
inPlaceEnvs = new Set;
|
|
381882
382537
|
constructor(serverRootPath) {
|
|
381883
382538
|
this.serverRootPath = serverRootPath;
|
|
382539
|
+
this.mcpIncludeHiddenFilesAndSources = isMcpIncludeHiddenFilesAndSources(serverRootPath);
|
|
381884
382540
|
this.gcsClient = new Storage2;
|
|
381885
382541
|
const watchEnvList = (process.env.PUBLISHER_WATCH || "").split(",").map((s2) => s2.trim()).filter((s2) => s2.length > 0);
|
|
381886
382542
|
for (const envName of watchEnvList)
|
|
@@ -382463,7 +383119,10 @@ class EnvironmentStore {
|
|
|
382463
383119
|
const environmentConfig = environmentManifest.environments.find((e2) => e2.name === environmentName);
|
|
382464
383120
|
const environmentPath = existingEnvironment?.metadata.location || environmentConfig?.packages[0]?.location;
|
|
382465
383121
|
if (!environmentPath) {
|
|
382466
|
-
throw new EnvironmentNotFoundError(`Environment "${environmentName}" could not be resolved to a path
|
|
383122
|
+
throw new EnvironmentNotFoundError(`Environment "${environmentName}" could not be resolved to a path.`, {
|
|
383123
|
+
environmentName,
|
|
383124
|
+
availableEnvironments: Array.from(this.environments.keys()).sort()
|
|
383125
|
+
});
|
|
382467
383126
|
}
|
|
382468
383127
|
return await this.addEnvironment({
|
|
382469
383128
|
name: environmentName,
|
|
@@ -387256,7 +387915,7 @@ var compileShape = {
|
|
|
387256
387915
|
packageName: exports_external.string().describe("Package containing the model, as list_packages returns it."),
|
|
387257
387916
|
modelPath: exports_external.string().describe("Path to the .malloy model the check targets: the namespace the source is appended to (scope append), or the file it replaces (scope file / package-with-source)."),
|
|
387258
387917
|
source: exports_external.string().optional().describe("The Malloy source to validate; never executed. Required at scope append/file; optional at scope package (a what-if replacement for modelPath)."),
|
|
387259
|
-
scope: exports_external.enum(["append", "file", "package"]).optional().describe(
|
|
387918
|
+
scope: exports_external.enum(["append", "file", "package"]).optional().describe('What source means. "append" (default): append to modelPath. "file": compile AS modelPath to validate an edit. "package": dry-run a reload over all .malloy/.malloynb files without serving the result, including its render-tag and dashboard checks; an optional source replaces modelPath.'),
|
|
387260
387919
|
includeSql: exports_external.boolean().optional().describe("When true and the source ends in a runnable query, also return the generated SQL for inspection. The query is still not run."),
|
|
387261
387920
|
givens: exports_external.record(exports_external.unknown()).optional().describe("Given values for the model's given: block. Also required to satisfy any authorize gate on the target model, whether or not includeSql is set.")
|
|
387262
387921
|
};
|
|
@@ -387265,7 +387924,7 @@ var COMPILE_DESCRIPTION = `Compile-check Malloy without running a query. Use thi
|
|
|
387265
387924
|
## Scopes (the scope parameter)
|
|
387266
387925
|
- "append" (default): append source to modelPath. Use for NEW definitions; existing definitions report "Cannot redefine". Positions refer to the concatenated file (a ## artifact document reads only your text, and no document is returned). Refused here, with a 400: import, connection.table(...), connection.sql(...), raw-SQL functions, given: declarations (reading the model's givens as $NAME is fine), ##! flags, and text that does not stand alone as top-level Malloy — a bare view:/dimension:/measure:, or anything parsing only as a continuation of the model's last statement. A ## artifact document also refuses # image, # link and @env.; plain text keeps them. Wrap a view body in a top-level query:, a field in a throwaway source: check is <source> extend { … }; use "file" or "package" for a model that declares data roots.
|
|
387267
387926
|
- "file": compile source AS modelPath. Use to validate an EDIT before saving; positions match the submitted file.
|
|
387268
|
-
- "package": run reload
|
|
387927
|
+
- "package": dry-run a reload of every .malloy/.malloynb file without serving the result, with its render-tag and dashboard checks (no position) but not its other warnings. A source replaces modelPath so importers see the edit. Diagnostics may name hidden files; no rows or SQL are returned, and authorize gates still apply to caller text. An unmatched path is warned and treated as new. Save, then reload_package to serve it.
|
|
387269
387928
|
|
|
387270
387929
|
## Parameters
|
|
387271
387930
|
- environmentName, packageName, modelPath: required. source: required at append/file, optional at package. Caller source may not declare an access-control gate outside prose — neither #(authorize) nor #(access_filter).
|
|
@@ -387432,6 +388091,22 @@ init_dashboard();
|
|
|
387432
388091
|
// src/mcp/query_envelope.ts
|
|
387433
388092
|
init_json_utils();
|
|
387434
388093
|
var MAX_RESULT_CHARS = 90000;
|
|
388094
|
+
function dedupeAnnotations(annotations) {
|
|
388095
|
+
let seen = new Set;
|
|
388096
|
+
const kept = [];
|
|
388097
|
+
for (let i2 = annotations.length - 1;i2 >= 0; i2--) {
|
|
388098
|
+
const annotation = annotations[i2];
|
|
388099
|
+
if (seen.has(annotation.value))
|
|
388100
|
+
continue;
|
|
388101
|
+
kept.push(annotation);
|
|
388102
|
+
if (annotation.value.includes(":=")) {
|
|
388103
|
+
seen = new Set;
|
|
388104
|
+
continue;
|
|
388105
|
+
}
|
|
388106
|
+
seen.add(annotation.value);
|
|
388107
|
+
}
|
|
388108
|
+
return kept.reverse();
|
|
388109
|
+
}
|
|
387435
388110
|
function serialize(envelope) {
|
|
387436
388111
|
return JSON.stringify(envelope, bigIntReplacer, 2);
|
|
387437
388112
|
}
|
|
@@ -387445,13 +388120,13 @@ function buildQueryEnvelope(rows, rowLimit, result, renderLogErrors = [], limit
|
|
|
387445
388120
|
annotations: result.annotations ?? [],
|
|
387446
388121
|
connection_name: result.connection_name,
|
|
387447
388122
|
...result.model_annotations !== undefined && {
|
|
387448
|
-
model_annotations: result.model_annotations
|
|
388123
|
+
model_annotations: dedupeAnnotations(result.model_annotations)
|
|
387449
388124
|
},
|
|
387450
388125
|
...result.query_timezone !== undefined && {
|
|
387451
388126
|
query_timezone: result.query_timezone
|
|
387452
388127
|
},
|
|
387453
388128
|
...result.source_annotations !== undefined && {
|
|
387454
|
-
source_annotations: result.source_annotations
|
|
388129
|
+
source_annotations: dedupeAnnotations(result.source_annotations)
|
|
387455
388130
|
}
|
|
387456
388131
|
},
|
|
387457
388132
|
_query_row_limit: rowLimit,
|
|
@@ -387523,19 +388198,22 @@ var executeQueryShape = {
|
|
|
387523
388198
|
environmentName: exports_external.string().describe("Environment name, as list_packages returns it."),
|
|
387524
388199
|
packageName: exports_external.string().describe("Package containing the model, as list_packages returns it."),
|
|
387525
388200
|
modelPath: exports_external.string().describe("Path to the .malloy model file"),
|
|
387526
|
-
query: exports_external.string().optional().describe(`Ad-hoc Malloy query code. Runs in restricted mode: it may not use ${RESTRICTED_CONSTRUCTS} — put those in a model file and reload instead.`),
|
|
388201
|
+
query: exports_external.string().optional().describe(`Ad-hoc Malloy query code with exactly one run: statement; source: and query: definitions may come before it. Text with more than one run: is refused, so send each as its own call. Runs in restricted mode: it may not use ${RESTRICTED_CONSTRUCTS} — put those in a model file and reload instead.`),
|
|
387527
388202
|
sourceName: exports_external.string().optional().describe("Source name for a view. A NAME, not Malloy code: one name exactly as get_context returned it, sent bare (the server quotes it, so a hyphen or a reserved word is fine — do not add backticks yourself). Anything richer, such as a parameterized source or an inline extension, goes in query."),
|
|
387528
388203
|
queryName: exports_external.string().optional().describe("Named query or view. A NAME, not Malloy code, on the same terms as sourceName: one view name as get_context returned it. A dotted path (carriers.by_name), a refinement (by_carrier + { limit: 10 }), or anything containing a newline goes in query instead."),
|
|
387529
388204
|
filterParams: exports_external.record(exports_external.union([exports_external.string(), exports_external.array(exports_external.string())])).optional().describe("Filter parameter values keyed by filter name. Used with sources that declare #(filter) annotations."),
|
|
387530
|
-
givens: exports_external.record(exports_external.unknown()).optional().describe("Per-query given values that override model defaults. Keys are given names declared in the model's given: block.")
|
|
388205
|
+
givens: exports_external.record(exports_external.unknown()).optional().describe("Per-query given values that override model defaults. Keys are given names declared in the model's given: block."),
|
|
388206
|
+
includeHiddenFilesAndSources: exports_external.boolean().optional().describe("Set true to also run the files and sources this package's index.malloy hides, so an author can test them without publishing them. It never bypasses #(authorize) or #(access_filter). Default false.")
|
|
387531
388207
|
};
|
|
388208
|
+
var { includeHiddenFilesAndSources: _authoringOnly, ...curatedShape } = executeQueryShape;
|
|
387532
388209
|
var EXECUTE_QUERY_DESCRIPTION = `Run a Malloy query against a model and return the rows. Takes either ad-hoc Malloy in query, or a named view/query via queryName (with sourceName for a view).
|
|
387533
388210
|
|
|
387534
388211
|
## Contract rules
|
|
387535
388212
|
- Check _limit_hit before reporting any total, count, or "top N". True means the server's default cap cut the result off and more rows exist, so what came back is a partial set, not the answer.
|
|
387536
388213
|
- Never sum or count the returned rows to state a total when _limit_hit or _rows_truncated is set. Aggregate in the query instead.
|
|
387537
388214
|
- _returned_rows: 0 with _rows_truncated set means one row was too large to send, NOT that nothing matched. Do not report it as an empty result.
|
|
387538
|
-
- Use source, view, and field names exactly as get_context returned them. sourceName/queryName take one NAME each, never Malloy code — they are quoted for you, so send even a hyphenated name bare, and put anything richer (a dotted path, a refinement, a
|
|
388215
|
+
- Use source, view, and field names exactly as get_context returned them. sourceName/queryName take one NAME each, never Malloy code — they are quoted for you, so send even a hyphenated name bare, and put anything richer (a dotted path, a refinement, a definition) in query.
|
|
388216
|
+
- One run: per call. query may define sources and queries first, but more than one run: statement is refused rather than run; send each question as its own call.
|
|
387539
388217
|
- query is RESTRICTED: no raw SQL/import/##! (see its param doc).
|
|
387540
388218
|
|
|
387541
388219
|
## Response
|
|
@@ -387551,7 +388229,8 @@ A JSON object, the same shape Credible's execute_query and an in-package data ap
|
|
|
387551
388229
|
|
|
387552
388230
|
Values above 2^53 are returned as JSON strings so their digits survive.`;
|
|
387553
388231
|
function registerExecuteQueryTool(mcpServer, environmentStore) {
|
|
387554
|
-
|
|
388232
|
+
const offerHidden = environmentStore.mcpIncludeHiddenFilesAndSources === true;
|
|
388233
|
+
mcpServer.tool("execute_query", EXECUTE_QUERY_DESCRIPTION, offerHidden ? executeQueryShape : curatedShape, async (params) => {
|
|
387555
388234
|
const {
|
|
387556
388235
|
environmentName,
|
|
387557
388236
|
packageName,
|
|
@@ -387562,6 +388241,7 @@ function registerExecuteQueryTool(mcpServer, environmentStore) {
|
|
|
387562
388241
|
filterParams,
|
|
387563
388242
|
givens
|
|
387564
388243
|
} = params;
|
|
388244
|
+
const includeHiddenFilesAndSources = offerHidden && params.includeHiddenFilesAndSources === true;
|
|
387565
388245
|
logger.info("[MCP Tool executeQuery] Received params:", { params });
|
|
387566
388246
|
const hasAdhocQuery = !!query;
|
|
387567
388247
|
const hasNamedQuery = !!queryName;
|
|
@@ -387604,7 +388284,7 @@ function registerExecuteQueryTool(mcpServer, environmentStore) {
|
|
|
387604
388284
|
rowLimit,
|
|
387605
388285
|
rowLimitSource,
|
|
387606
388286
|
queryCorrelationId
|
|
387607
|
-
} = await runWithQueryTimeout((abortSignal) => query ? model.getQueryResults(undefined, undefined, query, filterParams, undefined, givens, abortSignal, queryMetadataInput, "compact") : model.getQueryResults(sourceName, queryName, undefined, filterParams, undefined, givens, abortSignal, queryMetadataInput, "compact"), getQueryTimeoutMs());
|
|
388287
|
+
} = await runWithQueryTimeout((abortSignal) => query ? model.getQueryResults(undefined, undefined, query, filterParams, undefined, givens, abortSignal, queryMetadataInput, "compact", false, includeHiddenFilesAndSources) : model.getQueryResults(sourceName, queryName, undefined, filterParams, undefined, givens, abortSignal, queryMetadataInput, "compact", false, includeHiddenFilesAndSources), getQueryTimeoutMs());
|
|
387608
388288
|
const { validateRenderTags: validateRenderTags2 } = await Promise.resolve().then(() => __toESM(require_dist15(), 1));
|
|
387609
388289
|
const renderLogs = filterPublisherOwnedRenderLogs(validateRenderTags2(result), modelPath);
|
|
387610
388290
|
const resultUri = buildMalloyUri({
|
|
@@ -387651,7 +388331,7 @@ var reloadShape = {
|
|
|
387651
388331
|
var RELOAD_FAILURE_IS_SAFE = "A reload that fails to compile leaves your files on disk alone and keeps serving the previously compiled model, returning the compile errors.";
|
|
387652
388332
|
var RELOAD_DESCRIPTION = `Reload a package so edits to its model files on disk are picked up, making newly added or changed sources, views, and named queries resolvable by execute_query WITHOUT restarting the server. Publisher compiles each configured package at boot and serves that cached model, so a source or view you add afterwards is not queryable by name until the package is reloaded. Use this to close the edit -> run loop after saving a model change.
|
|
387653
388333
|
|
|
387654
|
-
${RELOAD_FAILURE_IS_SAFE} Running compile_model first is
|
|
388334
|
+
${RELOAD_FAILURE_IS_SAFE} Running compile_model at scope "package" first is the faster way to see diagnostics, and it keeps a broken model from ever reaching the reload. It reports the compile errors, render-tag findings and dashboard findings a reload would; every other package warning (storage, persist, materialization, explores, surface and discovery warnings, and deprecated-format warnings for .malloynb notebooks and #(filter) sources, among others) appears only here.
|
|
387655
388335
|
|
|
387656
388336
|
## Parameters
|
|
387657
388337
|
- environmentName, packageName (required): the package to recompile. Use the names get_context returns.
|
|
@@ -392559,6 +393239,7 @@ Find the right entities before writing any query.
|
|
|
392559
393239
|
|
|
392560
393240
|
- If you do not already know which package to work in, confirm the environment and package with the user before continuing.
|
|
392561
393241
|
- **Load \`skill:malloy-phrase-detection\`**, then call \`get_context\` with entity targets that describe the fields the question needs: a \`measure\` for the metric, a \`dimension\` for each breakdown or filter, and a \`view\` if the question sounds like a canned report. **Every target carries a \`search_text\`**: a target without one enumerates a capped catalog instead of searching, and the cap is blind to your question. The tool description covers what comes back and how to narrow or browse.
|
|
393242
|
+
- **A value the user typed (a brand, a city, a product name) is not yet a filter.** Resolve it to the string the data stores before you filter on it: a \`dimensional_value\` target in that \`get_context\` call, or, where value search isn't available, a query of the dimension's distinct values. "ray ban" may be stored as \`Ray-Ban\` and \`Ray-Ban Junior\`; a \`where:\` on the user's own spelling, or a regex guessed from it, can match nothing and still return a result.
|
|
392562
393243
|
- Read the documentation on each returned entity: on an entity it is \`description\`, on a source it is \`docs\`, often with a short \`one_line_summary\` and sometimes a longer \`summary\`. Which of these a server fills varies, so read the ones your response actually carries and do not go hunting for one that is absent. Authors write it as \`#(doc)\` in the model, but the response never uses that label, so do not go looking for that either. It is where grain, units, null handling, and any source-level filters are described. Confirm the exact field names against the results before using them.
|
|
392563
393244
|
- **Read the source's own documentation too, not just each field's.** The source-level \`docs\` often defines the grain, the universe of rows it represents, how joins behave, and source-level filters or assumptions that apply to every query rooted on it. Factor both the source and the field docstrings into how you build and later verify the query.
|
|
392564
393245
|
- **Do not rebuild a view that already exists from its description.** A description says what a view does, not how; rebuilding from prose loses what prose does not carry: a denominator, a \`partition_by\`, an exact filter. Set \`entity_name\` in the scope to get that entity on its own instead of a ranked sweep. That also returns its \`code\` (for a view, its definition as written): read it first, with the source's \`docs\` and the entity's \`description\`. Then RUN the view and adapt its output rather than writing a replacement; to change a band width or grain, change that one thing and keep the rest. Where a server returns no \`code\`, the docs plus the view's own output are what you have, and a calculation you cannot see is a reason to reuse the view rather than guess at it.
|
|
@@ -392727,8 +393408,12 @@ endpoint named beside it.
|
|
|
392727
393408
|
already-defined errors that reads as broken Malloy rather than a wrong scope. **Editing a shared
|
|
392728
393409
|
include wants \`"scope": "package"\`**, which recompiles every file as saved: \`file\` only checks the
|
|
392729
393410
|
one you are editing, so renaming a source in \`_shared.malloy\` passes it while breaking every
|
|
392730
|
-
dashboard that imports it.
|
|
392731
|
-
|
|
393411
|
+
dashboard that imports it. **\`"scope": "file"\` does not check tiles, drills or suggest queries.**
|
|
393412
|
+
To see those before you save, compile the same text at \`"scope": "package"\` with \`modelPath\` set
|
|
393413
|
+
to the dashboard's path: it runs the lint step 6 reads and returns its findings with code
|
|
393414
|
+
\`dashboard-lint\` (or \`render-tag\`), each at the severity a load gives it, so an error finding
|
|
393415
|
+
makes the compile an error too. Even that is not a working dashboard: some mistakes only show when
|
|
393416
|
+
you look at the page in step 7. **A not-yet-saved dashboard wants
|
|
392732
393417
|
\`"scope": "file"\` too**, not the default: a dashboard file opens with an \`import\`, and the
|
|
392733
393418
|
default \`append\` scope refuses one, so the new-file case fails on the import and again on the
|
|
392734
393419
|
path that does not exist yet. \`append\` is for a fragment checked against a model that is
|
|
@@ -392784,8 +393469,9 @@ nest's pipeline starts from its own query's source and there is no way to combin
|
|
|
392784
393469
|
|
|
392785
393470
|
Three things the form costs, so you are not surprised by them:
|
|
392786
393471
|
|
|
392787
|
-
- **A tile expression is a string in an annotation, so the compiler never checks it.** Rename
|
|
392788
|
-
and the file still compiles; the tile fails at package load.
|
|
393472
|
+
- **A tile expression is a string in an annotation, so the Malloy compiler never checks it.** Rename
|
|
393473
|
+
a view and the file still compiles; the tile fails at package load. The lint names it: at step 6,
|
|
393474
|
+
or before saving with \`compile_model\` at \`"scope": "package"\` (step 5).
|
|
392789
393475
|
- **No per-parent-row grouping.** There is no parent query to repeat a grid over.
|
|
392790
393476
|
- **Filtering lives on the tiles**, not on the page: each view's \`+ { where: ... }\` names the
|
|
392791
393477
|
controls it answers to. Below is why, and the one thing that does not work.
|
|
@@ -392972,6 +393658,10 @@ value, that the prefix "is not a well-formed route", because a route ends at the
|
|
|
392972
393658
|
complaint is a **compile** diagnostic on a compile that still succeeds, not a package warning, so
|
|
392973
393659
|
step 6 will not show it. Pick by which reader you care about.
|
|
392974
393660
|
|
|
393661
|
+
\`dimension=\` can name a field of a directly joined source (one join level). Quote it, because
|
|
393662
|
+
an unquoted dotted value does not parse as a tag:
|
|
393663
|
+
\`suggest { source=sales dimension="products.department" }\`.
|
|
393664
|
+
|
|
392975
393665
|
On the \`query=\` form of \`suggest\`, \`dimension\` names a column of that query's output, not a path into
|
|
392976
393666
|
the model, and only the last segment of it is read. Match it to the suggest query's \`group_by\`. When it
|
|
392977
393667
|
matches no column the picker falls back to the first column of the result: silently right for a
|
|
@@ -393815,7 +394505,7 @@ This is the modeling-time consequence of ignoring \`skill:malloy-define\`'s scop
|
|
|
393815
394505
|
|
|
393816
394506
|
## Thresholds Are Decisions, Not Syntax
|
|
393817
394507
|
|
|
393818
|
-
Before writing a \`pick\` expression or filtered measure with a numeric cutoff, see \`skill:malloy-model\` § Key Rules: every boundary must be user-supplied, distribution-derived (query the percentiles first), or explicitly flagged as an assumption in its \`#(doc)\`. Never invent one silently.
|
|
394508
|
+
Before writing a \`pick\` expression or filtered measure with a numeric cutoff, see \`skill:malloy-model\` § Key Rules: every boundary must be user-supplied, distribution-derived (query the percentiles first: Malloy has no percentile function, so use the **Tier boundaries** query in \`skill:malloy-discover\`), or explicitly flagged as an assumption in its \`#(doc)\`. Never invent one silently.
|
|
393819
394509
|
|
|
393820
394510
|
## \`except:\` Removes Fields From Namespace Entirely
|
|
393821
394511
|
|
|
@@ -395453,7 +396143,7 @@ dimension: is_complete is status = 'complete'
|
|
|
395453
396143
|
| \`type: min\` / \`max\` | \`min(field)\` / \`max(field)\` | Direct mapping |
|
|
395454
396144
|
| \`type: number\` | Derived measure expression | Usually a ratio; use \`nullif()\` for division |
|
|
395455
396145
|
| \`type: list\` | No direct equivalent | Flag for alternative approach |
|
|
395456
|
-
| \`type: percentile\` | No measure form: Malloy has no \`percentile\` function | Compute the value with the two-stage query in \`skill:malloy-discover\` § Example Queries, and tell the user it is a query result, not a reusable measure |
|
|
396146
|
+
| \`type: percentile\` / \`type: median\` | No measure form: Malloy has no \`percentile\` function | Compute the value with the two-stage query in \`skill:malloy-discover\` § Example Queries, and tell the user it is a query result, not a reusable measure |
|
|
395457
396147
|
|
|
395458
396148
|
**Filtered measures:** \`filters:\` → Malloy \`{ where: }\` syntax:
|
|
395459
396149
|
\`\`\`
|
|
@@ -395580,13 +396270,85 @@ Present coverage in this order:
|
|
|
395580
396270
|
},
|
|
395581
396271
|
{
|
|
395582
396272
|
name: "malloy-materialization",
|
|
395583
|
-
description: "Add and debug Malloy Persistence materializations in a package - persist an expensive source so queries read a pre-built table. Read this whenever the user wants to materialize a source, add a persist annotation, speed up a slow source, tune what to persist, or asks why a persist source isn't building.",
|
|
395584
|
-
body: "# Materialization (Malloy Persistence)\n\nMaterialize an expensive source once so queries read a **pre-built warehouse table** instead of recomputing it every time. You tag a source `#@ persist`, a materialization run builds it into a physical table, and queries against it are rewritten to read that table.\n\n> **The #1 gotcha, up front:** if a persist source isn't materializing, it is almost always one of two things - a `.malloy` file in the package missing the `##! experimental.persistence` flag (which aborts the *whole* package's build plan), or no build ever ran (a standalone Publisher does not build on publish - see **Building and refreshing**). Jump to **Debugging a no-op build**.\n\n**Deciding what to persist, what to stop persisting, and how to schedule it** (making a package cheaper or faster): read `reference/tuning.md`. It reads the materialization history with the `malloy-pub` CLI and proposes changes; it recommends and does not edit.\n\n## The recipe (get this right and it just works)\n\n1. **`##! experimental.persistence` on EVERY `.malloy` file in the package** - not only the file that declares the persist source. Either form enables it:\n - `##! experimental.persistence`, or\n - `##! experimental { access_modifiers, sql_functions, persistence }` (add `persistence` to the existing list).\n\n **Why every file:** the build plan is computed by asking *every* `.malloy` file in the package for its persist sources, and that call **throws on any file whose model lacks the flag** (`Model must have ##! experimental.persistence`). One unflagged helper or import file, even one with no persist source of its own, aborts the whole package's build plan, so *every* persist source in the package drops out. This is the most common cause of a no-op build.\n\n2. **`#@ persist name=\"...\"` on a query-based source, with the name quoted:**\n ```malloy\n #@ persist name=\"my_dataset.my_table\"\n source: my_rollup is some_source -> { group_by: ...; aggregate: ... }\n ```\n - **Only `query_source` and `sql_select` sources are persistable** - a source whose definition has a `-> { ... }` pipeline or a `conn.sql(\"...\")`. This **includes** one refined by a trailing `extend { ... }`. What is **not** persistable is a *plain* `extend` over a bare `conn.table(...)`; a `#@ persist` on such a source is **silently ignored** (its annotation is never read) - that one source just won't materialize, and the rest of the package still builds.\n - **Quote the name.** `name=\"my_table\"` (or a path `name=\"dataset.table\"` / `name=\"project.dataset.table\"`) is required. A **bare** `name=my_table` **always fails the build/publish** with `persist annotation name must be quoted` (a raw-source scan that hard-stops); it never silently no-ops.\n - `name=` is the target table name. In a standalone Publisher this **is** the physical table (rebuilt in place); a hosted (control-plane) deployment builds it under a content-addressed generation name. In both, the source's identity for reuse is a content address of its connection and canonical SQL (its `sourceEntityId`), so **republishing unchanged persist logic reuses the existing table** and changing the logic builds fresh.\n\n3. **Package persistence policy in `publisher.json`** (all optional):\n ```jsonc\n {\n \"name\": \"my-package\",\n \"materialization\": {\n \"scope\": \"package\", // default; \"version\" = each published version owns its own tables\n \"freshness\": { \"window\": \"24h\", \"fallback\": \"live\" },\n \"queryMetadata\": { \"team\": \"finance\" } // tags the build's backend statements\n }\n }\n ```\n Enforced at publish (strict), on edits (strict), at load (warn, still serves), and by the scheduler (an offending package is skipped):\n - **`scope`**: `package` (default; artifacts reused across published versions) or `version` (each artifact owned by one version). Package-level only; there is no per-source scope. A root-level `scope` is the deprecated home and still works, with a warning; declaring both homes with different values is rejected.\n - **`materialization.freshness`** (`window` + `fallback` of `live`/`stale_ok`/`fail`) is the objective a **hosted control plane** enforces by refreshing the table to meet it (`fallback: \"live\"` serves live compute while stale/absent). A **standalone** Publisher does **not** act on `freshness` for refresh - see **Building and refreshing**.\n - **`materialization.queryMetadata`** is a bag of string properties attached to every statement the build issues, for the backend's own cost attribution (Snowflake `QUERY_TAG`, BigQuery job labels, a leading SQL comment elsewhere). Overridable per source with `#@ persist queryMetadata.<name>=\"<value>\"`. Observability only: it never changes what gets built. See `docs/query-metadata.md`.\n - **`materialization.schedule`** is a 5-field UTC cron (`min hour dom mon dow`; `L`/`W`/`#`/`?` rejected). It **requires `scope: \"version\"`** and is **mutually exclusive with `freshness`**. This is how a standalone Publisher refreshes on a cadence.\n\n4. **Reads vs writes.** The persist source can *read* any dataset the connection can read; the persist *target* (`name=`'s dataset) must be a dataset the connection can **write** (typically a scratch dataset).\n\n## Building and refreshing (standalone vs. hosted)\n\nA `#@ persist` tag declares *what* to materialize; it does not by itself build anything.\n\n- **Standalone Publisher:** publishing or loading a package only computes its build plan - **no table is built until a materialization run executes.** Trigger one explicitly (`malloy-pub materialize --package <pkg> --wait`, or the materialization API), or turn on the opt-in local scheduler (off unless `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` is set) to fire the package's `schedule` cron. Refresh is a re-run or that cron; `freshness` is not a refresh trigger here, so a freshness-only standalone package builds once and is not auto-refreshed.\n- **Hosted (control-plane) deployment:** the build runs automatically on publish, best-effort - a build failure does **not** fail the publish (which is why a broken persist can look like a silent no-op), and the control plane drives refresh to meet the `freshness` objective.\n\nEither way, a successful publish alone does not prove a table exists - confirm the build separately.\n\n## Serve-time routing is `query_source`-only (today)\n\nBoth persistable types *build* a table, but only a **`query_source`** (a `-> { ... }` pipeline) is rewritten to *read* it at query time. A raw **`sql_select`** (`conn.sql(\"...\")`, including `conn.sql(\"...\") extend { ... }`) builds its table and then the query path re-inlines its SQL, so the table is built and never read, and queries are no faster. If you have raw SQL you want served from a table, wrap it in a thin `query_source` and persist that:\n\n```malloy\nsource: x_raw is my_conn.sql(\"select ...\")\n#@ persist name=\"scratch_dataset.x\"\nsource: x is x_raw -> { select: * }\n```\n\n## Confirming it worked\n\nAfter a build runs, re-run one of the source's queries - a persisted `query_source` should return quickly, reading the pre-built table instead of recomputing the upstream. Your host also reports each persisted source as **ready** with its physical table name (a materialization run detail, CLI listing, or materialization view, depending on the host); if nothing is listed, either no build ran (standalone) or the build plan was empty - see **Debugging a no-op build**.\n\n## Debugging a no-op build\n\nSymptom: no table was built and the source still recomputes on every query. Check, in order:\n\n0. **Did a build actually run?** On a standalone Publisher, publish/load does **not** build - run `malloy-pub materialize` (or enable the scheduler). \"Publishes fine, no table\" is the *expected* standalone state, not a model bug. On a hosted deployment the build is automatic but best-effort, so a failure is silent - look for a `FAILED` run.\n1. **A `.malloy` file missing the persistence flag** (the most common real bug). Every model file's `##!` line needs `persistence`, including pure helper/import files with no persist source - one unflagged file aborts the whole package's build plan.\n2. **An unquoted persist name** - a bare `name=foo` **always** hard-stops the build/publish with `persist annotation name must be quoted`; use `name=\"foo\"`. (If you got *no* error at all, it isn't this.)\n3. **A `#@ persist` on a non-persistable source** - a bare `extend` over `conn.table(...)` is silently ignored, so *that* source won't materialize (the rest of the package is unaffected). Tag a `query_source` / `sql_select` instead.\n4. **A persisted raw `sql_select` that builds but is never read** - if the table exists yet queries are no faster, it's the serve-routing gap above; wrap the `sql_select` in a `query_source`.\n\n**Isolation test** - add a trivial, self-contained persist source in its own file and rebuild:\n```malloy\n##! experimental.persistence\nsource: smoke_raw is my_conn.table('some_dataset.some_table')\n#@ persist name=\"scratch_dataset.persist_smoke_test\"\nsource: persist_smoke is smoke_raw -> { aggregate: n is count() }\n```\n- If **even this** doesn't build (after a real materialization run), the whole package's plan is aborting - a sibling `.malloy` file is missing the flag. Fix rule 1 across the package.\n- If the smoke source **does** build but your real one doesn't, your real source is the problem - a non-persistable type (a bare `extend`), or its own file's flag.\n\nDelete the smoke file and drop its table afterward.\n\n## Persisting a `#(access_filter)`-gated source\n\nA gated source **can** be persisted, but only on one tier and only in one shape, and the thing to be\ncareful about is not refused by anything - you have to decide it.\n\n- **`storage=` and `#@ preaggregate` always refuse a gated source**, naming it. The build skips a refused\n source, records it on the run (`metadata.refusedSources`), and builds the rest of the package. A run fails\n on a refusal only when every authored source it targeted was refused, or when `sourceNames` named this one;\n a refused rollup never fails it. (`#@ persist storage=<name>` is the tier that materializes into a separate\n registered storage destination and serves from there, rather than building in the source's own connection;\n `#@ preaggregate` stores a rollup Publisher derives from a measure you annotated with a grain, rather than a\n source you wrote.) A rollup also groups *across* the gated column, so it could not be row-filtered afterwards\n even in principle.\n- **A colocated `#@ persist` (no `storage=`) is admitted** when the gate is provably the entry point's\n **own row filter**. It is refused when the gate is reached only through a join, inherited from a base\n the compiler cannot attribute cleanly, or does not classify as a row filter at all. The gate is found\n through the import -> rename -> `query_source` chain, so a gate the persisted source did not declare\n itself still counts.\n\n**What to be wary of.** Persisting does not weaken the gate: it changes only where rows are read FROM, and\nthe gate still runs live on every query as that query's own `WHERE`, so filtered rows come back filtered.\nWhat freezes is the **column the gate filters on**. A row whose access decision changes - it changes\nowner, say - keeps being served under its OLD decision until the next rebuild. That is a stale *access\ndecision*, not merely stale data, and nothing raises an error.\n\n**None of this is needed for the gate to work.** It is enforced live on every query either way; what\nneeds a bound is how long a *stale* decision can survive. Of the three controls that look like that\nbound, only the first is:\n\n- **`materialization.freshness` `{ \"window\": \"24h\", \"fallback\": \"live\" }` is the bound.** The serve path\n re-checks freshness per query, so once the artifact ages past the window it drops out of the serving set\n and the query recomputes live, correctly filtered - whether or not a rebuild ever lands. Three details\n decide whether you actually get that. **`fallback` must be `live`**: under `stale_ok` a stale artifact\n keeps being served, which voids the bound, and window and fallback resolve *independently* per layer,\n so a package-level `stale_ok` silently defeats a window you set on the source. That is a statement about\n **layers**, which do not combine - not about siblings, below. Prefer the\n **per-source** spelling `#@ persist name=\"...\" freshness.window=\"24h\" freshness.fallback=\"live\"` over\n the package-wide `materialization.freshness` key: the gated source is what needs the bound, and setting\n it package-wide forces every other persisted source to recompute once stale too. And **a\n content-identical sibling shares the artifact, so it shares the window**: reuse is keyed on the\n content-addressed `sourceEntityId`, which folds the connection and the SQL but *not* the source name, so\n two persist sources whose bodies compute the same SQL resolve to one table carrying one freshness\n policy. The tightest window any of them declares governs all of them - a sibling declaring nothing\n cannot loosen yours, and yours pulls that sibling's reads off the table once it lapses. A sibling's\n `stale_ok` cannot void your bound either: the fold keeps whichever fallback bounds staleness, so the\n layer rule above does not carry over here. If two sources need genuinely different windows, give them\n genuinely different SQL.\n\n Both of those are properties of the **host** that assembles the manifest, not of the annotation. Where\n the host does not fold, which sibling's policy reaches the wire is unspecified; and a host that folds at\n manifest-assembly time typically applies it when a version's manifest is next published rather than\n retroactively to manifests already distributed - so you can declare the window correctly and not have it\n in force yet.\n- **A cron alone is not a bound.** A failed build or a stopped scheduler leaves the source serving its old\n decisions indefinitely. `freshness` and `schedule` are mutually exclusive; for a gated source, take the\n window.\n- **`refresh=\"incremental\"` does not bound revocation.** The delta only re-reads rows in\n `[covered_through, frontier)`, so a row that changes owner *without its watermark advancing* is never\n re-read - while the entry still reports an advancing `coveredThrough` and reads as healthy. Only a full\n rebuild recomputes the gating column.\n\n**And the window only binds where the serving manifest carries it.** Freshness is enforced from fields a\ncontrol plane stamps onto the manifest it distributes; a Publisher that serves what it just built binds the\ntable with no `dataAsOf` and no window, and an entry carrying no window never ages out. So on a standalone\ndeployment the declared window is inert and the artifact serves until the next full rebuild - which leaves a\nrebuild cadence you actually verify as the only bound, and makes leaving a revocation-sensitive source\nunpersisted the safer call.\n\nWhen recommending `#@ persist` on a gated source, pair it with a freshness window and say out loud what\nstaleness the author is accepting. A gated source with neither a window nor a full-rebuild cadence has no\nbound on how long a revoked row keeps being served.\n\n## Gotchas\n\n- **Every `.malloy` file needs the persistence flag** - one unflagged file aborts the whole package's build plan. (A `#@ persist` on a *non*-persistable source, by contrast, is silently ignored and does not affect other sources.)\n- **A tag doesn't build** - a standalone Publisher materializes only on an explicit run or its scheduler; only a hosted control plane builds on publish.\n- **Serve-time routing is `query_source`-only** - a raw `sql_select` builds a table the query path doesn't read; wrap it in a `query_source`.\n- **Quote the name** - a bare `name=` always hard-stops the build.\n- **Republishing unchanged persist logic reuses the table** - reuse is keyed on the content-addressed `sourceEntityId`, not the `name=`.\n- **Removing a persist source (or a smoke test) does not drop its table** - physical-table cleanup is the caller's responsibility; drop it yourself.\n- **A `#(access_filter)`-gated source freezes its gating column when persisted** - the gate still runs live, but a revoked row keeps being served under its old access decision until the next rebuild. `storage=` and `#@ preaggregate` refuse a gated source outright. See **Persisting a `#(access_filter)`-gated source**.\n\n## Reference files over MCP\n\nThis skill's `reference/` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read `reference/<name>.md`, get the prompt named `malloy-materialization/<name>` instead.\n\nAvailable: tuning."
|
|
396273
|
+
description: "Add, confirm and debug Malloy Persistence materializations in a package - persist an expensive source so queries read a pre-built table, in the source's own warehouse or in a storage destination. Read this whenever the user wants to materialize a source, add a persist annotation, speed up a slow source, build one stored rollup over another, tune what to persist, or asks why a persist source isn't building or isn't being read.",
|
|
396274
|
+
body: "# Materialization (Malloy Persistence)\n\nMaterialize an expensive source once so queries read a **pre-built table** instead of recomputing it every time. You tag a source `#@ persist`, a materialization run builds it into a physical table, and queries against it are rewritten to read that table.\n\n> **The #1 gotcha, up front:** if a persist source isn't materializing, it is almost always one of four things - no build ever ran (a standalone Publisher does not build on publish - see **Building and refreshing**), a build ran and failed or lost that one source (a run can finish ready with `failures`; read them), the model declaring the source has no `##! experimental.persistence` flag and nothing flagged imports it (it is skipped without a warning), or the package was reloaded since the build (a reload drops the bindings until the next run). Jump to **Debugging a no-op build**.\n\nThis file is the core. The details live beside it, one topic per file, and each is a prompt of its own:\n\n- `reference/annotation.md` - the `#@ persist` tag: what can carry it, the name rules, identity and reuse, readers of a persisted source, and every option with where it is explained.\n- `reference/policy.md` - the `publisher.json` block: `scope`, `queryMetadata`, and where the rules are enforced.\n- `reference/storage.md` - building into a **storage destination** (`storage=`), `partition=`, where a given may sit, which sources that tier refuses, and how a stored table is served.\n- `reference/chaining.md` - one stored source built **over another stored source**: what makes it reuse the parent's table or recompute from the warehouse, and how to read which happened.\n- `reference/refresh.md` - keeping a table current: `freshness`, `schedule`, and `refresh=\"incremental\"`.\n- `reference/access-filter.md` - persisting a `#(access_filter)`-gated source, and the bound on a stale access decision.\n- `reference/tuning.md` - reading the materialization history to decide what to persist, stop persisting, or reschedule (recommendations only).\n\n## The recipe (get this right and it just works)\n\n1. **`##! experimental.persistence` on every `.malloy` file that declares a persist source.** Either form enables it:\n - `##! experimental.persistence`, or\n - `##! experimental { access_modifiers, sql_functions, persistence }` (add `persistence` to the existing list).\n\n **Why it matters:** the build plan walks only the models that carry the flag, and skips every other file **without a warning**. The flag is a property of the model being *walked*, not of the file that declares the annotation: a flagged model builds every persist source its walk reaches, including ones declared in a flagless file it imports, and a flagless file's persist sources build only when some flagged model imports it. So a `#@ persist` in a flagless file that nothing flagged imports is silently never built, while adding a flagged entry model that imports it starts building tables that were inert - neither state warns. Put the flag in the declaring file itself: that is the one placement whose effect does not depend on what else imports it. A file with no persist source of its own does not need the flag; adding it anyway is harmless. (`#@ preaggregate` rollups are the one exception: they build from an unflagged file, because they live in a model Publisher synthesizes.)\n\n2. **`#@ persist name=\"...\"` on a query-based source, with the name quoted:**\n ```malloy\n #@ persist name=\"my_dataset.my_table\"\n source: my_rollup is some_source -> { group_by: ...; aggregate: ... }\n ```\n - **Only `query_source` and `sql_select` sources are persistable** - a `-> { ... }` pipeline or a `conn.sql(\"...\")`, including one refined by a trailing `extend { ... }`. A `#@ persist` on a *plain* `extend` over a bare `conn.table(...)` is **refused, not ignored**: the package warns, and every run that covers it fails naming the source, so a whole-package run builds nothing until you fix it. Persist a query source instead (`source: x is raw -> { where: ...; select: * }`).\n - **Quote the name, once.** A bare `name=my_table` fails the package load with `persist annotation name must be quoted`; a name quoted inside the quotes silently serves live. The target's container must already exist and be writable by the connection.\n - **The name is not the identity.** A persist source is identified by a content address of its connection, SQL and partition layout, so a rebuild with unchanged logic reuses the table and two sources with the same body share one. An `extend` of a persisted source reads the same table and is not a second build target.\n\n The name rules, bound arguments, `#@ -persist`, and every option (`storage=`, `partition=`, `refresh=`, `freshness.*`, `queryMetadata.*`) are in `reference/annotation.md`.\n\n3. **Package persistence policy in `publisher.json`** (all optional):\n ```jsonc\n {\n \"name\": \"my-package\",\n \"queryMetadata\": { \"team\": \"finance\" }, // root level: tags every statement a build OR a query issues\n \"materialization\": {\n \"scope\": \"package\", // default; \"version\" = each published version owns its own tables\n \"freshness\": { \"window\": \"24h\", \"fallback\": \"live\" }\n }\n }\n ```\n - **`scope`** is a package-level contract about sharing tables across versions; on a standalone server it changes nothing except gating `schedule`. Its deprecated root-level home still works, but the two homes disagreeing **fails the load**.\n - **`freshness`** is the objective a **hosted control plane** enforces by refreshing the table; a **standalone** Publisher does not act on it at all. `fail` behaves like `live` today; only `stale_ok` keeps serving a stale table.\n - **`queryMetadata`** tags every build statement and every query for the backend's cost attribution. Root level; the home inside `materialization` is deprecated. Observability only.\n - **`schedule`** is a 5-field UTC cron that **requires `scope: \"version\"`** and **excludes `freshness`**; it fires only on a standalone server whose scheduler is enabled.\n\n The coherence rules are rejected at publish and at a policy edit, warned at load, and skipped by the scheduler. `reference/policy.md` has `scope` and `queryMetadata` in full; `reference/refresh.md` has `freshness` and `schedule`.\n\n## Building and refreshing (standalone vs. hosted)\n\nA `#@ persist` tag declares *what* to materialize; it does not by itself build anything.\n\n- **Standalone Publisher:** publishing or loading a package only computes its build plan - **no table is built until a materialization run executes.** Trigger one explicitly (`malloy-pub materialize --environment <env> --package <pkg> --wait`, or the materialization API), or turn on the opt-in local scheduler (off unless `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` is set) to fire the package's `schedule` cron. Refresh is a re-run or that cron; `freshness` is not a refresh trigger here, so a freshness-only standalone package builds once and is not auto-refreshed. Two flags to know: `--force-refresh` builds every source even when its content address is unchanged (which is what every scheduler fire does), and `--reseed` is the only thing that rebuilds an incremental source from scratch. `--wait` gives up after **120 s by default** (`--timeout <s>`) and exits non-zero while the build keeps running; `malloy-pub get materialization <id>` then shows where it got to.\n- **Hosted (control-plane) deployment:** the build runs automatically on publish, best-effort - a build failure does **not** fail the publish (which is why a broken persist can look like a silent no-op), and the control plane drives refresh to meet the `freshness` objective.\n\nTwo mechanics either way. **One build at a time per package**: a second run requested while one is active is rejected with a 409 conflict rather than queued, and the scheduler skips a fire while a run is active. **A build can be partial**: `sourceNames` on the request builds only the sources it names and leaves the others exactly as they were (live if never built), which is how to add one source to a package without rebuilding the rest.\n\nEither way, a successful publish alone does not prove a table exists - confirm the build separately.\n\n## Confirming it worked\n\nA green run is not proof, and neither is a fast query (on small data a live recompute is fast too). Check what the server says it serves:\n\n1. **The run lists your source, and did not lose it.** A run's detail (`malloy-pub get materialization <id> --environment <env> --package <pkg>`) carries `manifest.entries`, a map keyed by `sourceEntityId` whose values name each built or reused source (`sourceName`) and its physical table. A run can finish `MANIFEST_FILE_READY` having built nothing (every source skipped for a missing flag, or carried forward unchanged) **or having lost a source**: a source that failed is absent from `entries` and present in `manifest.failures` with its error, and a source the eligibility gate refused is in `metadata.refusedSources`, while the run still reads ready. `metadata.sourcesBuilt` / `sourcesReused` / `sourcesFailed` / `sourcesRefused` are the counts. For a source built over another stored source, the entry's `upstreamReuse` says whether it read the parent's table (`reused`) or recomputed it (`recomputed`, with `upstreamRecomputeReason`) - see `reference/chaining.md`.\n2. **The package is bound to those tables.** On a standalone Publisher the package's details report `manifestBindingStatus` and `manifestEntryCount`. The count covers tables built in the source's own warehouse only: it reads `bound` with a nonzero count when such a table is bound and `unbound` otherwise. A `storage=` table never counts there - it appears under the package's `storageServeBindings`, so a package whose only persist sources are `storage=` reads `unbound` while serving from the store. `live_fallback` is a hosted-only status: a configured `manifestLocation` could not be fetched or bound, and the previously bound manifest (if any) stays in force.\n3. **The query names the table.** A query's full result (the REST query response without `compactJson`) carries the SQL it ran. Served from the table, its `FROM` names the physical table; computed live, it carries the source's own SQL instead. This is the per-query proof.\n4. **A reload unbinds them.** Reloading the package - including the automatic reload when a watched file is saved - drops the binding, and queries compute live until the next materialization run, which reuses unchanged tables and binds them again. (A server *restart* re-establishes the bindings from its store; a reload does not.) After editing a model, run a build before you trust a timing.\n\nA query response's `servedFrom` field does not answer this question for a table built in the source's own connection: it reports only the `storage=` tier's outcome (`storage` when that tier answered, `live_fallback` when it degraded to a live recompute) and is empty otherwise, served from a colocated table or not. Do not read `live_fallback` here as a signal about a colocated table.\n\n## Debugging a no-op build\n\nSymptom: no table was built and the source still recomputes on every query. Check, in order:\n\n0. **Did a build actually run?** On a standalone Publisher, publish/load does **not** build - run `malloy-pub materialize` (or enable the scheduler). \"Publishes fine, no table\" is the *expected* standalone state, not a model bug. On a hosted deployment the build is automatic but best-effort, so a failure is silent - look for a `FAILED` run, and for a ready run with `failures`.\n1. **Did the run fail, or lose this source?** A `FAILED` run's `error` names what stopped it. A ready run's `manifest.failures` names each source that did not build, and `metadata.refusedSources` each one the gate refused, with the reason to act on. The package's details flag some of these before any run: an unbuildable persist source appears in its warnings, and its `buildPlan` lists the sources a run will build and the ones it will refuse. A `#@ persist` on a non-persistable source fails every run that covers it, naming the source.\n2. **The declaring model is missing the persistence flag** and nothing flagged imports it: skipped without any warning, the run succeeds, the source is simply absent from what was built. Add the flag to the declaring file.\n3. **An unquoted persist name** - a bare `name=foo` **always** stops the load with `persist annotation name must be quoted`. (If you got *no* error at all, it isn't this.)\n4. **The package was reloaded** after the build (a saved file under watch mode counts). The tables exist and the run reads ready; the binding is gone. Run a build. And if a watched edit failed to compile, the server keeps serving the **old** model and its binding, and records the error under `staleCompileErrors` on `/status` - so \"still bound\" after an edit can mean the edit never took.\n5. **A `storage=` source serves live while the store is switched off.** With `PERSIST_STORAGE_MODE=off` a `storage=` source is skipped by the build and served live, with no error; it is never built into the warehouse instead. See `reference/storage.md`.\n\n**Isolation test** - add a trivial, self-contained persist source in its own file and rebuild:\n```malloy\n##! experimental.persistence\nsource: smoke_raw is my_conn.table('some_dataset.some_table')\n#@ persist name=\"scratch_dataset.persist_smoke_test\"\nsource: persist_smoke is smoke_raw -> { aggregate: n is count() }\n```\nIf **even this** doesn't build after a real run, the problem is package-wide: read the run's error (a non-persistable source anywhere fails the whole run and names it), then check that the connection can write the target dataset. If the smoke source builds and your real one doesn't, your real source is the problem: its file's flag, or its entry in `failures` / `refusedSources`. Delete the smoke file and drop its table afterward (`malloy-pub delete materialization <id> --drop-tables`).\n\n## Gotchas\n\n- **Flag every file that declares a persist source** - a flagless model nothing flagged imports is skipped without a warning.\n- **A `#@ persist` on a plain `extend` over `conn.table(...)` fails the run** - every run that covers it, naming the source.\n- **A tag doesn't build** - only an explicit run, the standalone scheduler, or a hosted control plane does.\n- **Quote the name, once** - bare stops the load; double-quoted silently serves live.\n- **A ready run can still have lost a source** - read `manifest.failures` and `metadata.refusedSources`.\n- **A reload unbinds; a restart rebinds** - run a build before trusting a timing.\n- **Reuse is by content address, not by name** - `--force-refresh` and every scheduler fire defeat it.\n- **Removing a persist source does not drop its table** - `delete materialization --drop-tables` drops what no other ready run names, and serving reverts to live in place.\n- **A given inside the persisted query is refused; in the source's own `extend` block it is applied per caller** - `reference/storage.md`.\n- **A join to a non-persisted source, or a nested column, serves live for the queries that touch it** - the source's own fields still serve from the table.\n- **A `#(access_filter)`-gated source freezes its gating column when persisted** - `storage=` and `#@ preaggregate` refuse it outright; `reference/access-filter.md`.\n\n## Reference files over MCP\n\nThis skill's `reference/` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read `reference/<name>.md`, get the prompt named `malloy-materialization/<name>` instead.\n\nAvailable: access-filter, annotation, chaining, policy, refresh, storage, tuning."
|
|
396275
|
+
},
|
|
396276
|
+
{
|
|
396277
|
+
name: "malloy-materialization/access-filter",
|
|
396278
|
+
description: "Persisting a `#(access_filter)`-gated source. Reference detail for the malloy-materialization skill.",
|
|
396279
|
+
body: "# Persisting a `#(access_filter)`-gated source\n\nA gated source **can** be persisted, but only on one tier and only in one shape, and the thing to be careful about is not refused by anything - you have to decide it.\n\n- **`storage=` and `#@ preaggregate` always refuse a gated source**, naming it. The build skips a refused source, records it on the run (`metadata.refusedSources`), and builds the rest of the package. A run fails on a refusal only when every authored source it targeted was refused, or when `sourceNames` named this one; a refused rollup never fails it. (`#@ persist storage=<name>` is the tier that materializes into a separate registered storage destination and serves from there, rather than building in the source's own connection; `#@ preaggregate` stores a rollup Publisher derives from a measure you annotated with a grain, rather than a source you wrote.) A rollup also groups *across* the gated column, so it could not be row-filtered afterwards even in principle.\n- **A colocated `#@ persist` (no `storage=`) is admitted** when the gate is provably the entry point's **own row filter**. The gate is found through the import -> rename -> `query_source` -> `extend` chain, so a gate the persisted source did not declare itself still counts, and an inherited gate is admitted like a declared one. It is refused in exactly two cases: the gate is reached only through a join, or it does not classify as a row filter at all.\n\n**What to be wary of.** Persisting does not weaken the gate: it changes only where rows are read FROM, and the gate still runs live on every query as that query's own `WHERE`, so filtered rows come back filtered. What freezes is the **column the gate filters on**. A row whose access decision changes - it changes owner, say - keeps being served under its OLD decision until the next rebuild. That is a stale *access decision*, not merely stale data, and nothing raises an error.\n\n**None of this is needed for the gate to work.** It is enforced live on every query either way; what needs a bound is how long a *stale* decision can survive. Of the three controls that look like that bound, only the first is:\n\n- **`freshness.window=\"24h\"` with `freshness.fallback=\"live\"` is the bound** (`reference/refresh.md`). The serve path re-checks freshness per query, so once the artifact ages past the window it drops out of the serving set and the query recomputes live, correctly filtered - whether or not a rebuild ever lands. Three details decide whether you actually get that. **`fallback` must not be `stale_ok`**: under `stale_ok` a stale artifact keeps being served, which voids the bound (`live` and `fail` both bound it today). Window and fallback resolve *independently, per field*, across the source, model-file and package layers, so a package-level `stale_ok` silently defeats a window you set on the source. Prefer the **per-source** spelling `#@ persist name=\"...\" freshness.window=\"24h\" freshness.fallback=\"live\"` over the package-wide `materialization.freshness` key: the gated source is what needs the bound, and setting it package-wide forces every other persisted source to recompute once stale too. And **a content-identical sibling shares the artifact, so it shares the window**: reuse is keyed on the content-addressed `sourceEntityId`, which folds the connection and the SQL but *not* the source name, so two persist sources whose bodies compute the same SQL resolve to one table carrying one freshness policy. A host that folds the siblings' policies keeps the tightest window any of them declares - a sibling declaring nothing cannot loosen yours, and yours pulls that sibling's reads off the table once it lapses - and keeps the bounding fallback, so a sibling's `stale_ok` cannot void your bound either. If two sources need genuinely different windows, give them genuinely different SQL.\n\n Both of those are properties of the **host** that assembles the manifest, not of the annotation: the publisher reports freshness per source and does no folding itself. Where the host does not fold, which sibling's policy reaches the wire is unspecified; a host that folds does so when it next publishes a version's manifest, not retroactively to manifests already distributed - so you can declare the window correctly and not have it in force yet.\n- **A cron alone is not a bound.** A failed build or a stopped scheduler leaves the source serving its old decisions indefinitely. `freshness` and `schedule` are mutually exclusive; for a gated source, take the window.\n- **`refresh=\"incremental\"` does not bound revocation.** The delta only re-reads rows in `[covered_through, frontier)`, so a row that changes owner *without its watermark advancing* is never re-read - while the entry still reports an advancing `coveredThrough` and reads as healthy. Only a full rebuild recomputes the gating column.\n\n**And the window only binds where the serving manifest carries it.** Freshness is enforced from fields a control plane stamps onto the manifest it distributes; a Publisher that serves what it just built binds the table with no `dataAsOf` and no window, and an entry carrying no window never ages out. So on a standalone deployment the declared window is inert unless something binds the manifest through the API with those fields - which leaves a rebuild cadence you actually verify as the only bound, and makes leaving a revocation-sensitive source unpersisted the safer call.\n\nWhen recommending `#@ persist` on a gated source, pair it with a freshness window and say out loud what staleness the author is accepting. A gated source with neither a window nor a full-rebuild cadence has no bound on how long a revoked row keeps being served."
|
|
396280
|
+
},
|
|
396281
|
+
{
|
|
396282
|
+
name: "malloy-materialization/annotation",
|
|
396283
|
+
description: "The `#@ persist` annotation. Reference detail for the malloy-materialization skill.",
|
|
396284
|
+
body: '# The `#@ persist` annotation\n\n```malloy\n#@ persist name="my_dataset.my_table"\nsource: my_rollup is some_source -> { group_by: ...; aggregate: ... }\n```\n\n## What can carry it\n\n**Only `query_source` and `sql_select` sources are persistable** - a source whose definition has a `-> { ... }` pipeline or a `conn.sql("...")`, including one refined by a trailing `extend { ... }`. What is **not** persistable is a *plain* `extend` over a bare `conn.table(...)` (a filtered pass-through). A `#@ persist` on such a source is **refused, not ignored**: the package carries a warning naming the source, and any materialization run that covers it fails with `annotated \'#@ persist\' but were not recognized as a materializable source`, so a whole-package run builds nothing until you fix it. Persist a query source instead (`source: x is raw -> { where: ...; select: * }`), or remove the annotation.\n\n**A parameterized source must have its argument bound.** `#@ persist` on `high_value(min_amount is 500) -> { ... }` builds one fixed relation; on the free template `high_value -> { ... }` it is refused (`free_parameter`), since there is no single relation to store. A `given` the persisted *query* reads is refused the same way (`given_in_persisted_query`); a given in the source\'s own `extend` block is applied per caller at read. `reference/storage.md` says where a given may sit.\n\n## The name\n\n- **Quote it, once.** `name="my_table"`, or a path `name="dataset.table"` / `name="project.dataset.table"`. A **bare** `name=my_table` fails the package **load** - so every publish and every restart - with `persist annotation name must be quoted`; it never silently no-ops.\n- **Plain identifier segments only**: dot-separated segments of letters, digits, underscores and hyphens. A quoted name holding a space, quote, semicolon or backtick is rejected with its own message, because the name is inlined into DDL.\n- **Never quote inside the quotes.** `name="\\"Quoted Tbl\\""` reaches the build with literal quote characters: the table is created with them in its name, the serve side looks for the unquoted one, and the source silently serves live after a wasted build.\n- **One name per table.** Two distinct persist sources that resolve to the same `name=` in the same destination clobber each other (the second build overwrites the first, and dropping one drops the other\'s). Publisher warns on the package at load and, where `PERSIST_COLLISION_ENFORCE` is set, rejects the publish. A shared *body* is fine (it shares one table by content address); a shared name over different bodies is not.\n- **The target\'s container must exist.** `name="analytics.daily"` puts the table in `analytics`, which the connection must be able to write and which Publisher does not create. The source can *read* anything the connection can read.\n- **The name is not the identity.** In a standalone Publisher `name=` is the physical table, rebuilt in place; a hosted deployment may assign the physical name itself. A `storage=` destination takes a single undotted name, since placement there is the destination\'s.\n\n## Identity and reuse\n\nA persist source\'s identity is its **`sourceEntityId`**: a content address of its connection, its canonical SQL and its `partition=` layout. Not the name, not the file, not the package version. So:\n\n- **A rebuild with unchanged persist logic reuses the existing table** (skip-if-unchanged); changing the SQL or the partition layout builds fresh under a new address. `--force-refresh` and every scheduler fire defeat the skip; an incremental source is exempt from it and advances by its boundary instead (`reference/refresh.md`).\n- **Two sources whose bodies compile to the same SQL share one table**, however they are named, and share one freshness policy and one incremental boundary with it.\n- **A republish by itself builds nothing on a standalone server**; reuse is a property of the next *run*.\n\n## Readers of a persisted source\n\n- **An `extend` of a persisted source reads the stored table** and is not a build target of its own: it inherits the parent\'s tag and the same address, so the two names are one table. To make a distinct non-persisted intermediate, pipe first: `base -> { select: * } extend { ... }`.\n- **A query over a persisted source** (`orders is _orders_fact -> { select: * }`, the private-fact / public-wrapper idiom) reads the table too, with the fact\'s `extend`-block filters applied per caller, and is not a build target.\n- **`#@ -persist`** on an extension opts it out: it recomputes from raw on every query and never reads the table. Reach for it when a reader must not see stale rows, and remember it forgoes exactly the work persistence was there to save.\n- Which queries over a reader route to the table is decided per query - a join to a non-persisted source or a nested column serves live for the queries that touch it (`reference/storage.md`).\n\n## Every option\n\n| On the tag | What it does | Where it is explained |\n|---|---|---|\n| `name="..."` | the target table (required) | above |\n| `storage=<destination>` | build into a storage destination and serve from it | `reference/storage.md` |\n| `partition="a,b"` | lay the stored table out by those columns (`storage=` only) | `reference/storage.md` |\n| `refresh="incremental" watermark="col" [merge_key="a,b"]` | refresh by bounded delta instead of a full rebuild | `reference/refresh.md` |\n| `freshness.window="24h" freshness.fallback="live"` | per-source freshness objective (hosted deployments) | `reference/refresh.md` |\n| `#@ queryMetadata.<key>="v"` (sibling line) | per-source cost-attribution properties | `reference/policy.md` |\n| `#@ -persist` (on a reader) | opt a reader out of the table | above |\n| `#@ preaggregate grain="..."` (on a measure) | store a derived rollup | `reference/storage.md` |\n\nAn unrecognized key on the tag is a warning on the package. The retired per-source `sharing=` and `schedule=` are rejected at publish: scope and schedule are declared once for the package (`reference/policy.md`).'
|
|
396285
|
+
},
|
|
396286
|
+
{
|
|
396287
|
+
name: "malloy-materialization/chaining",
|
|
396288
|
+
description: "Chaining: one stored source built over another. Reference detail for the malloy-materialization skill.",
|
|
396289
|
+
body: `# Chaining: one stored source built over another
|
|
396290
|
+
|
|
396291
|
+
A \`storage=\` source may read another \`storage=\` source. When both land in the same destination, the downstream is built by **reading the upstream's stored table** in the destination's own engine - the upstream is never re-scanned from the warehouse - and the chain is consistent by construction: the child is a pure function of the parent's stored rows. Chaining is what turns a package of independent rebuilds into one that touches the warehouse only for its roots.
|
|
396292
|
+
|
|
396293
|
+
\`\`\`malloy
|
|
396294
|
+
#@ persist name="daily_orders" storage=lake
|
|
396295
|
+
source: daily_orders is orders -> { group_by: order_date; aggregate: total_amount is amount.sum() }
|
|
396296
|
+
|
|
396297
|
+
#@ persist name="monthly_orders" storage=lake
|
|
396298
|
+
source: monthly_orders is daily_orders -> { group_by: order_month is order_date.month; aggregate: monthly_total is total_amount.sum() }
|
|
396299
|
+
\`\`\`
|
|
396300
|
+
|
|
396301
|
+
The downstream need not name the upstream directly. Non-persisted sources between them - a \`select: *\` wrapper, an \`extend\` that adds a dimension, a named source over an inline \`(parent extend { ... }) -> { ... }\`, a query over the upstream, a parent reached only through an \`import\` - are carried into the build in dependency order, under the \`##!\` flags of the files that declare them, and declaring the author model's \`given:\`s, so the downstream still reads the upstream's table through them.
|
|
396302
|
+
|
|
396303
|
+
## The four rules that decide reuse
|
|
396304
|
+
|
|
396305
|
+
1. **Declare the stored roots before the sources that read them.** Malloy resolves names in file order. A rollup rewired to read a stored root that is declared further down fails to compile with \`Reference to undefined object\`, and so does everything after it. When you chain an existing package onto its roots, move the roots up.
|
|
396306
|
+
2. **An extension of a stored source is the same table, not a new one.** \`rollup_base is daily_orders extend { ... }\` inherits the parent's persist tag and the same content address, so the publisher treats the two names as one build target. To make a distinct non-persisted intermediate, pipe through a query first: \`rollup_base is daily_orders -> { select: * } extend { ... }\`. (A host that checks persist names for collisions can refuse the bare \`extend\` outright, since two names now point at one table.)
|
|
396307
|
+
3. **Every join on the path must land on a stored table.** An intermediate that joins a source nothing materializes - a \`join_cross: sp is species_live\` where only \`species_base\` is stored - reaches the warehouse, and the whole downstream is recomputed from the warehouse with the upstream inlined. Point the join at the stored sibling instead.
|
|
396308
|
+
4. **A chained build runs in the destination's engine, under its memory bound.** The rows are read from the lake and rolled up in DuckDB with the build session's \`memory_limit\`. A grouped rollup fits; a self-join or a wide hash aggregate over a large fact may not, and fails with DuckDB's \`Out of Memory Error\` on every attempt. Such a source belongs in the warehouse: keep its input unpersisted so it is built there and only its result is copied in. Placement is a cost decision the build does not make for you.
|
|
396309
|
+
|
|
396310
|
+
## What the build does when it cannot reuse
|
|
396311
|
+
|
|
396312
|
+
If the downstream cannot be built over the stored tables - it reaches the warehouse through an intermediate (rule 3), or reads a field on the parent that is not a stored column - the publisher **recomputes the upstream from the warehouse**, inlining it, and the build succeeds. Under \`strictUpstreams\` (what an orchestrating host sets on every build) this is the **one** recompute strict permits, because no build over the stored tables exists for that shape. Strict still refuses what it exists to refuse: a persisted upstream the build neither materialized nor was handed by reference, one whose table lives in a *different* destination, and a source over stored upstreams that the build could not carry (a refinement the ladder below drops). A refusal names the upstream and where it lives.
|
|
396313
|
+
|
|
396314
|
+
A chained build re-declares on each parent's binding what the parent's \`extend\` block adds - its \`where:\`, dimensions, measures, joins and views - and thins them when they do not compile in the destination, in tiers: a view first, then a join, then a dimension or measure that reads a join the destination cannot bind. **The \`where:\` is never thinned**: a stored source declared \`extend { where: total > 100 }\` holds every row and is filtered when read, so a child built over it without that filter would sum the unfiltered rows.
|
|
396315
|
+
|
|
396316
|
+
## Reading which happened
|
|
396317
|
+
|
|
396318
|
+
Every manifest entry for a source that reads a persisted upstream carries:
|
|
396319
|
+
|
|
396320
|
+
- \`upstreamReuse: "reused"\` - every persisted upstream was read from its table.
|
|
396321
|
+
- \`upstreamReuse: "recomputed"\` - at least one was recomputed from its definition; \`upstreamRecomputeReason\` names which and why (for example, which intermediate reached the warehouse through which join).
|
|
396322
|
+
|
|
396323
|
+
It is reported on both tiers, since a table built in the source's own warehouse also inlines a \`storage=\` upstream it cannot read there. The field is not a location and never names a table. A recomputed source's rows reflect the warehouse at *that build's* time rather than the upstream's snapshot, so the two can disagree when their refresh cadences differ - this field is how to tell.
|
|
396324
|
+
|
|
396325
|
+
The \`publisher_storage_chained_build_total{outcome}\` counter reports the path each chained build took: \`parent_reuse\`, \`inline_fallback\` (non-strict recompute), \`strict_shape_fallback\` (the strict-permitted recompute), \`strict_refused\`, \`infra_failure\` (the destination was unreachable or the build died in it - kept distinct so a store outage or an out-of-memory is not read as an un-carriable shape).
|
|
396326
|
+
|
|
396327
|
+
## Three limits to know
|
|
396328
|
+
|
|
396329
|
+
- A refresh of a chained \`refresh="incremental"\` source always rebuilds in full (\`chained_storage\` reason): its parent's delta can restate rows below the child's own frontier where no delta of the child's would revisit them.
|
|
396330
|
+
- A \`#@ persist\` without \`storage=\` (built in the source's warehouse) never substitutes a \`storage=\` upstream's table, because the warehouse cannot see the lake; it inlines the upstream and reports \`recomputed\`.
|
|
396331
|
+
- A chained build carries the intermediates' *text*, written for the source warehouse, into the destination's dialect. A construct the destination lacks fails the carry, and under strict that is a refusal rather than a recompute.`
|
|
396332
|
+
},
|
|
396333
|
+
{
|
|
396334
|
+
name: "malloy-materialization/policy",
|
|
396335
|
+
description: "The package policy: `scope`, `queryMetadata`, and where the rules are enforced. Reference detail for the malloy-materialization skill.",
|
|
396336
|
+
body: '# The package policy: `scope`, `queryMetadata`, and where the rules are enforced\n\n```jsonc\n{\n "name": "my-package",\n "queryMetadata": { "team": "finance" }, // root level\n "materialization": {\n "scope": "package", // default; or "version"\n "freshness": { "window": "24h", "fallback": "live" },\n "schedule": "0 6 * * *" // never together with freshness\n }\n}\n```\n\n`freshness` and `schedule` are explained in `reference/refresh.md`. This file covers the rest of the block and the gate that checks it.\n\n## `scope`\n\n`package` (default) or `version`. It is a package-level contract about whether a persisted table may be shared across published versions: under `package` an unchanged source\'s table is reused by every version that computes the same SQL; under `version` each published version owns its own tables. There is no per-source scope and no per-source schedule; both are declared once for the package.\n\nTwo things to know about it on a standalone server:\n\n- **It changes nothing there except gating `schedule`.** A standalone auto-run keys tables by content address regardless of scope; true per-version tables are produced when a hosted control plane assigns versioned build targets. Standalone is the place to exercise the *policy* rules, not per-version fan-out.\n- **A root-level `scope` is the deprecated home** and still works, with a warning. Whenever the server rewrites the manifest (any package PATCH, including a description-only one) it writes **both** homes with the same value, so the root key reappears after an API edit. **The two homes holding different values is not a warning: the package fails to load**, disappears from the server, and says so only in `/status` `loadErrors`. Editing by hand, change `materialization.scope` and delete the root copy; never edit the root one.\n\n## `queryMetadata`\n\nA bag of up to 20 string properties attached to **every statement a build issues and every query against the source**, for the backend\'s own cost attribution: Snowflake `QUERY_TAG`, BigQuery job labels, a leading SQL comment elsewhere. Publisher adds its own context on top (`class=materialize` with the package, source, trigger and run id on a build; `class=interactive` on a served query), and drops a model-side declaration that reuses one of those server-owned names.\n\n- **Declare it at the root of `publisher.json`.** The older home inside `materialization` still works but emits a deprecation warning on every load and publish.\n- **Three layers, most specific wins:** a sibling line on the source (`#@ queryMetadata.team="risk"` above the `source:`), a model-file line (`## queryMetadata.team="risk"`), then the package block.\n- **Observability only.** It never changes what gets built, is not part of the content address, and problems with it (a bad key, an over-long value, the deprecated home) are warnings, never rejections.\n\nSee `docs/query-metadata.md` for the per-backend rendering.\n\n## Where the rules are enforced\n\nThe scope / schedule / freshness coherence rules - a single package-level scope, `schedule` requires `scope: "version"`, `schedule` and `freshness` (including a per-source `freshness.*`) are mutually exclusive, a valid 5-field UTC cron - are checked in four places:\n\n| Where | Outcome |\n|---|---|\n| publish | rejected (400) |\n| a package edit that touches the policy | rejected (400); an edit that does not touch it is not re-checked |\n| package load | warned; the package still serves |\n| the scheduler | an offending package is skipped |\n\nOnly those coherence rules are gates. `queryMetadata` findings, the deprecated homes, and an unrecognized `#@ persist` key are advisory. An invalid `refresh="incremental"` declaration is the other way round: it **fails the load** outright (`reference/refresh.md`). The `malloy-pub schedule` commands share the publish gate, so a rejection from them means the change was unsafe - and `schedule set` replaces the whole `materialization` block, dropping a freshness policy it finds there.'
|
|
396337
|
+
},
|
|
396338
|
+
{
|
|
396339
|
+
name: "malloy-materialization/refresh",
|
|
396340
|
+
description: "Keeping a table current: freshness, schedule, incremental refresh. Reference detail for the malloy-materialization skill.",
|
|
396341
|
+
body: '# Keeping a table current: freshness, schedule, incremental refresh\n\nA persisted table is a snapshot. Three declarations govern when it is rebuilt and what a reader gets in the meantime; which of them does anything depends on who drives the builds.\n\n## `freshness`: the objective tier\n\n```malloy\n#@ persist name="daily_orders" freshness.window="24h" freshness.fallback="live"\n```\n\nor package-wide in `publisher.json` under `materialization.freshness`, or per model file with `## materialization.freshness.window="24h"`. The three layers resolve **per field** - source, then model file, then package - so a source that declares only `window` takes its `fallback` from the package. That is how a package-level `stale_ok` silently defeats a window set on one source.\n\n- `window` is how old a table may be before it is stale. `fallback` says what a query over a stale table does: `live` and `fail` both skip the table and compute live (`fail` is reserved to error later; today it behaves as `live`); `stale_ok` keeps serving the stale table.\n- A **hosted control plane** enforces the objective: it refreshes the table to stay within the window and stamps each manifest entry with `dataAsOf` and the window, and the serve path re-checks freshness **per query**, so a table that ages past its window drops out of the serving set whether or not a rebuild ever lands.\n- A **standalone** Publisher does not act on it: it binds what it just built with no `dataAsOf` and no window, and an entry carrying no window never ages out. The declaration is inert there unless something binds the manifest through the API with those fields set. For a standalone server, the only refresh is a run or the scheduler below.\n- Two sources whose bodies compile to the same SQL share one table, and a host that folds their policies keeps the tightest window and the bounding fallback for both. If two sources need different windows, give them different SQL.\n\n## `schedule`: the power tier\n\n```jsonc\n{ "name": "orders", "materialization": { "scope": "version", "schedule": "0 6 * * *" } }\n```\n\n- A 5-field UTC cron; `L`, `W`, `#`, `?` are rejected at publish. It **requires `scope: "version"`** and is **mutually exclusive with `freshness`** - including a per-source `freshness.*`.\n- It fires only on a standalone Publisher whose scheduler is enabled with `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER`; without that, a schedule is inert. The scheduler sweeps loaded packages, skips one whose policy is invalid or that a control plane manages (`manifestLocation` set), fires at most a bounded number per tick, fires once to catch up after downtime, and skips a fire while a run is already active.\n- **Every scheduled fire is a forced run** (`forceRefresh: true`): every source is rebuilt whether or not its content address changed. A scheduled run therefore never reports reused sources, and a cron that is too frequent shows as build time, not as reuse.\n- `malloy-pub schedule set "<cron>"` writes the schedule and sets `scope: version` in one PATCH; it **replaces the whole `materialization` block**, dropping an existing `freshness` policy (it says so). `schedule clear` removes the cron and leaves `scope: version` in place; moving back to `package` scope is an edit to `publisher.json`.\n\n## `refresh="incremental"`: bounded deltas\n\n```malloy\n#@ persist name="daily_orders" refresh="incremental" watermark="order_date"\nsource: daily_orders is orders -> { group_by: order_date; aggregate: revenue is amount.sum() }\n```\n\n- `watermark=` names one of the source\'s own output columns - real, orderable, non-aggregate. Publisher records how far the table is materialized (`covered_through`) and each refresh recomputes only `[covered_through, frontier)`, half-open, so the frontier value itself is left for the next run. A `date`/`timestamp` watermark takes the run\'s start time as the frontier; a numeric or string one reads `max(watermark)` from the source. Two refreshes within the same day of a date-watermarked source produce an empty range and skip - correct, and it looks like nothing happened.\n- Add `merge_key="col,..."` when a row can be **restated** with a new watermark value; the delta is then applied as a `MERGE` on that identity instead of a delete-and-reinsert of the range. On a caller-scoped source (a given or gate in the `extend` block) the table holds every caller\'s rows, so the match also carries the scope columns - declare the key as you would for an unscoped source. A scope term that reaches through a join is refused (`merge_key_scope_unresolved`) rather than scoped partially.\n- **Coverage belongs to the table.** Moving a source between tiers (adding or removing `storage=`), renaming its physical table, or changing its SQL re-seeds it in full; the boundary is measured against the table it was recorded on.\n- **An invalid declaration fails the package load**, not just the publish: `watermark=` without `refresh="incremental"`, `merge_key=` without a watermark, a watermark that names no materialized column or an aggregate, a `calculate:` field, or an unsupported dialect. Postgres, BigQuery and Snowflake sources only (the dialect that has to express the range); a DuckDB source has to say `refresh="full"`.\n- **`forceRefresh` never re-seeds**; it only defeats skip-if-unchanged, and an incremental source is exempt from that anyway. Ask for a full rebuild with `reseed` (`malloy-pub materialize --reseed`, or per source on the build instruction). Keeping them apart is what lets a schedule drive deltas at all, since every scheduled fire is forced.\n- Each manifest entry reports what the run **did**: `refresh: delta | full | none`, with `ledger.coveredThrough` as the boundary now in force. Every fallback - no recorded boundary, a boundary measured on a different table or watermark, drifted columns, `MERGE` on a Postgres older than 15 - rebuilds in full and succeeds, so a source quietly rebuilding on every run looks exactly like one advancing unless you read `refresh`. The reason codes (`forced`, `no_boundary`, `lineage_changed`, `table_renamed`, `merge_unsupported`, `table_unreadable`, `table_emptied`, `shape_mismatch`, `ledger_unreadable`, `chained_storage`, `frontier_unreadable`, `not_advanced`) say why.\n- Two sources that compile to identical SQL share one table **and one boundary**; if their incremental declarations differ, neither ever advances. Publisher warns and names both.\n- `storage=` is supported: the warehouse computes the bounded range and the DML lands in the destination. A **chained** stored source always rebuilds in full (`chained_storage`): see `reference/chaining.md`.\n- The boundary lives in the publisher\'s own per-process store. A host that spreads a package\'s refreshes over several workers holds the ledger itself: it stores each entry\'s `ledger` object and sends them back as `buildInstructions.ledger` on the next run. Standalone, the store is the publisher\'s, always.\n\n## Which refresh a gated source needs\n\nA `#(access_filter)`-gated source that is persisted freezes the column its gate filters on, so its "refresh" is also the bound on how long a revoked row keeps being served. Only a freshness window with `fallback: live` on a hosted deployment bounds that; a cron alone does not, and an incremental delta never re-reads a row whose watermark did not move. See `reference/access-filter.md`.'
|
|
396342
|
+
},
|
|
396343
|
+
{
|
|
396344
|
+
name: "malloy-materialization/storage",
|
|
396345
|
+
description: "The storage tier: `storage=`, `partition=`, and how a stored table is served. Reference detail for the malloy-materialization skill.",
|
|
396346
|
+
body: '# The storage tier: `storage=`, `partition=`, and how a stored table is served\n\nBy default a persist source is built **in its own connection** (colocated): the warehouse runs the query and the table lands beside the data. `#@ persist name="..." storage=<destination>` builds it into a **storage destination** the environment declares instead - today a DuckLake catalog over local or object storage - and serves it from there, so a query served from the table never runs in the warehouse at all.\n\n```malloy\n#@ persist name="daily_orders" storage=lake\nsource: daily_orders is orders -> { group_by: order_date; aggregate: total is amount.sum() }\n```\n\n- `storage=` names a **storage destination**, never a connection. Destinations are declared on the environment (`storageDestinations: [{ "name": "lake", "type": "ducklake", "ducklakeConnection": { "catalog": {...}, "storage": {...} } }]`, by config or by environment PATCH); a build naming one the environment does not hold fails the run with a `DestinationNotFoundError`. Reads of the environment report a destination\'s name and type only, never its config.\n- The build is a native **query passthrough**: the source warehouse (Postgres, BigQuery or Snowflake) computes the query and only the result crosses into the store, through DuckDB. A source on a DuckDB connection is refused for `storage=` (there is nothing to push down). A `#(access_filter)`-gated source is always refused on this tier (`reference/access-filter.md`).\n- The tier is gated by `PERSIST_STORAGE_MODE` (`off` / `write-only` / `on`). Under `off` a `storage=` source is **skipped by the build and served live, with no error** - it is never built colocated instead. Under `write-only` it builds but is still served live, and the package warns. If a `storage=` source "did nothing", check the mode before the model.\n- A package may name several destinations; each source routes to its own.\n- `partition=` requires `storage=` (refused as `partition_without_storage` otherwise) and every named column must be one of the source\'s public output columns.\n\n## Where a given may sit\n\nA persisted table is built once, with no caller present, so a `given` has to be somewhere the build never reads it.\n\n- **Inside the persisted query - refused.** `scoped_rollup is orders -> { where: region = $REGION; group_by: ... }` has only the declaration default available at build time, so the table would hold one caller\'s slice and nothing at read would re-apply the filter. The build refuses it (`given_in_persisted_query`). The same applies when the given is *inherited*: a query over a source whose `extend` block carries the term reads that filter into its SQL, and is refused just the same - what decides is whether the **build** would substitute a value, not which line the author typed it on.\n- **In the persist source\'s own `extend` block - honoured per caller.** `#@ persist ... storage=lake` on `fact is raw -> { select: * } extend { where: org_id = $ORG_ID }` strips the term from the build, stores every caller\'s rows in one table, and re-applies the term per caller at read. One artifact serves every tenant, and a term a caller writes on an extension of it still scopes what is read. Partition on the scoped column (`partition="org_id"`) so each caller\'s reads touch one partition.\n\nA `#(access_filter)` gate is the same shape with a different source of truth; see `reference/access-filter.md`.\n\n## `partition=`: laying the table out\n\n`#@ persist ... storage=lake partition="org_id"` writes the stored table as one file set per distinct value, so an equality term on that column (`=`, `in`) reads only the files it names; `partition="org_id,day"` nests them in the order given. It is a **layout and carries no isolation**: every filter is re-applied at read whether or not its column is partitioned, so a partition list that omits the column a query filters by costs a full scan, never a wrong answer.\n\nUse it for the column the reads filter by. A rollup carrying every key at a fine grain (every species, every tenant, every product) that each query filters to one key is read **whole** per query without a partition on that key - that is what makes a pre-aggregated table answer in seconds instead of milliseconds. Partition on the filter key and read cost tracks one partition while build cost tracks the whole table. Nothing checks the column\'s cardinality for you: a partition per value across very many values is the many-small-files case, and that judgement is yours.\n\n`partition=` is part of the source\'s content address, so changing it rebuilds the table. A chained downstream of a partitioned parent reads the parent\'s table like any other.\n\n## Shaping a rollup for its reads\n\nA stored rollup pays at build time so that reads are cheap; the grain and the inputs decide whether they are.\n\n- **Grain to the reads, not to the data.** Group by exactly the columns the queries filter or group by. Every extra dimension multiplies the row count, and a rollup that approaches the fact table\'s size saves nothing.\n- **Bake the filters into the query.** A `where: is_valid = 1` inside the `-> { ... }` shrinks the table; the same clause in a trailing `extend { ... }` is re-applied at read and the table holds every row.\n- **Give a ratio both halves.** A percentage whose numerator is a rollup and whose denominator is a count over the fact table still scans the fact per query. Persist the denominator at its own grain too.\n- **List the columns a pass-through keeps.** `select: *` on a stored copy of a wide fact carries columns no reader uses; name the ones the downstream rollups read.\n\n## How a stored table is served\n\nA query against a package with bound `storage=` tables is compiled over a transient model in which each stored source is rebound to its table - a *virtual source* - under the `##!` flags of the files that declared the originals, with their `extend`-block refinements re-declared on top. A public wrapper over a stored source (`orders is _orders_fact -> { select: * }`, with or without `include { public: ... }`) is carried onto that shape verbatim and served from the table. A wrapper whose query or joins reach a source that is not materialized there - a warehouse table, or a stored source whose table is stale past its freshness window - is served **live** instead.\n\nRouting is decided per query, not per source, so one source can serve some queries from its table and others live:\n\n- **A join to a non-persisted source** serves live for the queries that traverse it; queries on the fact\'s own fields still route to the table. A join between two persisted sources routes both legs.\n- **A nested column** (`nest:`, a repeated record) is stored, but the serve shape carries it as opaque JSON, so every query that traverses it pays the live cost while the scalar columns serve from storage. The source looks fully materialized and is, for its flat fields.\n- **A refinement the shape cannot reproduce is thinned** (a view, then a join, then a dimension or measure reading a join); a **filter is never thinned** - a source whose filter the shape cannot reproduce loses the tier itself, and only itself.\n- A **stale** table (past its freshness window, with `fallback` `live` or `fail`) leaves the serving set, and a run-time store failure under `fallback: live` degrades that query to live.\n\nWhat the server reports:\n\n- `QueryResult.servedFrom`: `storage` when the tier answered, `live_fallback` when the tier was bound but a run-time store failure degraded the query to a live recompute, and empty for everything else (a colocated hit, no binding, or an ineligible query - three different things).\n- The package\'s `storageServeBindings` lists the stored tables the package is currently bound to, with each entry\'s origin (`persist` or `preaggregate`). `manifestEntryCount` does not count them.\n- `publisher_storage_serve_routing_total{outcome=storage|live_fallback|runtime_live_fallback|blocked_by_row_level_gate, origin}` counts routed queries; `publisher_storage_serve_shape_tier_drop_total{tier}` counts refinements the serve shape had to thin.\n- A query\'s full result carries the SQL it ran; served from the store, its `FROM` names the destination and the physical table.\n\n## Pre-aggregation uses the same tier\n\n`#@ preaggregate grain="category, order_day"` on a **measure** stores a rollup Publisher derives at that grain and routes to it when a query groups by a subset of the grain; queries do not change. It builds through the same plan, manifest and scheduler as `#@ persist`, needs no `##!` flag, inherits the base source\'s freshness, and refuses a gated source. A rollup on a measure the published surface hides is not built; the package loads and warns. See `docs/preaggregation.md` for grain choice.'
|
|
395585
396347
|
},
|
|
395586
396348
|
{
|
|
395587
396349
|
name: "malloy-materialization/tuning",
|
|
395588
396350
|
description: "Tuning materializations for cost and performance. Reference detail for the malloy-materialization skill.",
|
|
395589
|
-
body: "# Tuning materializations for cost and performance\n\nThis skill turns the signals the open-source Publisher already records (the materialization history, per-run timings, and which sources were built vs reused) into concrete, **recommendations-only** advice: which sources to persist, which to stop persisting, and how to schedule and scope them. It is the local counterpart to the platform's usage-driven optimization: the Publisher has the raw signals, and you read them with the `malloy-pub` CLI.\n\n> **Recommendations only. Never change a model, schedule, or scope without the user's explicit go-ahead.** Present the findings and the proposed edits, then apply them only when asked. Persisting the wrong source wastes storage and rebuild time; unpersisting a hot one makes queries slow. Let the user decide.\n\nAssumes the `malloy-pub` CLI is on PATH and points at the server (`--url` or `MALLOY_PUBLISHER_URL`, default `http://localhost:4000`). Substitute the real environment and package for `<env>` / `<pkg>`.\n\n## Step 1: Take inventory\n\nEstablish what the package persists today and how it is governed.\n\n- **Persist sources:** the sources annotated `#@ persist name=\"…\"` in the package's `.malloy` files. Read the models (or `get_context` the package) to list them.\n- **Schedule + scope:**\n\n ```bash\n malloy-pub schedule view --environment <env> --package <pkg>\n ```\n\n This prints the cron (or `none`, meaning publish / on-demand only), the persist **scope** (`package` = artifacts reused across versions; `version` = per published version), and whether a freshness policy is set. A control-plane-managed package (manifestLocation set) is refreshed by the control plane, not the standalone scheduler, so leave its cadence alone.\n\n## Step 2: Read the materialization history\n\nThe history is where cost lives. Each run records its trigger, timing, and how many sources were built vs reused.\n\n- **For one package** (newest first). A materialization is a run of one package's persist sources, so this is the listing; to review a whole environment, read each package in turn (`malloy-pub list package --environment <env>` names them):\n\n ```bash\n malloy-pub list materialization --environment <env> --package <pkg>\n ```\n\n Columns: ID, Status, **Trigger** (`SCHEDULER` vs `ON_DEMAND`), Started, Completed, Error.\n\n- **A single run's detail** (the cost signals):\n\n ```bash\n malloy-pub get materialization <id> --environment <env> --package <pkg>\n ```\n\n In the JSON, read:\n - `metadata.durationMs`: how long the build took.\n - `metadata.sourcesBuilt` vs `metadata.sourcesReused`: how much work each run actually did. A run that is nearly all _reused_ is cheap; one that is nearly all _built_ every time is where cost accumulates.\n - `metadata.sourcesRefused` and `metadata.refusedSources`: sources the eligibility gate skipped. Each one serves live on every query, so a refused source the user expected to be persisted is a finding in its own right; its entry's `message` says what to change.\n - `metadata.trigger`: `SCHEDULER` (a cron fired it) or `ON_DEMAND`.\n - `manifest.entries[*]`: the persisted sources: `sourceName`, `physicalTableName`, `realization`.\n\nLook across several runs, not one: the pattern over the recent history (how often it rebuilds, how much it reuses, how long it takes) is the signal.\n\n## Step 3: Analyze and recommend\n\nWeigh rebuild cost against query benefit. Common findings:\n\n- **Persist candidate:** an expensive, frequently-queried source that is _not_ persisted (recomputed on every query). Recommend adding `#@ persist name=\"…\"`. Strongest when the source is a heavy aggregate/join reused by many queries and its inputs change slowly. If the source carries a `#(access_filter)` gate, check which case it is first. A gate reached only through a `join_*`, or inherited from a base it cannot be attributed to, means the colocated persist is **refused** - the build skips it and it serves live - so do not recommend persisting it at all. Where the gate is provably the source's own row filter the persist is admitted, and then only recommend it alongside a freshness window (`freshness.fallback=\"live\"`): the gating column freezes at build time, so without one a revoked row can be served under its old access decision indefinitely (see `SKILL.md` in this skill, which also covers where that window does and does not bind).\n- **Removal candidate:** a persisted source that is cheap to compute, rarely queried, or rebuilt far more often than it is read. Recommend dropping the `#@ persist` annotation (and its table): the storage + rebuild cost is not buying anything.\n- **Cadence mismatch:** a `SCHEDULER` cadence out of step with how fast the data changes or how long a build takes. If most scheduled runs are all-reused (nothing changed), the cron is too frequent, so loosen it. If queries routinely read stale data, tighten it. If the build's `durationMs` approaches the interval, the cadence is too aggressive.\n- **Scope mismatch:** `scope: version` re-materializes per published version (right when versions must be isolated, e.g. a schedule); `scope: package` reuses one lineage across versions (cheaper when versions can share). A schedule _requires_ `version`. If a package carries a schedule it does not need, clearing the schedule frees it to use the cheaper package scope.\n\nFrame each recommendation with the evidence from Step 2 (the run IDs, timings, built/reused counts) so the user can judge it.\n\n## Step 4: Apply (only once approved)\n\n- **Add a persist source:** add the `#@ persist` annotation in the `.malloy` file (use the modeling workflow to validate and reload), then rebuild so it is materialized:\n\n ```bash\n malloy-pub materialize --environment <env> --package <pkg> --wait\n ```\n\n- **Remove a persist source:** delete the `#@ persist` annotation, then drop the old run's tables **before** rebuilding what remains:\n\n ```bash\n malloy-pub delete materialization <id> --environment <env> --package <pkg> --drop-tables\n malloy-pub materialize --environment <env> --package <pkg> --wait\n ```\n\n > `--drop-tables` drops **every** physical table in that run's manifest, not just the removed source's: auto-run assigns stable table names and carries unchanged sources forward, so an old run's manifest names tables a newer manifest still serves. Do the drop **first, then rebuild**: `materialize --wait` re-creates every source that is still persisted, ending in the desired state. Dropping a run whose tables the current serving manifest depends on, without an immediate rebuild, breaks queries (`Table ... does not exist`).\n\n- **Change the schedule / cadence:**\n\n ```bash\n malloy-pub schedule set \"0 6 * * *\" --environment <env> --package <pkg> # 5-field UTC cron\n malloy-pub schedule clear --environment <env> --package <pkg>\n ```\n\n `set` also sets `scope: version` (a schedule requires it); the server rejects an invalid cron or an illegal scope/freshness combination, so a rejection means the change was unsafe.\n\n- **Change the scope:** `scope` is declared in the package's `publisher.json` under `materialization` (`\"scope\": \"package\"` or `\"version\"`; the root-level form is deprecated but still read). Edit it there, then reload/republish the package. Remember a schedule pins scope to `version`, so clear the schedule first if moving to `package`.\n\nAfter applying, re-run Step 2 on the next few builds to confirm the change did what you predicted (more reuse, shorter builds, or a table that is actually read).\n\n## What this skill does not do\n\n- It does not decide for the user or apply changes silently; every edit needs an explicit go-ahead.\n- It cannot see per-query read counts (the open-source Publisher records build history, not query-level table usage), so \"rarely queried\" is a judgment from the model and the user's knowledge, not a measured metric. Say so when it matters."
|
|
396351
|
+
body: "# Tuning materializations for cost and performance\n\nThis skill turns the signals the open-source Publisher already records (the materialization history, per-run and per-source timings, which sources were built vs reused, and for the storage tier how queries were routed) into concrete, **recommendations-only** advice: which sources to persist, which to stop persisting, which to chain or partition, and how to schedule them. The Publisher has the raw signals, and you read them with the `malloy-pub` CLI.\n\n> **Recommendations only. Never change a model, schedule, or scope without the user's explicit go-ahead.** Present the findings and the proposed edits, then apply them only when asked. Persisting the wrong source wastes storage and rebuild time; unpersisting a hot one makes queries slow. Let the user decide.\n\nAssumes the `malloy-pub` CLI is on PATH and points at the server (`--url` or `MALLOY_PUBLISHER_URL`). Substitute the real environment and package for `<env>` / `<pkg>`.\n\n## Step 1: Take inventory\n\nEstablish what the package persists today and how it is governed.\n\n- **Persist sources:** the sources annotated `#@ persist name=\"...\"` in the package's `.malloy` files, with their tier (`storage=` or not), `partition=`, and `refresh=`. Read the models (or `get_context` the package) to list them, and read the package's `buildPlan` for what a run will build and what it will refuse.\n- **Schedule + scope:**\n\n ```bash\n malloy-pub schedule view --environment <env> --package <pkg>\n ```\n\n This prints the cron (or `none`, meaning on-demand only), the persist **scope** (`package` = artifacts reused across versions; `version` = per published version), and whether a freshness policy is set. It also says when a control plane manages the package (`manifestLocation` set): leave that package's cadence alone, the control plane refreshes it. And remember the standalone scheduler only runs with `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` set; a cron on a server without it is inert.\n\n## Step 2: Read the materialization history\n\nThe history is where cost lives. Each run records its trigger, timing, and per source what it did.\n\n- **For one package** (newest first; `--limit` / `--offset` page it):\n\n ```bash\n malloy-pub list materialization --environment <env> --package <pkg>\n ```\n\n Columns: ID, Status, **Trigger** (`SCHEDULER` vs `ON_DEMAND`), Started, Completed, Error.\n\n- **A single run's detail** (the cost signals):\n\n ```bash\n malloy-pub get materialization <id> --environment <env> --package <pkg>\n ```\n\n In the JSON, read:\n - `metadata.durationMs`: how long the whole run took.\n - `metadata.sourcesBuilt` / `sourcesReused` / `sourcesFailed`: how much work the run did. A run that reused most sources was cheap. **A `SCHEDULER` run never reuses**: every scheduled fire is forced, so its `sourcesReused` is 0 by construction; judge a scheduled run by its duration and its per-source entries instead. An incremental source is counted as built even when its refresh applied nothing.\n - `metadata.sourcesRefused` and `metadata.refusedSources` (present only when a source was refused, and only on a run the publisher planned itself): sources the eligibility gate skipped. Each one serves live on every query, so a refused source the user expected to be persisted is a finding in its own right; its entry's `message` says what to change.\n - `manifest.entries` - a map keyed by `sourceEntityId`. Each entry carries `sourceName`, `physicalTableName`, `realization`, and the per-source cost signals: `buildDurationMs`; `queryCostBytes` where the warehouse reports it; `refresh: delta | full | none` for an incremental source (a `full` on an unchanged source every run is a source that never advances); `upstreamReuse: reused | recomputed` for a chained source (a `recomputed` with `upstreamRecomputeReason` is a chain that went back to the warehouse).\n - `manifest.failures`: sources the run lost while still finishing ready.\n\nLook across several runs, not one: the pattern over the recent history (how often it rebuilds, how long each source takes, what reuses) is the signal.\n\n- **For `storage=` tables, how queries were routed.** `publisher_storage_serve_routing_total{outcome, origin}` on the metrics endpoint counts queries answered from the store (`storage`) against ones that degraded (`live_fallback`, `runtime_live_fallback`), and each query response's `servedFrom` says it per query. Nothing equivalent exists for a table built in the source's own warehouse: there, \"rarely queried\" is a judgement from the model and the user's knowledge, not a measured metric. Say so when it matters.\n\n## Step 3: Analyze and recommend\n\nWeigh rebuild cost against query benefit. Common findings:\n\n- **Persist candidate:** an expensive, frequently-queried source that is _not_ persisted (recomputed on every query). Recommend adding `#@ persist name=\"...\"`. Strongest when the source is a heavy aggregate/join reused by many queries and its inputs change slowly. If the source carries a `#(access_filter)` gate, check which case it is first. A gate reached only through a `join_*`, or not classifying as a row filter, means the colocated persist is **refused** - the build skips it and it serves live - so do not recommend persisting it at all. Where the gate is the source's own row filter the persist is admitted, and then only recommend it alongside a freshness window (`freshness.fallback=\"live\"`): the gating column freezes at build time, so without one a revoked row can be served under its old access decision indefinitely (see `access-filter.md`).\n- **Removal candidate:** a persisted source that is cheap to compute, rarely queried, or rebuilt far more often than it is read. Recommend dropping the `#@ persist` annotation (and its table): the storage + rebuild cost is not buying anything.\n- **Chain candidate:** a `storage=` rollup whose intermediates re-read the warehouse on every build while a stored root already holds the rows. Rewire the intermediates onto the stored root (declaration order, a distinct `-> { select: * }` intermediate, joins onto stored siblings - `chaining.md`), and read `upstreamReuse` on the next run to confirm the chain holds. Keep a self-join or a wide hash aggregate over a large fact **in the warehouse**: chained, it builds in the destination's engine under its memory bound.\n- **Partition candidate:** a `storage=` rollup carrying every key at a fine grain that queries filter to one key - read whole per query. Recommend `partition=\"<key>\"` on the filter column (`storage.md`), noting the cardinality.\n- **Denominator candidate:** a ratio whose numerator is a rollup and whose denominator counts the fact table per query. Persist the denominator at its own grain.\n- **Cadence mismatch:** a `SCHEDULER` cadence out of step with how fast the data changes or how long a build takes. If the build's `durationMs` approaches the interval, the cadence is too aggressive. If queries routinely read stale data, tighten it. For an incremental source, `refresh: delta` with small `buildDurationMs` per fire is the cheap steady state; `refresh: full` every fire means the boundary is being lost (read the reason).\n- **Scope mismatch:** `scope: version` re-materializes per published version (right when versions must be isolated, e.g. a schedule); `scope: package` reuses one lineage across versions (cheaper when versions can share). A schedule _requires_ `version`. If a package carries a schedule it does not need, clearing it is step one; the scope itself is edited in `publisher.json` (the CLI does not move it back).\n\nFrame each recommendation with the evidence from Step 2 (the run IDs, timings, built/reused counts, per-entry fields) so the user can judge it.\n\n## Step 4: Apply (only once approved)\n\n- **Add a persist source:** add the `#@ persist` annotation in the `.malloy` file (use the modeling workflow to validate and reload), then rebuild so it is materialized:\n\n ```bash\n malloy-pub materialize --environment <env> --package <pkg> --wait --timeout 900\n ```\n\n (`--wait` gives up after 120 s by default and exits non-zero while the build continues; raise `--timeout` for a real build.)\n\n- **Remove a persist source:** delete the `#@ persist` annotation, rebuild, then drop the old run's tables:\n\n ```bash\n malloy-pub materialize --environment <env> --package <pkg> --wait\n malloy-pub delete materialization <old-id> --environment <env> --package <pkg> --drop-tables\n ```\n\n > `--drop-tables` drops the physical tables in that run's manifest **except any a still-ready run also names** - with the stable names an auto-run assigns, a table the new run carried forward survives the delete. Deleting a run also re-derives the package's serve bindings from the latest remaining ready run (or clears them to serve live), so a delete does not break queries; it can leave a table behind. A removed source's table is therefore dropped only once no ready run names it - delete the old runs that do, or drop it in the warehouse yourself.\n\n- **Change the schedule / cadence:**\n\n ```bash\n malloy-pub schedule set \"0 6 * * *\" --environment <env> --package <pkg> # 5-field UTC cron\n malloy-pub schedule clear --environment <env> --package <pkg>\n ```\n\n `set` also sets `scope: version` (a schedule requires it) and **replaces the whole `materialization` block, dropping an existing `freshness` policy**; the server rejects an invalid cron or an illegal scope/freshness combination, so a rejection means the change was unsafe. `clear` leaves `scope: version` in place.\n\n- **Change the scope:** `scope` is declared in the package's `publisher.json` under `materialization` (`\"scope\": \"package\"` or `\"version\"`; the root-level form is deprecated but still read). Edit it there, then reload/republish the package. Remember a schedule pins scope to `version`, so clear the schedule first if moving to `package`.\n\nAfter applying, re-run Step 2 on the next few builds to confirm the change did what you predicted (more reuse, shorter builds, a chain that reports `reused`, or a table that is actually read).\n\n## What this skill does not do\n\n- It does not decide for the user or apply changes silently; every edit needs an explicit go-ahead.\n- It cannot measure reads of a table built in the source's own warehouse (only the storage tier reports routing), so \"rarely queried\" there is a judgment from the model and the user's knowledge, not a measured metric. Say so when it matters."
|
|
395590
396352
|
},
|
|
395591
396353
|
{
|
|
395592
396354
|
name: "malloy-model",
|
|
@@ -396585,7 +397347,7 @@ Available: _concepts, cookbook-filter-context, cookbook-structure, cookbook-time
|
|
|
396585
397347
|
{
|
|
396586
397348
|
name: "malloy-powerbi-review/_concepts",
|
|
396587
397349
|
description: "Power BI → Malloy Concept Mapping. Reference detail for the malloy-powerbi-review skill.",
|
|
396588
|
-
body: "# Power BI → Malloy Concept Mapping\n\nReference table for translating Power BI (TMDL / DAX) constructs to Malloy. Referenced by multiple reference files.\n\n## Model Objects\n\n| Power BI (TMDL) | Malloy | Notes |\n|--------|--------|-------|\n| `table X` | `source:` | One source per table |\n| `column Y` | column reference, or `dimension:` when renamed or derived | Plain columns need no declaration |\n| `column Y` with `type: calculated` | `dimension:` with the translated expression | DAX evaluated at refresh; see `translate-measures.md` |\n| `measure M = <DAX>` | `measure:` | The translation is the hard part, not the syntax |\n| `partition P = m` with `mode: import` | `conn.table('schema.table')` | The M code names the real source table |\n| `partition P` with `mode: directQuery` | `conn.table('schema.table')` | Same target, no imported copy |\n| `table X` whose partition is a DAX expression | Computed source, or push upstream | A calculated table |\n| `/// description text` | `#(doc)` tag | Triple-slash doc comment, directly above the object |\n| `isHidden: true` (or bare `isHidden`) | **not a direct mapping** | Classify the reason first; see `rls-roles.md`. Do NOT map it to `internal:` mechanically: Power BI hides surrogate keys by default and `internal` fields are unreachable through join paths. |\n| `role R` + `metadataPermission` / `columnPermission` | `private:` / `internal:` | Object-level security, a real permission; see `rls-roles.md` |\n| `displayFolder:` | nothing | Authoring convenience, no Malloy equivalent |\n| `hierarchy` with `level` | nothing structural | Note level order as drill intent |\n| `role R` + `tablePermission` | `#(access_filter)` / `#(authorize)` | Narrow grammar; see `rls-roles.md` |\n| `perspective` | access modifiers, or a separate source | A curated subset of the model |\n| `LocalDateTable_<guid>`, `DateTableTemplate_<guid>` | **skip** | Auto-generated, one per date column |\n\n## Relationships\n\nA Power BI relationship is declared once at model level, not on either table. `fromColumn` is the **many** side, `toColumn` is the **one** side.\n\n**TMDL writes only non-default properties.** A typical relationship is three lines - `relationship <guid>`, `fromColumn:`, `toColumn:` - and the absence of the properties below is meaningful, not missing data. Read absent as **many-to-one, single-direction, active**. The relationship name is a GUID and carries no information. The same shorthand applies to booleans generally: `isHidden` alone implies `true`, so a grep for `isHidden: true` misses every one written that way.\n\n| Power BI | Malloy | Notes |\n|--------|--------|-------|\n| `fromCardinality: many` / `toCardinality: one` | `join_one:` on the fact source | The common case |\n| `fromCardinality: one` / `toCardinality: one` | `join_one:` | Direct mapping |\n| `fromCardinality: many` / `toCardinality: many` | **flag** | No direct equivalent; changes results |\n| `crossFilteringBehavior: oneDirection` (default) | ordinary `join_one:` | Filter flows from the one side to the many side |\n| `crossFilteringBehavior: bothDirections` | **flag** | No Malloy equivalent; ask what it was for |\n| `isActive: false` | **flag** | Exists for `USERELATIONSHIP`; find the measures that use it |\n| `relyOnReferentialIntegrity: true` | nothing | A query-plan hint |\n\n## Column Types\n\n| Power BI `dataType` | Malloy | Notes |\n|--------|--------|-------|\n| `string` | `string` | Direct mapping |\n| `int64` | `number` | Direct mapping |\n| `double` | `number` | Direct mapping |\n| `decimal` | `number` | Fixed-point in Power BI; check precision if money |\n| `boolean` | `boolean` | Direct mapping |\n| `dateTime` | `date` or `timestamp` | Pick by whether a time component is used |\n| `binary` | **skip** | Not analyzable |\n\n## Aggregations\n\n| DAX | Malloy | Notes |\n|--------|--------|-------|\n| `SUM(T[c])` | `sum(c)` | Direct mapping |\n| `AVERAGE(T[c])` | `avg(c)` | Direct mapping |\n| `MIN` / `MAX` | `min()` / `max()` | Direct mapping |\n| `SUMX(T, <row expression>)` | `sum(<expression>)` | **The common case, and it is a direct mapping.** `SUMX(Sales, Sales[Qty] * Sales[Price])` is `sum(qty * price)`. No context transition is involved. |\n| `AVERAGEX` / `MINX` / `MAXX` / `COUNTX` over a row expression | `avg()` / `min()` / `max()` / `count()` | Same: an iterator over a row-level expression is an ordinary aggregate |\n| `SUMX(T, [Some Measure])` | **flag** | A measure reference inside the iterator *is* context transition; see `translate-measures.md` |\n| `RELATED(Other[c])` | `other.c` | Follows a relationship; becomes a join path |\n| `RELATEDTABLE(Other)` | the joined source | Usually an aggregate over a `join_many:` |\n| `SELECTEDVALUE(T[c])` | **flag** | Depends on the query's grouping; no measure-level equivalent |\n| `VAR x = ... RETURN ...` | inline, or a `dimension:` | A local binding; translate the body |\n| `COUNTROWS(T)` | `count()` | Direct mapping |\n| `DISTINCTCOUNT(T[c])` | `count(c)` | **`count(field)` is already the distinct count in Malloy.** `count(distinct c)` is a hard error, despite a message that reads like a deprecation warning: ``count(distinct expression)` deprecated, use `count(expression)` instead.` |\n| `COUNT(T[c])` | `count() { where: c is not null }` | **Not `count(c)`.** DAX `COUNT` counts non-blank rows; Malloy's `count(c)` counts *distinct* values. They differ on any column with repeats, and the wrong one compiles. |\n| `DIVIDE(a, b)` | `a / nullif(b, 0)` | `DIVIDE` returns blank on divide-by-zero |\n| `DIVIDE(a, b, alt)` | `(a / nullif(b, 0)) ?? alt` | The third argument is the divide-by-zero result |\n| `COALESCE(a, b)` | `a ?? b` | Direct mapping |\n| `IF(cond, a, b)` | `pick a when cond else b` | Direct syntax translation |\n| `SWITCH(TRUE(), c1, v1, ...)` | chained `pick ... when` | Direct syntax translation |\n| `BLANK()` | `null` | Not identical in arithmetic; see below |\n\n**`BLANK()` is not `null`.** In DAX, `BLANK() + 1` is `1`; in SQL and Malloy, `null + 1` is `null`. A measure that leans on blank-as-zero arithmetic changes behavior on translation. Check any measure that adds or subtracts other measures.\n\n## Filter Context\n\n**This section is the one that produces wrong numbers.** Read `translate-measures.md` before translating anything that uses `CALCULATE`.\n\n| DAX | Malloy | Notes |\n|--------|--------|-------|\n| `CALCULATE(expr, T[c] = \"x\")` | `measure { where: c = 'x' }` **only when safe** | DAX **overwrites** the filter on `T[c]`; Malloy **intersects**. Different answers. |\n| `CALCULATE(expr, KEEPFILTERS(T[c] = \"x\"))` | `measure { where: c = 'x' }` | `KEEPFILTERS` makes DAX intersect, which is what Malloy already does. Safe. |\n| `CALCULATE(expr, ALLSELECTED())` | `all(expr)` | **Exact match for this top-level shape.** `all()` removes *grouping* and keeps the query's `where:` - which is `ALLSELECTED` semantics. Inside a nested iterator `ALLSELECTED` has shadow-filter-context behavior `all()` does not model. `cookbook-filter-context.md#fc3` |\n| `CALCULATE(expr, ALLSELECTED(T[c]))` | `exclude(expr, c)` | **Not `all()`.** A column argument restores only *that* column's visual filter, so in a category > subcategory matrix `ALLSELECTED(Product[Subcategory])` is the category total. |\n| `CALCULATE(expr, ALL(T))` | `all(expr)` **only when grouping is the sole filter** | **Not equivalent in general** - and `ALL`, not `ALLSELECTED`, is the divergent one. DAX `ALL` removes the *filters* too, but only on `T`'s expanded table: `ALL(FactTable)` clears everything reachable and diverges maximally, `ALL(SomeDimension)` leaves filters on other tables standing. `cookbook-filter-context.md#fc2` |\n| `CALCULATE(expr, ALL(T[c]))` | `exclude(expr, c)` **with the same caveat** | Same divergence, one dimension. `cookbook-filter-context.md#fc5` |\n| `CALCULATE(expr, REMOVEFILTERS(T[c]))` | `exclude(expr, c)` **with the same caveat** | `REMOVEFILTERS` is the clearer spelling of `ALL` as a modifier |\n| `CALCULATE(expr, ALLEXCEPT(T, T[keep]))` | `all(expr, keep)` | **Keeps** the listed columns and removes the rest, so it is `all(..., keep)`, not `exclude(..., keep)`. `cookbook-filter-context.md#fc4` |\n| `expr / CALCULATE(expr, ALL(T))` | percent-of-total with `all()` | Matches only under the caveat below. `cookbook-filter-context.md#fc2` |\n| `USERELATIONSHIP(...)` | a second join path | Not a gap: name every role instead of switching between them. `cookbook-structure.md#s1` |\n| `CROSSFILTER(..., BOTH)` | **flag** | Sets filter direction inside one measure, so it leaves no trace in `relationships.tmdl`. `cookbook-structure.md#s3` |\n| `FILTER(T, cond)` as a `CALCULATE` argument | depends | A table filter, not a column filter; see `translate-measures.md` |\n| `EARLIER` / `EARLIEST` | **no recipe** (`NR`) | Row-context construct with no equivalent; ask what the number means and rewrite the intent |\n| `RANKX`, `TOPN` | `calculate: rank()` in a query | Not a measure, but not a gap either. `rank()` orders by any expression, independently of the query's own ordering. `cookbook-filter-context.md#fc6` |\n| `SUMX(FILTER(T, cond), expr)` | `sum(expr) { where: cond }`, with the `FILTER` caveat | Only a context transition if a measure reference appears inside; see `translate-measures.md` |\n| `GENERATESERIES` parameter table + `SELECTEDVALUE` | `given:` | A disconnected what-if slicer is a runtime value. `cookbook-structure.md#s4` |\n\n**`ALL` removes filters; `all()` removes grouping.** This is the same overwrite-versus-intersect divergence as the `CALCULATE` row above, wearing different clothes, and it is on the *safe*-looking side of the table.\n\nIn a report grouped by category with a filter `Year = 2023`:\n\n| | DAX `CALCULATE([Sales], ALL(Sales))` | Malloy `all(sales)` |\n|---|---|---|\n| Category filter (from the grouping) | removed | removed |\n| Year filter | **also removed** | **still applied** |\n| Result | total across all years | total for 2023 |\n\nThey agree when the grouping is the only filter in play, which is exactly the unfiltered grand total people check first. Treat every `ALL`/`REMOVEFILTERS` translation as divergent and validate it with a filter active on a column other than the grouping.\n\n**Second constraint:** a dimension named in `all()` or `exclude()` must be an output field of the query, so `exclude(expr, c)` written as a source-level `measure:` only works in queries that `group_by: c`.\n\n## Time Intelligence\n\nEvery one of these is a **rewrite, not a transcription**. They depend on a table marked as the model's date table with a contiguous date column, a concept Malloy does not have. Translate the intent - the intent translates, and mostly without a stopgap.\n\n| DAX | Malloy | Notes |\n|--------|--------|-------|\n| `TOTALYTD(expr, Date[Date])`, `DATESYTD` | `calculate: sum_cumulative(expr) { partition_by: year, order_by: period }` | A correct YTD, and right on sparse data. **`year_end_date` picks the partition column** - it defaults to `\"12/31\"`, so the three-argument form is calendar-year, not fiscal. `cookbook-time.md#t1` |\n| `TOTALMTD`, `DATESMTD`, `DATESQTD` | the same, partitioned one level finer | `cookbook-time.md#t1` |\n| `SAMEPERIODLASTYEAR(Date[Date])` | a filtered aggregate per side, over named ranges | `cookbook-time.md#t2` |\n| `DATEADD(Date[Date], -1, MONTH)`, `PREVIOUSMONTH`, `PARALLELPERIOD` | `lag()` **where every period is present**; named ranges where not | `lag()` is positional, so it silently compares across a gap. `cookbook-time.md#t3` |\n| `CALENDAR()`, `CALENDARAUTO()` | a generated date source | `cookbook-time.md#t6` |\n| `CLOSINGBALANCEMONTH` and the semi-additive family (`LASTNONBLANK`, `LASTDATE` and the rest, as a `CALCULATE` filter) | **STOPGAP** - a multi-stage pipeline | `last_value` cannot be a measure. `cookbook-time.md#t5` |\n| densifying a sparse period series | **STOPGAP** - a generated spine plus a join | Nothing in Malloy densifies. `cookbook-time.md#t4` |\n\n## Functions With No Mapping Yet\n\nFound in real Microsoft-published TMDL and not covered by any table above. Counts are measures containing the function in `PBIASEngine` (126 measures). Most are report-layer once you look at them, which is the point: route them through `translate-measures.md` step 1 rather than treating each as a translation problem.\n\n| DAX | Measures | What to do |\n|--------|---:|-------|\n| `FIRSTNONBLANK` / `LASTNONBLANK` | 16 | The semi-additive shape when it is a `CALCULATE` filter (`cookbook-time.md#t5`). None of the 16 is one, so none routes to T5 |\n| `LASTDATE` / `FIRSTDATE` / `ENDOFMONTH` / `STARTOFMONTH` (and the `ENDOF*` / `STARTOF*` family) | 0 | Return a date, not a measure, and the semi-additive use, `CALCULATE([m], LASTDATE(...))`, needs the value as at that date. Malloy has no measure-level equivalent (`last_value` and `first_value` are window functions, legal only in `calculate:`), so it is a multi-stage query, over the T4 spine for the calendar reading. `cookbook-time.md#t5`. As a `DATESINPERIOD` anchor, `LASTDATE` is a period bound and does not route to T5 |\n| `INT` | 15 | `floor()`, or drop it where it only exists to coerce a Boolean to 1/0 |\n| `DATEDIFF` | 11 | `days(a to b)`, `months(a to b)`, and so on. **Not `date_diff`** - that is not a Malloy function (`Unknown function 'date_diff'`), and `(a - b)` on timestamps is not the idiom either |\n| `ISFILTERED` | 11 | Report-layer. The query knows what it grouped by; the measure does not need to |\n| `COUNTA` | 9 | `count() { where: c is not null }` - the same trap as DAX `COUNT` |\n| `ALLNOBLANKROW` | 9 | `all()`, plus a null check if the blank row was load-bearing |\n| `HASONEFILTER` / `HASONEVALUE` | 9 / 2 | Report-layer, almost always guarding a label |\n| `ISBLANK` | 8 | `is null`, but check the `BLANK()`-as-zero caveat below |\n| `ISINSCOPE` | 7 | Report-layer. Drop it - see `cookbook-filter-context.md#fc6` |\n| `CALCULATETABLE` | 7 | A filtered table expression; usually collapses into the surrounding query's `where:`. With a Boolean filter argument it overwrites the column's filter like `CALCULATE` does, and routes to `FC1` |\n| `GROUPBY` | 7 | A `group_by:` stage |\n| `VALUES` | 5 | The distinct values of a column; `group_by:` or `count(c)` depending on use |\n| `PERCENTILE.INC` | 5 | No direct equivalent; use the dialect's percentile via `fn!()` |\n| `ADDCOLUMNS` / `SUMMARIZE` | 5 / 5 | A query stage, not a measure |\n| `CONCATENATEX` | 5 | `string_agg(expr, sep)`, native - no `fn!()` needed; usually report-layer |\n| `STDEV.P` | 2 | `stddev()` where the dialect has it |\n| `CROSSFILTER` | 1 | `cookbook-structure.md#s3` |\n\n## Formatting\n\n| Power BI | Malloy | Notes |\n|--------|--------|-------|\n| `formatString: \"\\\\$#,0.00\"` | `# currency` | Map to Malloy render tags |\n| `formatString: \"0.00%\"` | `# percent` | Map to Malloy render tags |\n| `formatString: \"#,0\"` | `# number=\"#,0\"` | Map to Malloy render tags |\n| `dataCategory: WebUrl` | `# url` | Map to Malloy render tags |\n| `dataCategory: Place` / `City` / `Country` | nothing automatic | Geo hint for chart selection |"
|
|
397350
|
+
body: "# Power BI → Malloy Concept Mapping\n\nReference table for translating Power BI (TMDL / DAX) constructs to Malloy. Referenced by multiple reference files.\n\n## Model Objects\n\n| Power BI (TMDL) | Malloy | Notes |\n|--------|--------|-------|\n| `table X` | `source:` | One source per table |\n| `column Y` | column reference, or `dimension:` when renamed or derived | Plain columns need no declaration |\n| `column Y` with `type: calculated` | `dimension:` with the translated expression | DAX evaluated at refresh; see `translate-measures.md` |\n| `measure M = <DAX>` | `measure:` | The translation is the hard part, not the syntax |\n| `partition P = m` with `mode: import` | `conn.table('schema.table')` | The M code names the real source table |\n| `partition P` with `mode: directQuery` | `conn.table('schema.table')` | Same target, no imported copy |\n| `table X` whose partition is a DAX expression | Computed source, or push upstream | A calculated table |\n| `/// description text` | `#(doc)` tag | Triple-slash doc comment, directly above the object |\n| `isHidden: true` (or bare `isHidden`) | **not a direct mapping** | Classify the reason first; see `rls-roles.md`. Do NOT map it to `internal:` mechanically: Power BI hides surrogate keys by default and `internal` fields are unreachable through join paths. |\n| `role R` + `metadataPermission` / `columnPermission` | `private:` / `internal:` | Object-level security, a real permission; see `rls-roles.md` |\n| `displayFolder:` | nothing | Authoring convenience, no Malloy equivalent |\n| `hierarchy` with `level` | nothing structural | Note level order as drill intent |\n| `role R` + `tablePermission` | `#(access_filter)` / `#(authorize)` | Narrow grammar; see `rls-roles.md` |\n| `perspective` | access modifiers, or a separate source | A curated subset of the model |\n| `LocalDateTable_<guid>`, `DateTableTemplate_<guid>` | **skip** | Auto-generated, one per date column |\n\n## Relationships\n\nA Power BI relationship is declared once at model level, not on either table. `fromColumn` is the **many** side, `toColumn` is the **one** side.\n\n**TMDL writes only non-default properties.** A typical relationship is three lines - `relationship <guid>`, `fromColumn:`, `toColumn:` - and the absence of the properties below is meaningful, not missing data. Read absent as **many-to-one, single-direction, active**. The relationship name is a GUID and carries no information. The same shorthand applies to booleans generally: `isHidden` alone implies `true`, so a grep for `isHidden: true` misses every one written that way.\n\n| Power BI | Malloy | Notes |\n|--------|--------|-------|\n| `fromCardinality: many` / `toCardinality: one` | `join_one:` on the fact source | The common case |\n| `fromCardinality: one` / `toCardinality: one` | `join_one:` | Direct mapping |\n| `fromCardinality: many` / `toCardinality: many` | **flag** | No direct equivalent; changes results |\n| `crossFilteringBehavior: oneDirection` (default) | ordinary `join_one:` | Filter flows from the one side to the many side |\n| `crossFilteringBehavior: bothDirections` | **flag** | No Malloy equivalent; ask what it was for |\n| `isActive: false` | **flag** | Exists for `USERELATIONSHIP`; find the measures that use it |\n| `relyOnReferentialIntegrity: true` | nothing | A query-plan hint |\n\n## Column Types\n\n| Power BI `dataType` | Malloy | Notes |\n|--------|--------|-------|\n| `string` | `string` | Direct mapping |\n| `int64` | `number` | Direct mapping |\n| `double` | `number` | Direct mapping |\n| `decimal` | `number` | Fixed-point in Power BI; check precision if money |\n| `boolean` | `boolean` | Direct mapping |\n| `dateTime` | `date` or `timestamp` | Pick by whether a time component is used |\n| `binary` | **skip** | Not analyzable |\n\n## Aggregations\n\n| DAX | Malloy | Notes |\n|--------|--------|-------|\n| `SUM(T[c])` | `sum(c)` | Direct mapping |\n| `AVERAGE(T[c])` | `avg(c)` | Direct mapping |\n| `MIN` / `MAX` | `min()` / `max()` | Direct mapping |\n| `SUMX(T, <row expression>)` | `sum(<expression>)` | **The common case, and it is a direct mapping.** `SUMX(Sales, Sales[Qty] * Sales[Price])` is `sum(qty * price)`. No context transition is involved. |\n| `AVERAGEX` / `MINX` / `MAXX` / `COUNTX` over a row expression | `avg()` / `min()` / `max()` / `count()` | Same: an iterator over a row-level expression is an ordinary aggregate |\n| `SUMX(T, [Some Measure])` | **flag** | A measure reference inside the iterator *is* context transition; see `translate-measures.md` |\n| `RELATED(Other[c])` | `other.c` | Follows a relationship; becomes a join path |\n| `RELATEDTABLE(Other)` | the joined source | Usually an aggregate over a `join_many:` |\n| `SELECTEDVALUE(T[c])` | **flag** | Depends on the query's grouping; no measure-level equivalent |\n| `VAR x = ... RETURN ...` | inline, or a `dimension:` | A local binding; translate the body |\n| `COUNTROWS(T)` | `count()` | Direct mapping |\n| `DISTINCTCOUNT(T[c])` | `count(c)` | **`count(field)` is already the distinct count in Malloy.** `count(distinct c)` is a hard error, despite a message that reads like a deprecation warning: ``count(distinct expression)` deprecated, use `count(expression)` instead.` |\n| `COUNT(T[c])` | `count() { where: c is not null }` | **Not `count(c)`.** DAX `COUNT` counts non-blank rows; Malloy's `count(c)` counts *distinct* values. They differ on any column with repeats, and the wrong one compiles. |\n| `DIVIDE(a, b)` | `a / nullif(b, 0)` | `DIVIDE` returns blank on divide-by-zero |\n| `DIVIDE(a, b, alt)` | `(a / nullif(b, 0)) ?? alt` | The third argument is the divide-by-zero result |\n| `COALESCE(a, b)` | `a ?? b` | Direct mapping |\n| `IF(cond, a, b)` | `pick a when cond else b` | Direct syntax translation |\n| `SWITCH(TRUE(), c1, v1, ...)` | chained `pick ... when` | Direct syntax translation |\n| `BLANK()` | `null` | Not identical in arithmetic; see below |\n\n**`BLANK()` is not `null`.** In DAX, `BLANK() + 1` is `1`; in SQL and Malloy, `null + 1` is `null`. A measure that leans on blank-as-zero arithmetic changes behavior on translation. Check any measure that adds or subtracts other measures.\n\n## Filter Context\n\n**This section is the one that produces wrong numbers.** Read `translate-measures.md` before translating anything that uses `CALCULATE`.\n\n| DAX | Malloy | Notes |\n|--------|--------|-------|\n| `CALCULATE(expr, T[c] = \"x\")` | `measure { where: c = 'x' }` **only when safe** | DAX **overwrites** the filter on `T[c]`; Malloy **intersects**. Different answers. |\n| `CALCULATE(expr, KEEPFILTERS(T[c] = \"x\"))` | `measure { where: c = 'x' }` | `KEEPFILTERS` makes DAX intersect, which is what Malloy already does. Safe. |\n| `CALCULATE(expr, ALLSELECTED())` | `all(expr)` | **Exact match for this top-level shape.** `all()` removes *grouping* and keeps the query's `where:` - which is `ALLSELECTED` semantics. Inside a nested iterator `ALLSELECTED` has shadow-filter-context behavior `all()` does not model. `cookbook-filter-context.md#fc3` |\n| `CALCULATE(expr, ALLSELECTED(T[c]))` | `exclude(expr, c)` | **Not `all()`.** A column argument restores only *that* column's visual filter, so in a category > subcategory matrix `ALLSELECTED(Product[Subcategory])` is the category total. |\n| `CALCULATE(expr, ALL(T))` | `all(expr)` **only when grouping is the sole filter** | **Not equivalent in general** - and `ALL`, not `ALLSELECTED`, is the divergent one. DAX `ALL` removes the *filters* too, but only on `T`'s expanded table: `ALL(FactTable)` clears everything reachable and diverges maximally, `ALL(SomeDimension)` leaves filters on other tables standing. `cookbook-filter-context.md#fc2` |\n| `CALCULATE(expr, ALL(T[c]))` | `exclude(expr, c)` **with the same caveat** | Same divergence, one dimension. `cookbook-filter-context.md#fc5` |\n| `CALCULATE(expr, REMOVEFILTERS(T[c]))` | `exclude(expr, c)` **with the same caveat** | `REMOVEFILTERS` is the clearer spelling of `ALL` as a modifier |\n| `CALCULATE(expr, ALLEXCEPT(T, T[keep]))` | `all(expr, keep)` | **Keeps** the listed columns and removes the rest, so it is `all(..., keep)`, not `exclude(..., keep)`. `cookbook-filter-context.md#fc4` |\n| `expr / CALCULATE(expr, ALL(T))` | percent-of-total with `all()` | Matches only under the caveat below. `cookbook-filter-context.md#fc2` |\n| `USERELATIONSHIP(...)` | a second join path | Not a gap: name every role instead of switching between them. `cookbook-structure.md#s1` |\n| `CROSSFILTER(..., BOTH)` | **flag** | Sets filter direction inside one measure, so it leaves no trace in `relationships.tmdl`. `cookbook-structure.md#s3` |\n| `FILTER(T, cond)` as a `CALCULATE` argument | depends | A table filter, not a column filter; see `translate-measures.md` |\n| `EARLIER` / `EARLIEST` | **no recipe** (`NR`) | Row-context construct with no equivalent; ask what the number means and rewrite the intent |\n| `RANKX`, `TOPN` | `calculate: rank()` in a query | Not a measure, but not a gap either. `rank()` orders by any expression, independently of the query's own ordering. `cookbook-filter-context.md#fc6` |\n| `SUMX(FILTER(T, cond), expr)` | `sum(expr) { where: cond }`, with the `FILTER` caveat | Only a context transition if a measure reference appears inside; see `translate-measures.md` |\n| `GENERATESERIES` parameter table + `SELECTEDVALUE` | `given:` | A disconnected what-if slicer is a runtime value. `cookbook-structure.md#s4` |\n\n**`ALL` removes filters; `all()` removes grouping.** This is the same overwrite-versus-intersect divergence as the `CALCULATE` row above, wearing different clothes, and it is on the *safe*-looking side of the table.\n\nIn a report grouped by category with a filter `Year = 2023`:\n\n| | DAX `CALCULATE([Sales], ALL(Sales))` | Malloy `all(sales)` |\n|---|---|---|\n| Category filter (from the grouping) | removed | removed |\n| Year filter | **also removed** | **still applied** |\n| Result | total across all years | total for 2023 |\n\nThey agree when the grouping is the only filter in play, which is exactly the unfiltered grand total people check first. Treat every `ALL`/`REMOVEFILTERS` translation as divergent and validate it with a filter active on a column other than the grouping.\n\n**Second constraint:** a dimension named in `all()` or `exclude()` must be an output field of the query, so `exclude(expr, c)` written as a source-level `measure:` only works in queries that `group_by: c`.\n\n## Time Intelligence\n\nEvery one of these is a **rewrite, not a transcription**. They depend on a table marked as the model's date table with a contiguous date column, a concept Malloy does not have. Translate the intent - the intent translates, and mostly without a stopgap.\n\n| DAX | Malloy | Notes |\n|--------|--------|-------|\n| `TOTALYTD(expr, Date[Date])`, `DATESYTD` | `calculate: sum_cumulative(expr) { partition_by: year, order_by: period }` | A correct YTD, and right on sparse data. **`year_end_date` picks the partition column** - it defaults to `\"12/31\"`, so the three-argument form is calendar-year, not fiscal. `cookbook-time.md#t1` |\n| `TOTALMTD`, `DATESMTD`, `DATESQTD` | the same, partitioned one level finer | `cookbook-time.md#t1` |\n| `SAMEPERIODLASTYEAR(Date[Date])` | a filtered aggregate per side, over named ranges | `cookbook-time.md#t2` |\n| `DATEADD(Date[Date], -1, MONTH)`, `PREVIOUSMONTH`, `PARALLELPERIOD` | `lag()` **where every period is present**; named ranges where not | `lag()` is positional, so it silently compares across a gap. `cookbook-time.md#t3` |\n| `CALENDAR()`, `CALENDARAUTO()` | a generated date source | `cookbook-time.md#t6` |\n| `CLOSINGBALANCEMONTH` and the semi-additive family (`LASTNONBLANK`, `LASTDATE` and the rest, as a `CALCULATE` filter) | **STOPGAP** - a multi-stage pipeline | `last_value` cannot be a measure. `cookbook-time.md#t5` |\n| densifying a sparse period series | **STOPGAP** - a generated spine plus a join | Nothing in Malloy densifies. `cookbook-time.md#t4` |\n\n## Functions With No Mapping Yet\n\nFound in real Microsoft-published TMDL and not covered by any table above. Counts are measures containing the function in `PBIASEngine` (126 measures). Most are report-layer once you look at them, which is the point: route them through `translate-measures.md` step 1 rather than treating each as a translation problem.\n\n| DAX | Measures | What to do |\n|--------|---:|-------|\n| `FIRSTNONBLANK` / `LASTNONBLANK` | 16 | The semi-additive shape when it is a `CALCULATE` filter (`cookbook-time.md#t5`). None of the 16 is one, so none routes to T5 |\n| `LASTDATE` / `FIRSTDATE` / `ENDOFMONTH` / `STARTOFMONTH` (and the `ENDOF*` / `STARTOF*` family) | 0 | Return a date, not a measure, and the semi-additive use, `CALCULATE([m], LASTDATE(...))`, needs the value as at that date. Malloy has no measure-level equivalent (`last_value` and `first_value` are window functions, legal only in `calculate:`), so it is a multi-stage query, over the T4 spine for the calendar reading. `cookbook-time.md#t5`. As a `DATESINPERIOD` anchor, `LASTDATE` is a period bound and does not route to T5 |\n| `INT` | 15 | `floor()`, or drop it where it only exists to coerce a Boolean to 1/0 |\n| `DATEDIFF` | 11 | `days(a to b)`, `months(a to b)`, and so on. **Not `date_diff`** - that is not a Malloy function (`Unknown function 'date_diff'`), and `(a - b)` on timestamps is not the idiom either |\n| `ISFILTERED` | 11 | Report-layer. The query knows what it grouped by; the measure does not need to |\n| `COUNTA` | 9 | `count() { where: c is not null }` - the same trap as DAX `COUNT` |\n| `ALLNOBLANKROW` | 9 | `all()`, plus a null check if the blank row was load-bearing |\n| `HASONEFILTER` / `HASONEVALUE` | 9 / 2 | Report-layer, almost always guarding a label |\n| `ISBLANK` | 8 | `is null`, but check the `BLANK()`-as-zero caveat below |\n| `ISINSCOPE` | 7 | Report-layer. Drop it - see `cookbook-filter-context.md#fc6` |\n| `CALCULATETABLE` | 7 | A filtered table expression; usually collapses into the surrounding query's `where:`. With a Boolean filter argument it overwrites the column's filter like `CALCULATE` does, and routes to `FC1` |\n| `GROUPBY` | 7 | A `group_by:` stage |\n| `VALUES` | 5 | The distinct values of a column; `group_by:` or `count(c)` depending on use |\n| `PERCENTILE.INC` | 5 | No measure equivalent. A raw-SQL percentile through `fn!()` does not compile as a measure (`skill:malloy-gotchas-modeling`). Compute it as a query with the **Tier boundaries** pattern in `skill:malloy-discover`, or list it as a gap. That pattern is nearest-rank, and `PERCENTILE.INC` interpolates, so the two can differ when the rank falls between two values |\n| `ADDCOLUMNS` / `SUMMARIZE` | 5 / 5 | A query stage, not a measure |\n| `CONCATENATEX` | 5 | `string_agg(expr, sep)`, native - no `fn!()` needed; usually report-layer |\n| `STDEV.P` | 2 | `stddev()` where the dialect has it |\n| `CROSSFILTER` | 1 | `cookbook-structure.md#s3` |\n\n## Formatting\n\n| Power BI | Malloy | Notes |\n|--------|--------|-------|\n| `formatString: \"\\\\$#,0.00\"` | `# currency` | Map to Malloy render tags |\n| `formatString: \"0.00%\"` | `# percent` | Map to Malloy render tags |\n| `formatString: \"#,0\"` | `# number=\"#,0\"` | Map to Malloy render tags |\n| `dataCategory: WebUrl` | `# url` | Map to Malloy render tags |\n| `dataCategory: Place` / `City` / `Country` | nothing automatic | Geo hint for chart selection |"
|
|
396589
397351
|
},
|
|
396590
397352
|
{
|
|
396591
397353
|
name: "malloy-powerbi-review/cookbook-filter-context",
|