@malloy-publisher/server 0.0.236 → 0.0.238
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 +221 -64
- package/dist/app/assets/{EnvironmentPage-BsAnavYN.js → EnvironmentPage-2oMC3DUO.js} +1 -1
- package/dist/app/assets/{HomePage-CADE138j.js → HomePage-DmgHdpH6.js} +1 -1
- package/dist/app/assets/{LightMode-Cfh7KzN8.js → LightMode-BNaG4oAx.js} +1 -1
- package/dist/app/assets/{MainPage-CO3pRlnV.js → MainPage-Dje9yqIp.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-p9YjkRXZ.js → MaterializationsPage-D4jCpScu.js} +1 -1
- package/dist/app/assets/ModelPage-oI3O6TwY.js +1 -0
- package/dist/app/assets/{PackagePage-e4kN75YR.js → PackagePage-DlO2UBEI.js} +1 -1
- package/dist/app/assets/RouteError-B5MgkwXx.js +1 -0
- package/dist/app/assets/{ThemeEditorPage-CciagFTq.js → ThemeEditorPage-DZzT5zt4.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DNWmkCXa.js → WorkbookPage-CfDz8XAp.js} +1 -1
- package/dist/app/assets/{core-Rj_4rRnA.es-BZyvITuO.js → core-BGHaIj_q.es-BeNX3GvK.js} +1 -1
- package/dist/app/assets/{index-DQpV7MyA.js → index-BeZphO3v.js} +1 -1
- package/dist/app/assets/{index-VBbcc8s6.js → index-BfV8vaZu.js} +1 -1
- package/dist/app/assets/{index-CH2AcDzc.js → index-ByceOJbS.js} +15 -15
- package/dist/app/assets/{index-DQa463gC.js → index-DXjc2oZx.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +79 -21
- package/dist/server.mjs +1658 -658
- package/package.json +4 -1
- package/dist/app/assets/ModelPage-C1OSTv-x.js +0 -1
- package/dist/app/assets/RouteError-CzbfOkng.js +0 -1
package/dist/server.mjs
CHANGED
|
@@ -120280,8 +120280,8 @@ var require_node42 = __commonJS((exports, module) => {
|
|
|
120280
120280
|
}
|
|
120281
120281
|
break;
|
|
120282
120282
|
case "FILE":
|
|
120283
|
-
var
|
|
120284
|
-
stream2 = new
|
|
120283
|
+
var fs2 = __require("fs");
|
|
120284
|
+
stream2 = new fs2.SyncWriteStream(fd2, { autoClose: false });
|
|
120285
120285
|
stream2._type = "fs";
|
|
120286
120286
|
break;
|
|
120287
120287
|
case "PIPE":
|
|
@@ -136347,8 +136347,8 @@ var require_node43 = __commonJS((exports, module) => {
|
|
|
136347
136347
|
}
|
|
136348
136348
|
break;
|
|
136349
136349
|
case "FILE":
|
|
136350
|
-
var
|
|
136351
|
-
stream2 = new
|
|
136350
|
+
var fs2 = __require("fs");
|
|
136351
|
+
stream2 = new fs2.SyncWriteStream(fd2, { autoClose: false });
|
|
136352
136352
|
stream2._type = "fs";
|
|
136353
136353
|
break;
|
|
136354
136354
|
case "PIPE":
|
|
@@ -137073,8 +137073,8 @@ var require_node44 = __commonJS((exports, module) => {
|
|
|
137073
137073
|
}
|
|
137074
137074
|
break;
|
|
137075
137075
|
case "FILE":
|
|
137076
|
-
var
|
|
137077
|
-
stream2 = new
|
|
137076
|
+
var fs2 = __require("fs");
|
|
137077
|
+
stream2 = new fs2.SyncWriteStream(fd2, { autoClose: false });
|
|
137078
137078
|
stream2._type = "fs";
|
|
137079
137079
|
break;
|
|
137080
137080
|
case "PIPE":
|
|
@@ -137986,7 +137986,7 @@ var require_view = __commonJS((exports, module) => {
|
|
|
137986
137986
|
*/
|
|
137987
137987
|
var debug = require_src109()("express:view");
|
|
137988
137988
|
var path = __require("path");
|
|
137989
|
-
var
|
|
137989
|
+
var fs2 = __require("fs");
|
|
137990
137990
|
var dirname = path.dirname;
|
|
137991
137991
|
var basename = path.basename;
|
|
137992
137992
|
var extname = path.extname;
|
|
@@ -138052,7 +138052,7 @@ var require_view = __commonJS((exports, module) => {
|
|
|
138052
138052
|
function tryStat(path2) {
|
|
138053
138053
|
debug('stat "%s"', path2);
|
|
138054
138054
|
try {
|
|
138055
|
-
return
|
|
138055
|
+
return fs2.statSync(path2);
|
|
138056
138056
|
} catch (e) {
|
|
138057
138057
|
return;
|
|
138058
138058
|
}
|
|
@@ -138595,8 +138595,8 @@ var require_node45 = __commonJS((exports, module) => {
|
|
|
138595
138595
|
}
|
|
138596
138596
|
break;
|
|
138597
138597
|
case "FILE":
|
|
138598
|
-
var
|
|
138599
|
-
stream2 = new
|
|
138598
|
+
var fs2 = __require("fs");
|
|
138599
|
+
stream2 = new fs2.SyncWriteStream(fd2, { autoClose: false });
|
|
138600
138600
|
stream2._type = "fs";
|
|
138601
138601
|
break;
|
|
138602
138602
|
case "PIPE":
|
|
@@ -138784,7 +138784,7 @@ var require_types45 = __commonJS((exports, module) => {
|
|
|
138784
138784
|
// ../../node_modules/send/node_modules/mime/mime.js
|
|
138785
138785
|
var require_mime = __commonJS((exports, module) => {
|
|
138786
138786
|
var path = __require("path");
|
|
138787
|
-
var
|
|
138787
|
+
var fs2 = __require("fs");
|
|
138788
138788
|
function Mime() {
|
|
138789
138789
|
this.types = Object.create(null);
|
|
138790
138790
|
this.extensions = Object.create(null);
|
|
@@ -138805,7 +138805,7 @@ var require_mime = __commonJS((exports, module) => {
|
|
|
138805
138805
|
};
|
|
138806
138806
|
Mime.prototype.load = function(file) {
|
|
138807
138807
|
this._loading = file;
|
|
138808
|
-
var map = {}, content =
|
|
138808
|
+
var map = {}, content = fs2.readFileSync(file, "ascii"), lines = content.split(/[\r\n]+/);
|
|
138809
138809
|
lines.forEach(function(line) {
|
|
138810
138810
|
var fields = line.replace(/\s*#.*|^\s*|\s*$/g, "").split(/\s+/);
|
|
138811
138811
|
map[fields.shift()] = fields;
|
|
@@ -138933,7 +138933,7 @@ var require_send = __commonJS((exports, module) => {
|
|
|
138933
138933
|
var escapeHtml = require_escape_html();
|
|
138934
138934
|
var etag = require_etag();
|
|
138935
138935
|
var fresh = require_fresh();
|
|
138936
|
-
var
|
|
138936
|
+
var fs2 = __require("fs");
|
|
138937
138937
|
var mime = require_mime();
|
|
138938
138938
|
var ms = require_ms();
|
|
138939
138939
|
var onFinished = require_on_finished();
|
|
@@ -139261,7 +139261,7 @@ var require_send = __commonJS((exports, module) => {
|
|
|
139261
139261
|
var i = 0;
|
|
139262
139262
|
var self2 = this;
|
|
139263
139263
|
debug('stat "%s"', path2);
|
|
139264
|
-
|
|
139264
|
+
fs2.stat(path2, function onstat(err, stat) {
|
|
139265
139265
|
if (err && err.code === "ENOENT" && !extname(path2) && path2[path2.length - 1] !== sep) {
|
|
139266
139266
|
return next(err);
|
|
139267
139267
|
}
|
|
@@ -139278,7 +139278,7 @@ var require_send = __commonJS((exports, module) => {
|
|
|
139278
139278
|
}
|
|
139279
139279
|
var p = path2 + "." + self2._extensions[i++];
|
|
139280
139280
|
debug('stat "%s"', p);
|
|
139281
|
-
|
|
139281
|
+
fs2.stat(p, function(err2, stat) {
|
|
139282
139282
|
if (err2)
|
|
139283
139283
|
return next(err2);
|
|
139284
139284
|
if (stat.isDirectory())
|
|
@@ -139299,7 +139299,7 @@ var require_send = __commonJS((exports, module) => {
|
|
|
139299
139299
|
}
|
|
139300
139300
|
var p = join(path2, self2._index[i]);
|
|
139301
139301
|
debug('stat "%s"', p);
|
|
139302
|
-
|
|
139302
|
+
fs2.stat(p, function(err2, stat) {
|
|
139303
139303
|
if (err2)
|
|
139304
139304
|
return next(err2);
|
|
139305
139305
|
if (stat.isDirectory())
|
|
@@ -139313,7 +139313,7 @@ var require_send = __commonJS((exports, module) => {
|
|
|
139313
139313
|
SendStream.prototype.stream = function stream(path2, options) {
|
|
139314
139314
|
var self2 = this;
|
|
139315
139315
|
var res = this.res;
|
|
139316
|
-
var stream2 =
|
|
139316
|
+
var stream2 = fs2.createReadStream(path2, options);
|
|
139317
139317
|
this.emit("stream", stream2);
|
|
139318
139318
|
stream2.pipe(res);
|
|
139319
139319
|
function cleanup() {
|
|
@@ -156044,6 +156044,8 @@ function internalErrorToHttpError(error) {
|
|
|
156044
156044
|
return httpError(413, error.message);
|
|
156045
156045
|
} else if (error instanceof QueryTimeoutError) {
|
|
156046
156046
|
return httpError(504, error.message);
|
|
156047
|
+
} else if (error instanceof NotImplementedError) {
|
|
156048
|
+
return httpError(501, error.message);
|
|
156047
156049
|
} else {
|
|
156048
156050
|
return httpError(500, error.message);
|
|
156049
156051
|
}
|
|
@@ -156057,7 +156059,7 @@ function httpError(code, message) {
|
|
|
156057
156059
|
}
|
|
156058
156060
|
};
|
|
156059
156061
|
}
|
|
156060
|
-
var NotImplementedError, BadRequestError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, ConnectionNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, MaterializationEligibilityError, FrozenConfigError, AccessDeniedError, NotQueryableError, MaterializationNotFoundError, MaterializationConflictError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, QueryTimeoutError;
|
|
156062
|
+
var NotImplementedError, BadRequestError, InvalidArgumentError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, ConnectionNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, MaterializationEligibilityError, FrozenConfigError, AccessDeniedError, NotQueryableError, MaterializationNotFoundError, MaterializationConflictError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
|
|
156061
156063
|
var init_errors = __esm(() => {
|
|
156062
156064
|
init_constants();
|
|
156063
156065
|
NotImplementedError = class NotImplementedError extends Error {
|
|
@@ -156070,6 +156072,8 @@ var init_errors = __esm(() => {
|
|
|
156070
156072
|
super(message);
|
|
156071
156073
|
}
|
|
156072
156074
|
};
|
|
156075
|
+
InvalidArgumentError = class InvalidArgumentError extends BadRequestError {
|
|
156076
|
+
};
|
|
156073
156077
|
EnvironmentNotFoundError = class EnvironmentNotFoundError extends Error {
|
|
156074
156078
|
constructor(message) {
|
|
156075
156079
|
super(message);
|
|
@@ -156161,6 +156165,13 @@ var init_errors = __esm(() => {
|
|
|
156161
156165
|
PayloadTooLargeError = class PayloadTooLargeError extends Error {
|
|
156162
156166
|
constructor(message) {
|
|
156163
156167
|
super(message);
|
|
156168
|
+
this.name = "PayloadTooLargeError";
|
|
156169
|
+
}
|
|
156170
|
+
};
|
|
156171
|
+
ResponseUnserializableError = class ResponseUnserializableError extends PayloadTooLargeError {
|
|
156172
|
+
constructor(message) {
|
|
156173
|
+
super(message);
|
|
156174
|
+
this.name = "ResponseUnserializableError";
|
|
156164
156175
|
}
|
|
156165
156176
|
};
|
|
156166
156177
|
QueryTimeoutError = class QueryTimeoutError extends Error {
|
|
@@ -165288,7 +165299,7 @@ var require_form_data = __commonJS((exports, module) => {
|
|
|
165288
165299
|
var http = __require("http");
|
|
165289
165300
|
var https = __require("https");
|
|
165290
165301
|
var parseUrl = __require("url").parse;
|
|
165291
|
-
var
|
|
165302
|
+
var fs3 = __require("fs");
|
|
165292
165303
|
var Stream = __require("stream").Stream;
|
|
165293
165304
|
var crypto2 = __require("crypto");
|
|
165294
165305
|
var mime = require_mime_types3();
|
|
@@ -165356,7 +165367,7 @@ var require_form_data = __commonJS((exports, module) => {
|
|
|
165356
165367
|
if (value.end != null && value.end != Infinity && value.start != null) {
|
|
165357
165368
|
callback(null, value.end + 1 - (value.start ? value.start : 0));
|
|
165358
165369
|
} else {
|
|
165359
|
-
|
|
165370
|
+
fs3.stat(value.path, function(err, stat) {
|
|
165360
165371
|
if (err) {
|
|
165361
165372
|
callback(err);
|
|
165362
165373
|
return;
|
|
@@ -183157,8 +183168,8 @@ ${suffix}`;
|
|
|
183157
183168
|
// ../../node_modules/ssh2/lib/protocol/SFTP.js
|
|
183158
183169
|
var require_SFTP = __commonJS((exports, module) => {
|
|
183159
183170
|
var EventEmitter2 = __require("events");
|
|
183160
|
-
var
|
|
183161
|
-
var { constants } =
|
|
183171
|
+
var fs3 = __require("fs");
|
|
183172
|
+
var { constants } = fs3;
|
|
183162
183173
|
var {
|
|
183163
183174
|
Readable: ReadableStream3,
|
|
183164
183175
|
Writable: WritableStream
|
|
@@ -183557,12 +183568,12 @@ var require_SFTP = __commonJS((exports, module) => {
|
|
|
183557
183568
|
fastGet(remotePath, localPath, opts, cb) {
|
|
183558
183569
|
if (this.server)
|
|
183559
183570
|
throw new Error("Client-only method called in server mode");
|
|
183560
|
-
fastXfer(this,
|
|
183571
|
+
fastXfer(this, fs3, remotePath, localPath, opts, cb);
|
|
183561
183572
|
}
|
|
183562
183573
|
fastPut(localPath, remotePath, opts, cb) {
|
|
183563
183574
|
if (this.server)
|
|
183564
183575
|
throw new Error("Client-only method called in server mode");
|
|
183565
|
-
fastXfer(
|
|
183576
|
+
fastXfer(fs3, this, localPath, remotePath, opts, cb);
|
|
183566
183577
|
}
|
|
183567
183578
|
readFile(path3, options, callback_) {
|
|
183568
183579
|
if (this.server)
|
|
@@ -184771,13 +184782,13 @@ var require_SFTP = __commonJS((exports, module) => {
|
|
|
184771
184782
|
if (--left === 0)
|
|
184772
184783
|
cb(err);
|
|
184773
184784
|
};
|
|
184774
|
-
if (srcHandle && (src ===
|
|
184785
|
+
if (srcHandle && (src === fs3 || src.outgoing.state === "open"))
|
|
184775
184786
|
++left;
|
|
184776
|
-
if (dstHandle && (dst ===
|
|
184787
|
+
if (dstHandle && (dst === fs3 || dst.outgoing.state === "open"))
|
|
184777
184788
|
++left;
|
|
184778
|
-
if (srcHandle && (src ===
|
|
184789
|
+
if (srcHandle && (src === fs3 || src.outgoing.state === "open"))
|
|
184779
184790
|
src.close(srcHandle, cbfinal);
|
|
184780
|
-
if (dstHandle && (dst ===
|
|
184791
|
+
if (dstHandle && (dst === fs3 || dst.outgoing.state === "open"))
|
|
184781
184792
|
dst.close(dstHandle, cbfinal);
|
|
184782
184793
|
} else {
|
|
184783
184794
|
cb(err);
|
|
@@ -184793,7 +184804,7 @@ var require_SFTP = __commonJS((exports, module) => {
|
|
|
184793
184804
|
tryStat(null, { size: fileSize });
|
|
184794
184805
|
function tryStat(err2, attrs) {
|
|
184795
184806
|
if (err2) {
|
|
184796
|
-
if (src !==
|
|
184807
|
+
if (src !== fs3) {
|
|
184797
184808
|
src.stat(srcPath, (err_, attrs_) => {
|
|
184798
184809
|
if (err_)
|
|
184799
184810
|
return onerror(err2);
|
|
@@ -190365,23 +190376,23 @@ var require_crypto2 = __commonJS((exports) => {
|
|
|
190365
190376
|
var require_crypto3 = __commonJS((exports) => {
|
|
190366
190377
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
190367
190378
|
exports.NodeCrypto = undefined;
|
|
190368
|
-
var
|
|
190379
|
+
var crypto4 = __require("crypto");
|
|
190369
190380
|
|
|
190370
190381
|
class NodeCrypto {
|
|
190371
190382
|
async sha256DigestBase64(str) {
|
|
190372
|
-
return
|
|
190383
|
+
return crypto4.createHash("sha256").update(str).digest("base64");
|
|
190373
190384
|
}
|
|
190374
190385
|
randomBytesBase64(count) {
|
|
190375
|
-
return
|
|
190386
|
+
return crypto4.randomBytes(count).toString("base64");
|
|
190376
190387
|
}
|
|
190377
190388
|
async verify(pubkey, data, signature) {
|
|
190378
|
-
const verifier =
|
|
190389
|
+
const verifier = crypto4.createVerify("RSA-SHA256");
|
|
190379
190390
|
verifier.update(data);
|
|
190380
190391
|
verifier.end();
|
|
190381
190392
|
return verifier.verify(pubkey, signature, "base64");
|
|
190382
190393
|
}
|
|
190383
190394
|
async sign(privateKey, data) {
|
|
190384
|
-
const signer =
|
|
190395
|
+
const signer = crypto4.createSign("RSA-SHA256");
|
|
190385
190396
|
signer.update(data);
|
|
190386
190397
|
signer.end();
|
|
190387
190398
|
return signer.sign(privateKey, "base64");
|
|
@@ -190393,11 +190404,11 @@ var require_crypto3 = __commonJS((exports) => {
|
|
|
190393
190404
|
return Buffer.from(text, "utf-8").toString("base64");
|
|
190394
190405
|
}
|
|
190395
190406
|
async sha256DigestHex(str) {
|
|
190396
|
-
return
|
|
190407
|
+
return crypto4.createHash("sha256").update(str).digest("hex");
|
|
190397
190408
|
}
|
|
190398
190409
|
async signWithHmacSha256(key, msg) {
|
|
190399
190410
|
const cryptoKey = typeof key === "string" ? key : toBuffer(key);
|
|
190400
|
-
return toArrayBuffer(
|
|
190411
|
+
return toArrayBuffer(crypto4.createHmac("sha256", cryptoKey).update(msg).digest());
|
|
190401
190412
|
}
|
|
190402
190413
|
}
|
|
190403
190414
|
exports.NodeCrypto = NodeCrypto;
|
|
@@ -191013,10 +191024,10 @@ var require_oauth2client = __commonJS((exports) => {
|
|
|
191013
191024
|
throw new Error("generateCodeVerifier is removed, please use generateCodeVerifierAsync instead.");
|
|
191014
191025
|
}
|
|
191015
191026
|
async generateCodeVerifierAsync() {
|
|
191016
|
-
const
|
|
191017
|
-
const randomString =
|
|
191027
|
+
const crypto4 = (0, crypto_1.createCrypto)();
|
|
191028
|
+
const randomString = crypto4.randomBytesBase64(96);
|
|
191018
191029
|
const codeVerifier = randomString.replace(/\+/g, "~").replace(/=/g, "_").replace(/\//g, "-");
|
|
191019
|
-
const unencodedCodeChallenge = await
|
|
191030
|
+
const unencodedCodeChallenge = await crypto4.sha256DigestBase64(codeVerifier);
|
|
191020
191031
|
const codeChallenge = unencodedCodeChallenge.split("=")[0].replace(/\+/g, "-").replace(/\//g, "_");
|
|
191021
191032
|
return { codeVerifier, codeChallenge };
|
|
191022
191033
|
}
|
|
@@ -191418,7 +191429,7 @@ var require_oauth2client = __commonJS((exports) => {
|
|
|
191418
191429
|
throw new Error("verifySignedJwtWithCerts is removed, please use verifySignedJwtWithCertsAsync instead.");
|
|
191419
191430
|
}
|
|
191420
191431
|
async verifySignedJwtWithCertsAsync(jwt, certs, requiredAudience, issuers, maxExpiry) {
|
|
191421
|
-
const
|
|
191432
|
+
const crypto4 = (0, crypto_1.createCrypto)();
|
|
191422
191433
|
if (!maxExpiry) {
|
|
191423
191434
|
maxExpiry = OAuth2Client.DEFAULT_MAX_TOKEN_LIFETIME_SECS_;
|
|
191424
191435
|
}
|
|
@@ -191431,7 +191442,7 @@ var require_oauth2client = __commonJS((exports) => {
|
|
|
191431
191442
|
let envelope;
|
|
191432
191443
|
let payload;
|
|
191433
191444
|
try {
|
|
191434
|
-
envelope = JSON.parse(
|
|
191445
|
+
envelope = JSON.parse(crypto4.decodeBase64StringUtf8(segments[0]));
|
|
191435
191446
|
} catch (err) {
|
|
191436
191447
|
if (err instanceof Error) {
|
|
191437
191448
|
err.message = `Can't parse token envelope: ${segments[0]}': ${err.message}`;
|
|
@@ -191442,7 +191453,7 @@ var require_oauth2client = __commonJS((exports) => {
|
|
|
191442
191453
|
throw new Error("Can't parse token envelope: " + segments[0]);
|
|
191443
191454
|
}
|
|
191444
191455
|
try {
|
|
191445
|
-
payload = JSON.parse(
|
|
191456
|
+
payload = JSON.parse(crypto4.decodeBase64StringUtf8(segments[1]));
|
|
191446
191457
|
} catch (err) {
|
|
191447
191458
|
if (err instanceof Error) {
|
|
191448
191459
|
err.message = `Can't parse token payload '${segments[0]}`;
|
|
@@ -191459,7 +191470,7 @@ var require_oauth2client = __commonJS((exports) => {
|
|
|
191459
191470
|
if (envelope.alg === "ES256") {
|
|
191460
191471
|
signature = formatEcdsa.joseToDer(signature, "ES256").toString("base64");
|
|
191461
191472
|
}
|
|
191462
|
-
const verified = await
|
|
191473
|
+
const verified = await crypto4.verify(cert, signed, signature);
|
|
191463
191474
|
if (!verified) {
|
|
191464
191475
|
throw new Error("Invalid token signature: " + jwt);
|
|
191465
191476
|
}
|
|
@@ -191783,7 +191794,7 @@ var require_buffer_equal_constant_time = __commonJS((exports, module) => {
|
|
|
191783
191794
|
// ../../node_modules/jwa/index.js
|
|
191784
191795
|
var require_jwa = __commonJS((exports, module) => {
|
|
191785
191796
|
var Buffer2 = require_safe_buffer().Buffer;
|
|
191786
|
-
var
|
|
191797
|
+
var crypto4 = __require("crypto");
|
|
191787
191798
|
var formatEcdsa = require_ecdsa_sig_formatter();
|
|
191788
191799
|
var util5 = __require("util");
|
|
191789
191800
|
var MSG_INVALID_ALGORITHM = `"%s" is not a valid algorithm.
|
|
@@ -191792,7 +191803,7 @@ var require_jwa = __commonJS((exports, module) => {
|
|
|
191792
191803
|
var MSG_INVALID_SECRET = "secret must be a string or buffer";
|
|
191793
191804
|
var MSG_INVALID_VERIFIER_KEY = "key must be a string or a buffer";
|
|
191794
191805
|
var MSG_INVALID_SIGNER_KEY = "key must be a string, a buffer or an object";
|
|
191795
|
-
var supportsKeyObjects = typeof
|
|
191806
|
+
var supportsKeyObjects = typeof crypto4.createPublicKey === "function";
|
|
191796
191807
|
if (supportsKeyObjects) {
|
|
191797
191808
|
MSG_INVALID_VERIFIER_KEY += " or a KeyObject";
|
|
191798
191809
|
MSG_INVALID_SECRET += "or a KeyObject";
|
|
@@ -191882,17 +191893,17 @@ var require_jwa = __commonJS((exports, module) => {
|
|
|
191882
191893
|
return function sign(thing, secret) {
|
|
191883
191894
|
checkIsSecretKey(secret);
|
|
191884
191895
|
thing = normalizeInput(thing);
|
|
191885
|
-
var hmac =
|
|
191896
|
+
var hmac = crypto4.createHmac("sha" + bits, secret);
|
|
191886
191897
|
var sig = (hmac.update(thing), hmac.digest("base64"));
|
|
191887
191898
|
return fromBase64(sig);
|
|
191888
191899
|
};
|
|
191889
191900
|
}
|
|
191890
191901
|
var bufferEqual;
|
|
191891
|
-
var timingSafeEqual = "timingSafeEqual" in
|
|
191902
|
+
var timingSafeEqual = "timingSafeEqual" in crypto4 ? function timingSafeEqual2(a, b) {
|
|
191892
191903
|
if (a.byteLength !== b.byteLength) {
|
|
191893
191904
|
return false;
|
|
191894
191905
|
}
|
|
191895
|
-
return
|
|
191906
|
+
return crypto4.timingSafeEqual(a, b);
|
|
191896
191907
|
} : function timingSafeEqual2(a, b) {
|
|
191897
191908
|
if (!bufferEqual) {
|
|
191898
191909
|
bufferEqual = require_buffer_equal_constant_time();
|
|
@@ -191909,7 +191920,7 @@ var require_jwa = __commonJS((exports, module) => {
|
|
|
191909
191920
|
return function sign(thing, privateKey) {
|
|
191910
191921
|
checkIsPrivateKey(privateKey);
|
|
191911
191922
|
thing = normalizeInput(thing);
|
|
191912
|
-
var signer =
|
|
191923
|
+
var signer = crypto4.createSign("RSA-SHA" + bits);
|
|
191913
191924
|
var sig = (signer.update(thing), signer.sign(privateKey, "base64"));
|
|
191914
191925
|
return fromBase64(sig);
|
|
191915
191926
|
};
|
|
@@ -191919,7 +191930,7 @@ var require_jwa = __commonJS((exports, module) => {
|
|
|
191919
191930
|
checkIsPublicKey(publicKey);
|
|
191920
191931
|
thing = normalizeInput(thing);
|
|
191921
191932
|
signature = toBase64(signature);
|
|
191922
|
-
var verifier =
|
|
191933
|
+
var verifier = crypto4.createVerify("RSA-SHA" + bits);
|
|
191923
191934
|
verifier.update(thing);
|
|
191924
191935
|
return verifier.verify(publicKey, signature, "base64");
|
|
191925
191936
|
};
|
|
@@ -191928,11 +191939,11 @@ var require_jwa = __commonJS((exports, module) => {
|
|
|
191928
191939
|
return function sign(thing, privateKey) {
|
|
191929
191940
|
checkIsPrivateKey(privateKey);
|
|
191930
191941
|
thing = normalizeInput(thing);
|
|
191931
|
-
var signer =
|
|
191942
|
+
var signer = crypto4.createSign("RSA-SHA" + bits);
|
|
191932
191943
|
var sig = (signer.update(thing), signer.sign({
|
|
191933
191944
|
key: privateKey,
|
|
191934
|
-
padding:
|
|
191935
|
-
saltLength:
|
|
191945
|
+
padding: crypto4.constants.RSA_PKCS1_PSS_PADDING,
|
|
191946
|
+
saltLength: crypto4.constants.RSA_PSS_SALTLEN_DIGEST
|
|
191936
191947
|
}, "base64"));
|
|
191937
191948
|
return fromBase64(sig);
|
|
191938
191949
|
};
|
|
@@ -191942,12 +191953,12 @@ var require_jwa = __commonJS((exports, module) => {
|
|
|
191942
191953
|
checkIsPublicKey(publicKey);
|
|
191943
191954
|
thing = normalizeInput(thing);
|
|
191944
191955
|
signature = toBase64(signature);
|
|
191945
|
-
var verifier =
|
|
191956
|
+
var verifier = crypto4.createVerify("RSA-SHA" + bits);
|
|
191946
191957
|
verifier.update(thing);
|
|
191947
191958
|
return verifier.verify({
|
|
191948
191959
|
key: publicKey,
|
|
191949
|
-
padding:
|
|
191950
|
-
saltLength:
|
|
191960
|
+
padding: crypto4.constants.RSA_PKCS1_PSS_PADDING,
|
|
191961
|
+
saltLength: crypto4.constants.RSA_PSS_SALTLEN_DIGEST
|
|
191951
191962
|
}, signature, "base64");
|
|
191952
191963
|
};
|
|
191953
191964
|
}
|
|
@@ -192251,12 +192262,12 @@ var require_src113 = __commonJS((exports) => {
|
|
|
192251
192262
|
var _GoogleToken_requestToken;
|
|
192252
192263
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
192253
192264
|
exports.GoogleToken = undefined;
|
|
192254
|
-
var
|
|
192265
|
+
var fs4 = __require("fs");
|
|
192255
192266
|
var gaxios_1 = require_src53();
|
|
192256
192267
|
var jws = require_jws();
|
|
192257
192268
|
var path4 = __require("path");
|
|
192258
192269
|
var util_1 = __require("util");
|
|
192259
|
-
var readFile =
|
|
192270
|
+
var readFile = fs4.readFile ? (0, util_1.promisify)(fs4.readFile) : async () => {
|
|
192260
192271
|
throw new ErrorWithCode("use key rather than keyFile.", "MISSING_CREDENTIALS");
|
|
192261
192272
|
};
|
|
192262
192273
|
var GOOGLE_TOKEN_URL = "https://www.googleapis.com/oauth2/v4/token";
|
|
@@ -193469,10 +193480,10 @@ var require_filesubjecttokensupplier = __commonJS((exports) => {
|
|
|
193469
193480
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
193470
193481
|
exports.FileSubjectTokenSupplier = undefined;
|
|
193471
193482
|
var util_1 = __require("util");
|
|
193472
|
-
var
|
|
193473
|
-
var readFile = (0, util_1.promisify)((_a =
|
|
193474
|
-
var realpath = (0, util_1.promisify)((_b =
|
|
193475
|
-
var lstat = (0, util_1.promisify)((_c =
|
|
193483
|
+
var fs4 = __require("fs");
|
|
193484
|
+
var readFile = (0, util_1.promisify)((_a = fs4.readFile) !== null && _a !== undefined ? _a : () => {});
|
|
193485
|
+
var realpath = (0, util_1.promisify)((_b = fs4.realpath) !== null && _b !== undefined ? _b : () => {});
|
|
193486
|
+
var lstat = (0, util_1.promisify)((_c = fs4.lstat) !== null && _c !== undefined ? _c : () => {});
|
|
193476
193487
|
|
|
193477
193488
|
class FileSubjectTokenSupplier {
|
|
193478
193489
|
constructor(opts) {
|
|
@@ -193673,14 +193684,14 @@ var require_awsrequestsigner = __commonJS((exports) => {
|
|
|
193673
193684
|
}
|
|
193674
193685
|
}
|
|
193675
193686
|
exports.AwsRequestSigner = AwsRequestSigner;
|
|
193676
|
-
async function sign(
|
|
193677
|
-
return await
|
|
193678
|
-
}
|
|
193679
|
-
async function getSigningKey(
|
|
193680
|
-
const kDate = await sign(
|
|
193681
|
-
const kRegion = await sign(
|
|
193682
|
-
const kService = await sign(
|
|
193683
|
-
const kSigning = await sign(
|
|
193687
|
+
async function sign(crypto4, key, msg) {
|
|
193688
|
+
return await crypto4.signWithHmacSha256(key, msg);
|
|
193689
|
+
}
|
|
193690
|
+
async function getSigningKey(crypto4, key, dateStamp, region, serviceName) {
|
|
193691
|
+
const kDate = await sign(crypto4, `AWS4${key}`, dateStamp);
|
|
193692
|
+
const kRegion = await sign(crypto4, kDate, region);
|
|
193693
|
+
const kService = await sign(crypto4, kRegion, serviceName);
|
|
193694
|
+
const kSigning = await sign(crypto4, kService, "aws4_request");
|
|
193684
193695
|
return kSigning;
|
|
193685
193696
|
}
|
|
193686
193697
|
async function generateAuthenticationHeaderMap(options) {
|
|
@@ -194036,7 +194047,7 @@ var require_pluggable_auth_handler = __commonJS((exports) => {
|
|
|
194036
194047
|
var pluggable_auth_client_1 = require_pluggable_auth_client();
|
|
194037
194048
|
var executable_response_1 = require_executable_response();
|
|
194038
194049
|
var childProcess = __require("child_process");
|
|
194039
|
-
var
|
|
194050
|
+
var fs4 = __require("fs");
|
|
194040
194051
|
|
|
194041
194052
|
class PluggableAuthHandler {
|
|
194042
194053
|
constructor(options) {
|
|
@@ -194092,14 +194103,14 @@ var require_pluggable_auth_handler = __commonJS((exports) => {
|
|
|
194092
194103
|
}
|
|
194093
194104
|
let filePath;
|
|
194094
194105
|
try {
|
|
194095
|
-
filePath = await
|
|
194106
|
+
filePath = await fs4.promises.realpath(this.outputFile);
|
|
194096
194107
|
} catch (_a) {
|
|
194097
194108
|
return;
|
|
194098
194109
|
}
|
|
194099
|
-
if (!(await
|
|
194110
|
+
if (!(await fs4.promises.lstat(filePath)).isFile()) {
|
|
194100
194111
|
return;
|
|
194101
194112
|
}
|
|
194102
|
-
const responseString = await
|
|
194113
|
+
const responseString = await fs4.promises.readFile(filePath, {
|
|
194103
194114
|
encoding: "utf8"
|
|
194104
194115
|
});
|
|
194105
194116
|
if (responseString === "") {
|
|
@@ -194426,7 +194437,7 @@ var require_googleauth = __commonJS((exports) => {
|
|
|
194426
194437
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
194427
194438
|
exports.GoogleAuth = exports.GoogleAuthExceptionMessages = exports.CLOUD_SDK_CLIENT_ID = undefined;
|
|
194428
194439
|
var child_process_1 = __require("child_process");
|
|
194429
|
-
var
|
|
194440
|
+
var fs4 = __require("fs");
|
|
194430
194441
|
var gcpMetadata = require_src55();
|
|
194431
194442
|
var os2 = __require("os");
|
|
194432
194443
|
var path4 = __require("path");
|
|
@@ -194630,7 +194641,7 @@ var require_googleauth = __commonJS((exports) => {
|
|
|
194630
194641
|
}
|
|
194631
194642
|
if (location) {
|
|
194632
194643
|
location = path4.join(location, "gcloud", "application_default_credentials.json");
|
|
194633
|
-
if (!
|
|
194644
|
+
if (!fs4.existsSync(location)) {
|
|
194634
194645
|
location = null;
|
|
194635
194646
|
}
|
|
194636
194647
|
}
|
|
@@ -194645,8 +194656,8 @@ var require_googleauth = __commonJS((exports) => {
|
|
|
194645
194656
|
throw new Error("The file path is invalid.");
|
|
194646
194657
|
}
|
|
194647
194658
|
try {
|
|
194648
|
-
filePath =
|
|
194649
|
-
if (!
|
|
194659
|
+
filePath = fs4.realpathSync(filePath);
|
|
194660
|
+
if (!fs4.lstatSync(filePath).isFile()) {
|
|
194650
194661
|
throw new Error;
|
|
194651
194662
|
}
|
|
194652
194663
|
} catch (err) {
|
|
@@ -194655,7 +194666,7 @@ var require_googleauth = __commonJS((exports) => {
|
|
|
194655
194666
|
}
|
|
194656
194667
|
throw err;
|
|
194657
194668
|
}
|
|
194658
|
-
const readStream2 =
|
|
194669
|
+
const readStream2 = fs4.createReadStream(filePath);
|
|
194659
194670
|
return this.fromStream(readStream2, options);
|
|
194660
194671
|
}
|
|
194661
194672
|
fromImpersonatedJSON(json) {
|
|
@@ -194907,24 +194918,24 @@ var require_googleauth = __commonJS((exports) => {
|
|
|
194907
194918
|
const signed = await client.sign(data);
|
|
194908
194919
|
return signed.signedBlob;
|
|
194909
194920
|
}
|
|
194910
|
-
const
|
|
194921
|
+
const crypto4 = (0, crypto_1.createCrypto)();
|
|
194911
194922
|
if (client instanceof jwtclient_1.JWT && client.key) {
|
|
194912
|
-
const sign = await
|
|
194923
|
+
const sign = await crypto4.sign(client.key, data);
|
|
194913
194924
|
return sign;
|
|
194914
194925
|
}
|
|
194915
194926
|
const creds = await this.getCredentials();
|
|
194916
194927
|
if (!creds.client_email) {
|
|
194917
194928
|
throw new Error("Cannot sign data without `client_email`.");
|
|
194918
194929
|
}
|
|
194919
|
-
return this.signBlob(
|
|
194930
|
+
return this.signBlob(crypto4, creds.client_email, data, endpoint);
|
|
194920
194931
|
}
|
|
194921
|
-
async signBlob(
|
|
194932
|
+
async signBlob(crypto4, emailOrUniqueId, data, endpoint) {
|
|
194922
194933
|
const url2 = new URL(endpoint + `${emailOrUniqueId}:signBlob`);
|
|
194923
194934
|
const res = await this.request({
|
|
194924
194935
|
method: "POST",
|
|
194925
194936
|
url: url2.href,
|
|
194926
194937
|
data: {
|
|
194927
|
-
payload:
|
|
194938
|
+
payload: crypto4.encodeBase64StringUtf8(data)
|
|
194928
194939
|
},
|
|
194929
194940
|
retry: true,
|
|
194930
194941
|
retryConfig: {
|
|
@@ -194947,7 +194958,7 @@ var require_googleauth = __commonJS((exports) => {
|
|
|
194947
194958
|
return this._cacheClientFromJSON(this.jsonContent, this.clientOptions);
|
|
194948
194959
|
} else if (this.keyFilename) {
|
|
194949
194960
|
const filePath = path4.resolve(this.keyFilename);
|
|
194950
|
-
const stream4 =
|
|
194961
|
+
const stream4 = fs4.createReadStream(filePath);
|
|
194951
194962
|
return await this.fromStreamAsync(stream4, this.clientOptions);
|
|
194952
194963
|
} else if (this.apiKey) {
|
|
194953
194964
|
const client = await this.fromAPIKey(this.apiKey, this.clientOptions);
|
|
@@ -202701,7 +202712,7 @@ var require_table = __commonJS((exports) => {
|
|
|
202701
202712
|
var Big = require_big();
|
|
202702
202713
|
var extend2 = require_extend();
|
|
202703
202714
|
var events_1 = __require("events");
|
|
202704
|
-
var
|
|
202715
|
+
var fs4 = __require("fs");
|
|
202705
202716
|
var is = require_is();
|
|
202706
202717
|
var path4 = __require("path");
|
|
202707
202718
|
var streamEvents = require_stream_events();
|
|
@@ -203011,7 +203022,7 @@ var require_table = __commonJS((exports) => {
|
|
|
203011
203022
|
if (!metadata.sourceFormat && detectedFormat) {
|
|
203012
203023
|
metadata.sourceFormat = detectedFormat;
|
|
203013
203024
|
}
|
|
203014
|
-
const jobWritable =
|
|
203025
|
+
const jobWritable = fs4.createReadStream(source).pipe(this.createWriteStream_(metadata));
|
|
203015
203026
|
const [jobResponse] = await (0, events_1.once)(jobWritable, "job");
|
|
203016
203027
|
return [jobResponse, jobResponse.metadata];
|
|
203017
203028
|
}
|
|
@@ -208811,11 +208822,11 @@ var require_randomUUID = __commonJS((exports) => {
|
|
|
208811
208822
|
|
|
208812
208823
|
// ../../node_modules/@smithy/uuid/dist-cjs/index.js
|
|
208813
208824
|
var require_dist_cjs16 = __commonJS((exports) => {
|
|
208814
|
-
var
|
|
208825
|
+
var randomUUID3 = require_randomUUID();
|
|
208815
208826
|
var decimalToHex = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
208816
208827
|
var v4 = () => {
|
|
208817
|
-
if (
|
|
208818
|
-
return
|
|
208828
|
+
if (randomUUID3.randomUUID) {
|
|
208829
|
+
return randomUUID3.randomUUID();
|
|
208819
208830
|
}
|
|
208820
208831
|
const rnds = new Uint8Array(16);
|
|
208821
208832
|
crypto.getRandomValues(rnds);
|
|
@@ -223136,7 +223147,7 @@ var require_dist_cjs55 = __commonJS((exports) => {
|
|
|
223136
223147
|
var utilBufferFrom = require_dist_cjs7();
|
|
223137
223148
|
var utilUtf8 = require_dist_cjs8();
|
|
223138
223149
|
var buffer = __require("buffer");
|
|
223139
|
-
var
|
|
223150
|
+
var crypto4 = __require("crypto");
|
|
223140
223151
|
|
|
223141
223152
|
class Hash {
|
|
223142
223153
|
algorithmIdentifier;
|
|
@@ -223154,7 +223165,7 @@ var require_dist_cjs55 = __commonJS((exports) => {
|
|
|
223154
223165
|
return Promise.resolve(this.hash.digest());
|
|
223155
223166
|
}
|
|
223156
223167
|
reset() {
|
|
223157
|
-
this.hash = this.secret ?
|
|
223168
|
+
this.hash = this.secret ? crypto4.createHmac(this.algorithmIdentifier, castSourceData(this.secret)) : crypto4.createHash(this.algorithmIdentifier);
|
|
223158
223169
|
}
|
|
223159
223170
|
}
|
|
223160
223171
|
function castSourceData(toCast, encoding) {
|
|
@@ -225794,7 +225805,7 @@ var require_dist_cjs59 = __commonJS((exports) => {
|
|
|
225794
225805
|
var httpAuthSchemes = require_httpAuthSchemes();
|
|
225795
225806
|
var propertyProvider = require_dist_cjs18();
|
|
225796
225807
|
var sharedIniFileLoader = require_dist_cjs44();
|
|
225797
|
-
var
|
|
225808
|
+
var fs4 = __require("fs");
|
|
225798
225809
|
var fromEnvSigningName = ({ logger: logger2, signingName } = {}) => async () => {
|
|
225799
225810
|
logger2?.debug?.("@aws-sdk/token-providers - fromEnvSigningName");
|
|
225800
225811
|
if (!signingName) {
|
|
@@ -225840,7 +225851,7 @@ var require_dist_cjs59 = __commonJS((exports) => {
|
|
|
225840
225851
|
throw new propertyProvider.TokenProviderError(`Value not present for '${key}' in SSO Token${forRefresh ? ". Cannot refresh" : ""}. ${REFRESH_MESSAGE}`, false);
|
|
225841
225852
|
}
|
|
225842
225853
|
};
|
|
225843
|
-
var { writeFile } =
|
|
225854
|
+
var { writeFile } = fs4.promises;
|
|
225844
225855
|
var writeSSOTokenToFile = (id, ssoToken) => {
|
|
225845
225856
|
const tokenFilepath = sharedIniFileLoader.getSSOTokenFilepath(id);
|
|
225846
225857
|
const tokenString = JSON.stringify(ssoToken, null, 2);
|
|
@@ -230052,7 +230063,7 @@ var require_dist_cjs70 = __commonJS((exports) => {
|
|
|
230052
230063
|
|
|
230053
230064
|
// ../../node_modules/@smithy/hash-stream-node/dist-cjs/index.js
|
|
230054
230065
|
var require_dist_cjs71 = __commonJS((exports) => {
|
|
230055
|
-
var
|
|
230066
|
+
var fs4 = __require("fs");
|
|
230056
230067
|
var utilUtf8 = require_dist_cjs8();
|
|
230057
230068
|
var stream4 = __require("stream");
|
|
230058
230069
|
|
|
@@ -230076,7 +230087,7 @@ var require_dist_cjs71 = __commonJS((exports) => {
|
|
|
230076
230087
|
reject(new Error("Unable to calculate hash for non-file streams."));
|
|
230077
230088
|
return;
|
|
230078
230089
|
}
|
|
230079
|
-
const fileStreamTee =
|
|
230090
|
+
const fileStreamTee = fs4.createReadStream(fileStream.path, {
|
|
230080
230091
|
start: fileStream.start,
|
|
230081
230092
|
end: fileStream.end
|
|
230082
230093
|
});
|
|
@@ -238485,9 +238496,9 @@ var require_dist11 = __commonJS((exports) => {
|
|
|
238485
238496
|
var require_pump = __commonJS((exports, module) => {
|
|
238486
238497
|
var once = require_once2();
|
|
238487
238498
|
var eos = require_end_of_stream4();
|
|
238488
|
-
var
|
|
238499
|
+
var fs4;
|
|
238489
238500
|
try {
|
|
238490
|
-
|
|
238501
|
+
fs4 = __require("fs");
|
|
238491
238502
|
} catch (e) {}
|
|
238492
238503
|
var noop2 = function() {};
|
|
238493
238504
|
var ancient = typeof process === "undefined" ? false : /^v?\.0/.test(process.version);
|
|
@@ -238497,9 +238508,9 @@ var require_pump = __commonJS((exports, module) => {
|
|
|
238497
238508
|
var isFS = function(stream4) {
|
|
238498
238509
|
if (!ancient)
|
|
238499
238510
|
return false;
|
|
238500
|
-
if (!
|
|
238511
|
+
if (!fs4)
|
|
238501
238512
|
return false;
|
|
238502
|
-
return (stream4 instanceof (
|
|
238513
|
+
return (stream4 instanceof (fs4.ReadStream || noop2) || stream4 instanceof (fs4.WriteStream || noop2)) && isFn(stream4.close);
|
|
238503
238514
|
};
|
|
238504
238515
|
var isRequest2 = function(stream4) {
|
|
238505
238516
|
return stream4.setHeader && isFn(stream4.abort);
|
|
@@ -238714,7 +238725,7 @@ var require_pend = __commonJS((exports, module) => {
|
|
|
238714
238725
|
|
|
238715
238726
|
// ../../node_modules/fd-slicer/index.js
|
|
238716
238727
|
var require_fd_slicer = __commonJS((exports) => {
|
|
238717
|
-
var
|
|
238728
|
+
var fs4 = __require("fs");
|
|
238718
238729
|
var util5 = __require("util");
|
|
238719
238730
|
var stream4 = __require("stream");
|
|
238720
238731
|
var Readable3 = stream4.Readable;
|
|
@@ -238739,7 +238750,7 @@ var require_fd_slicer = __commonJS((exports) => {
|
|
|
238739
238750
|
FdSlicer.prototype.read = function(buffer, offset, length, position, callback) {
|
|
238740
238751
|
var self2 = this;
|
|
238741
238752
|
self2.pend.go(function(cb) {
|
|
238742
|
-
|
|
238753
|
+
fs4.read(self2.fd, buffer, offset, length, position, function(err, bytesRead, buffer2) {
|
|
238743
238754
|
cb();
|
|
238744
238755
|
callback(err, bytesRead, buffer2);
|
|
238745
238756
|
});
|
|
@@ -238748,7 +238759,7 @@ var require_fd_slicer = __commonJS((exports) => {
|
|
|
238748
238759
|
FdSlicer.prototype.write = function(buffer, offset, length, position, callback) {
|
|
238749
238760
|
var self2 = this;
|
|
238750
238761
|
self2.pend.go(function(cb) {
|
|
238751
|
-
|
|
238762
|
+
fs4.write(self2.fd, buffer, offset, length, position, function(err, written, buffer2) {
|
|
238752
238763
|
cb();
|
|
238753
238764
|
callback(err, written, buffer2);
|
|
238754
238765
|
});
|
|
@@ -238771,7 +238782,7 @@ var require_fd_slicer = __commonJS((exports) => {
|
|
|
238771
238782
|
if (self2.refCount < 0)
|
|
238772
238783
|
throw new Error("invalid unref");
|
|
238773
238784
|
if (self2.autoClose) {
|
|
238774
|
-
|
|
238785
|
+
fs4.close(self2.fd, onCloseDone);
|
|
238775
238786
|
}
|
|
238776
238787
|
function onCloseDone(err) {
|
|
238777
238788
|
if (err) {
|
|
@@ -238810,7 +238821,7 @@ var require_fd_slicer = __commonJS((exports) => {
|
|
|
238810
238821
|
if (self2.destroyed)
|
|
238811
238822
|
return cb();
|
|
238812
238823
|
var buffer = new Buffer(toRead);
|
|
238813
|
-
|
|
238824
|
+
fs4.read(self2.context.fd, buffer, 0, toRead, self2.pos, function(err, bytesRead) {
|
|
238814
238825
|
if (err) {
|
|
238815
238826
|
self2.destroy(err);
|
|
238816
238827
|
} else if (bytesRead === 0) {
|
|
@@ -238860,7 +238871,7 @@ var require_fd_slicer = __commonJS((exports) => {
|
|
|
238860
238871
|
self2.context.pend.go(function(cb) {
|
|
238861
238872
|
if (self2.destroyed)
|
|
238862
238873
|
return cb();
|
|
238863
|
-
|
|
238874
|
+
fs4.write(self2.context.fd, buffer, 0, buffer.length, self2.pos, function(err2, bytes) {
|
|
238864
238875
|
if (err2) {
|
|
238865
238876
|
self2.destroy();
|
|
238866
238877
|
cb();
|
|
@@ -239285,7 +239296,7 @@ var require_buffer_crc32 = __commonJS((exports, module) => {
|
|
|
239285
239296
|
|
|
239286
239297
|
// ../../node_modules/yauzl/index.js
|
|
239287
239298
|
var require_yauzl = __commonJS((exports) => {
|
|
239288
|
-
var
|
|
239299
|
+
var fs4 = __require("fs");
|
|
239289
239300
|
var zlib2 = __require("zlib");
|
|
239290
239301
|
var fd_slicer = require_fd_slicer();
|
|
239291
239302
|
var crc32 = require_buffer_crc32();
|
|
@@ -239322,12 +239333,12 @@ var require_yauzl = __commonJS((exports) => {
|
|
|
239322
239333
|
options.strictFileNames = false;
|
|
239323
239334
|
if (callback == null)
|
|
239324
239335
|
callback = defaultCallback;
|
|
239325
|
-
|
|
239336
|
+
fs4.open(path4, "r", function(err, fd) {
|
|
239326
239337
|
if (err)
|
|
239327
239338
|
return callback(err);
|
|
239328
239339
|
fromFd(fd, options, function(err2, zipfile) {
|
|
239329
239340
|
if (err2)
|
|
239330
|
-
|
|
239341
|
+
fs4.close(fd, defaultCallback);
|
|
239331
239342
|
callback(err2, zipfile);
|
|
239332
239343
|
});
|
|
239333
239344
|
});
|
|
@@ -239351,7 +239362,7 @@ var require_yauzl = __commonJS((exports) => {
|
|
|
239351
239362
|
options.strictFileNames = false;
|
|
239352
239363
|
if (callback == null)
|
|
239353
239364
|
callback = defaultCallback;
|
|
239354
|
-
|
|
239365
|
+
fs4.fstat(fd, function(err, stats) {
|
|
239355
239366
|
if (err)
|
|
239356
239367
|
return callback(err);
|
|
239357
239368
|
var reader = fd_slicer.createFromFd(fd, { autoClose: true });
|
|
@@ -239984,7 +239995,7 @@ var require_yauzl = __commonJS((exports) => {
|
|
|
239984
239995
|
// ../../node_modules/extract-zip/index.js
|
|
239985
239996
|
var require_extract_zip = __commonJS((exports, module) => {
|
|
239986
239997
|
var debug = require_src5()("extract-zip");
|
|
239987
|
-
var { createWriteStream, promises:
|
|
239998
|
+
var { createWriteStream, promises: fs4 } = __require("fs");
|
|
239988
239999
|
var getStream = require_get_stream();
|
|
239989
240000
|
var path4 = __require("path");
|
|
239990
240001
|
var { promisify } = __require("util");
|
|
@@ -240026,8 +240037,8 @@ var require_extract_zip = __commonJS((exports, module) => {
|
|
|
240026
240037
|
}
|
|
240027
240038
|
const destDir = path4.dirname(path4.join(this.opts.dir, entry.fileName));
|
|
240028
240039
|
try {
|
|
240029
|
-
await
|
|
240030
|
-
const canonicalDestDir = await
|
|
240040
|
+
await fs4.mkdir(destDir, { recursive: true });
|
|
240041
|
+
const canonicalDestDir = await fs4.realpath(destDir);
|
|
240031
240042
|
const relativeDestDir = path4.relative(this.opts.dir, canonicalDestDir);
|
|
240032
240043
|
if (relativeDestDir.split(path4.sep).includes("..")) {
|
|
240033
240044
|
throw new Error(`Out of bound path "${canonicalDestDir}" found while processing file ${entry.fileName}`);
|
|
@@ -240072,7 +240083,7 @@ var require_extract_zip = __commonJS((exports, module) => {
|
|
|
240072
240083
|
mkdirOptions.mode = procMode;
|
|
240073
240084
|
}
|
|
240074
240085
|
debug("mkdir", { dir: destDir, ...mkdirOptions });
|
|
240075
|
-
await
|
|
240086
|
+
await fs4.mkdir(destDir, mkdirOptions);
|
|
240076
240087
|
if (isDir)
|
|
240077
240088
|
return;
|
|
240078
240089
|
debug("opening read stream", dest);
|
|
@@ -240080,7 +240091,7 @@ var require_extract_zip = __commonJS((exports, module) => {
|
|
|
240080
240091
|
if (symlink) {
|
|
240081
240092
|
const link = await getStream(readStream2);
|
|
240082
240093
|
debug("creating symlink", link, dest);
|
|
240083
|
-
await
|
|
240094
|
+
await fs4.symlink(link, dest);
|
|
240084
240095
|
} else {
|
|
240085
240096
|
await pipeline(readStream2, createWriteStream(dest, { mode: procMode }));
|
|
240086
240097
|
}
|
|
@@ -240112,8 +240123,8 @@ var require_extract_zip = __commonJS((exports, module) => {
|
|
|
240112
240123
|
if (!path4.isAbsolute(opts.dir)) {
|
|
240113
240124
|
throw new Error("Target directory is expected to be absolute");
|
|
240114
240125
|
}
|
|
240115
|
-
await
|
|
240116
|
-
opts.dir = await
|
|
240126
|
+
await fs4.mkdir(opts.dir, { recursive: true });
|
|
240127
|
+
opts.dir = await fs4.realpath(opts.dir);
|
|
240117
240128
|
return new Extractor(zipPath, opts).extract();
|
|
240118
240129
|
};
|
|
240119
240130
|
});
|
|
@@ -240697,10 +240708,10 @@ var init_package_load_pool = __esm(() => {
|
|
|
240697
240708
|
})();
|
|
240698
240709
|
defaultUrlReader = {
|
|
240699
240710
|
readURL: async (url2) => {
|
|
240700
|
-
const { promises:
|
|
240711
|
+
const { promises: fs4 } = await import("fs");
|
|
240701
240712
|
const { fileURLToPath: fileURLToPath3 } = await import("url");
|
|
240702
240713
|
const filePath = url2.protocol === "file:" ? fileURLToPath3(url2) : url2.toString();
|
|
240703
|
-
return
|
|
240714
|
+
return fs4.readFile(filePath, "utf8");
|
|
240704
240715
|
}
|
|
240705
240716
|
};
|
|
240706
240717
|
});
|
|
@@ -241511,7 +241522,7 @@ globstar while`, file, fr, pattern, pr, swallowee);
|
|
|
241511
241522
|
|
|
241512
241523
|
// ../../node_modules/recursive-readdir/index.js
|
|
241513
241524
|
var require_recursive_readdir = __commonJS((exports, module) => {
|
|
241514
|
-
var
|
|
241525
|
+
var fs6 = __require("fs");
|
|
241515
241526
|
var p = __require("path");
|
|
241516
241527
|
var minimatch = require_minimatch();
|
|
241517
241528
|
function patternMatcher(pattern) {
|
|
@@ -241545,7 +241556,7 @@ var require_recursive_readdir = __commonJS((exports, module) => {
|
|
|
241545
241556
|
}
|
|
241546
241557
|
ignores = ignores.map(toMatcherFunction);
|
|
241547
241558
|
var list = [];
|
|
241548
|
-
|
|
241559
|
+
fs6.readdir(path6, function(err, files) {
|
|
241549
241560
|
if (err) {
|
|
241550
241561
|
return callback(err);
|
|
241551
241562
|
}
|
|
@@ -241555,7 +241566,7 @@ var require_recursive_readdir = __commonJS((exports, module) => {
|
|
|
241555
241566
|
}
|
|
241556
241567
|
files.forEach(function(file) {
|
|
241557
241568
|
var filePath = p.join(path6, file);
|
|
241558
|
-
|
|
241569
|
+
fs6.stat(filePath, function(_err, stats) {
|
|
241559
241570
|
if (_err) {
|
|
241560
241571
|
return callback(_err);
|
|
241561
241572
|
}
|
|
@@ -241883,7 +241894,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
241883
241894
|
}
|
|
241884
241895
|
}
|
|
241885
241896
|
|
|
241886
|
-
class
|
|
241897
|
+
class InvalidArgumentError2 extends LuxonError {
|
|
241887
241898
|
}
|
|
241888
241899
|
|
|
241889
241900
|
class ZoneIsAbstractError extends LuxonError {
|
|
@@ -243226,10 +243237,10 @@ var require_luxon = __commonJS((exports) => {
|
|
|
243226
243237
|
if (settings == null) {
|
|
243227
243238
|
return null;
|
|
243228
243239
|
} else if (typeof settings !== "object") {
|
|
243229
|
-
throw new
|
|
243240
|
+
throw new InvalidArgumentError2("Week settings must be an object");
|
|
243230
243241
|
} else {
|
|
243231
243242
|
if (!integerBetween(settings.firstDay, 1, 7) || !integerBetween(settings.minimalDays, 1, 7) || !Array.isArray(settings.weekend) || settings.weekend.some((v) => !integerBetween(v, 1, 7))) {
|
|
243232
|
-
throw new
|
|
243243
|
+
throw new InvalidArgumentError2("Invalid week settings");
|
|
243233
243244
|
}
|
|
243234
243245
|
return {
|
|
243235
243246
|
firstDay: settings.firstDay,
|
|
@@ -243360,7 +243371,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
243360
243371
|
function asNumber2(value) {
|
|
243361
243372
|
const numericValue = Number(value);
|
|
243362
243373
|
if (typeof value === "boolean" || value === "" || !Number.isFinite(numericValue))
|
|
243363
|
-
throw new
|
|
243374
|
+
throw new InvalidArgumentError2(`Invalid unit value ${value}`);
|
|
243364
243375
|
return numericValue;
|
|
243365
243376
|
}
|
|
243366
243377
|
function normalizeObject(obj, normalizer) {
|
|
@@ -244206,7 +244217,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
244206
244217
|
}
|
|
244207
244218
|
static fromObject(obj, opts = {}) {
|
|
244208
244219
|
if (obj == null || typeof obj !== "object") {
|
|
244209
|
-
throw new
|
|
244220
|
+
throw new InvalidArgumentError2(`Duration.fromObject: argument expected to be an object, got ${obj === null ? "null" : typeof obj}`);
|
|
244210
244221
|
}
|
|
244211
244222
|
return new Duration({
|
|
244212
244223
|
values: normalizeObject(obj, Duration.normalizeUnit),
|
|
@@ -244223,7 +244234,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
244223
244234
|
} else if (typeof durationLike === "object") {
|
|
244224
244235
|
return Duration.fromObject(durationLike);
|
|
244225
244236
|
} else {
|
|
244226
|
-
throw new
|
|
244237
|
+
throw new InvalidArgumentError2(`Unknown duration argument ${durationLike} of type ${typeof durationLike}`);
|
|
244227
244238
|
}
|
|
244228
244239
|
}
|
|
244229
244240
|
static fromISO(text, opts) {
|
|
@@ -244244,7 +244255,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
244244
244255
|
}
|
|
244245
244256
|
static invalid(reason, explanation = null) {
|
|
244246
244257
|
if (!reason) {
|
|
244247
|
-
throw new
|
|
244258
|
+
throw new InvalidArgumentError2("need to specify a reason the Duration is invalid");
|
|
244248
244259
|
}
|
|
244249
244260
|
const invalid = reason instanceof Invalid ? reason : new Invalid(reason, explanation);
|
|
244250
244261
|
if (Settings.throwOnInvalid) {
|
|
@@ -244609,7 +244620,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
244609
244620
|
}
|
|
244610
244621
|
static invalid(reason, explanation = null) {
|
|
244611
244622
|
if (!reason) {
|
|
244612
|
-
throw new
|
|
244623
|
+
throw new InvalidArgumentError2("need to specify a reason the Interval is invalid");
|
|
244613
244624
|
}
|
|
244614
244625
|
const invalid = reason instanceof Invalid ? reason : new Invalid(reason, explanation);
|
|
244615
244626
|
if (Settings.throwOnInvalid) {
|
|
@@ -245880,7 +245891,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
245880
245891
|
}
|
|
245881
245892
|
static fromMillis(milliseconds, options = {}) {
|
|
245882
245893
|
if (!isNumber2(milliseconds)) {
|
|
245883
|
-
throw new
|
|
245894
|
+
throw new InvalidArgumentError2(`fromMillis requires a numerical input, but received a ${typeof milliseconds} with value ${milliseconds}`);
|
|
245884
245895
|
} else if (milliseconds < -MAX_DATE || milliseconds > MAX_DATE) {
|
|
245885
245896
|
return DateTime.invalid("Timestamp out of range");
|
|
245886
245897
|
} else {
|
|
@@ -245893,7 +245904,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
245893
245904
|
}
|
|
245894
245905
|
static fromSeconds(seconds, options = {}) {
|
|
245895
245906
|
if (!isNumber2(seconds)) {
|
|
245896
|
-
throw new
|
|
245907
|
+
throw new InvalidArgumentError2("fromSeconds requires a numerical input");
|
|
245897
245908
|
} else {
|
|
245898
245909
|
return new DateTime({
|
|
245899
245910
|
ts: seconds * 1000,
|
|
@@ -245978,7 +245989,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
245978
245989
|
}
|
|
245979
245990
|
static fromFormat(text, fmt, opts = {}) {
|
|
245980
245991
|
if (isUndefined2(text) || isUndefined2(fmt)) {
|
|
245981
|
-
throw new
|
|
245992
|
+
throw new InvalidArgumentError2("fromFormat requires an input string and a format");
|
|
245982
245993
|
}
|
|
245983
245994
|
const {
|
|
245984
245995
|
locale = null,
|
|
@@ -246003,7 +246014,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
246003
246014
|
}
|
|
246004
246015
|
static invalid(reason, explanation = null) {
|
|
246005
246016
|
if (!reason) {
|
|
246006
|
-
throw new
|
|
246017
|
+
throw new InvalidArgumentError2("need to specify a reason the DateTime is invalid");
|
|
246007
246018
|
}
|
|
246008
246019
|
const invalid = reason instanceof Invalid ? reason : new Invalid(reason, explanation);
|
|
246009
246020
|
if (Settings.throwOnInvalid) {
|
|
@@ -246563,13 +246574,13 @@ var require_luxon = __commonJS((exports) => {
|
|
|
246563
246574
|
}
|
|
246564
246575
|
static min(...dateTimes) {
|
|
246565
246576
|
if (!dateTimes.every(DateTime.isDateTime)) {
|
|
246566
|
-
throw new
|
|
246577
|
+
throw new InvalidArgumentError2("min requires all arguments be DateTimes");
|
|
246567
246578
|
}
|
|
246568
246579
|
return bestBy(dateTimes, (i) => i.valueOf(), Math.min);
|
|
246569
246580
|
}
|
|
246570
246581
|
static max(...dateTimes) {
|
|
246571
246582
|
if (!dateTimes.every(DateTime.isDateTime)) {
|
|
246572
|
-
throw new
|
|
246583
|
+
throw new InvalidArgumentError2("max requires all arguments be DateTimes");
|
|
246573
246584
|
}
|
|
246574
246585
|
return bestBy(dateTimes, (i) => i.valueOf(), Math.max);
|
|
246575
246586
|
}
|
|
@@ -246600,7 +246611,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
246600
246611
|
}
|
|
246601
246612
|
static fromFormatParser(text, formatParser, opts = {}) {
|
|
246602
246613
|
if (isUndefined2(text) || isUndefined2(formatParser)) {
|
|
246603
|
-
throw new
|
|
246614
|
+
throw new InvalidArgumentError2("fromFormatParser requires an input string and a format parser");
|
|
246604
246615
|
}
|
|
246605
246616
|
const {
|
|
246606
246617
|
locale = null,
|
|
@@ -246611,7 +246622,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
246611
246622
|
defaultToEN: true
|
|
246612
246623
|
});
|
|
246613
246624
|
if (!localeToUse.equals(formatParser.locale)) {
|
|
246614
|
-
throw new
|
|
246625
|
+
throw new InvalidArgumentError2(`fromFormatParser called with a locale of ${localeToUse}, ` + `but the format parser was created for ${formatParser.locale}`);
|
|
246615
246626
|
}
|
|
246616
246627
|
const {
|
|
246617
246628
|
result,
|
|
@@ -246700,7 +246711,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
246700
246711
|
} else if (dateTimeish && typeof dateTimeish === "object") {
|
|
246701
246712
|
return DateTime.fromObject(dateTimeish);
|
|
246702
246713
|
} else {
|
|
246703
|
-
throw new
|
|
246714
|
+
throw new InvalidArgumentError2(`Unknown datetime argument: ${dateTimeish}, of type ${typeof dateTimeish}`);
|
|
246704
246715
|
}
|
|
246705
246716
|
}
|
|
246706
246717
|
var VERSION3 = "3.7.2";
|
|
@@ -256141,6 +256152,68 @@ function httpMetricsMiddleware(req, res, next) {
|
|
|
256141
256152
|
next();
|
|
256142
256153
|
}
|
|
256143
256154
|
|
|
256155
|
+
// src/node_version_check.ts
|
|
256156
|
+
import * as fs from "node:fs";
|
|
256157
|
+
var MIN_NODE_MAJOR = 20;
|
|
256158
|
+
var REQUIRED_NODE_RANGE = `>=${MIN_NODE_MAJOR}`;
|
|
256159
|
+
var UNSUPPORTED_NODE_TOKEN = "PUBLISHER_UNSUPPORTED_NODE";
|
|
256160
|
+
function evaluateRuntime(versions) {
|
|
256161
|
+
if (versions.bunVersion) {
|
|
256162
|
+
return { supported: true };
|
|
256163
|
+
}
|
|
256164
|
+
const major = parseMajor(versions.nodeVersion);
|
|
256165
|
+
if (major === undefined || major >= MIN_NODE_MAJOR) {
|
|
256166
|
+
return { supported: true };
|
|
256167
|
+
}
|
|
256168
|
+
return {
|
|
256169
|
+
supported: false,
|
|
256170
|
+
message: unsupportedMessage(versions.nodeVersion)
|
|
256171
|
+
};
|
|
256172
|
+
}
|
|
256173
|
+
function parseMajor(version) {
|
|
256174
|
+
const match = /^v?(\d+)\./.exec(version.trim());
|
|
256175
|
+
if (!match) {
|
|
256176
|
+
return;
|
|
256177
|
+
}
|
|
256178
|
+
const major = Number(match[1]);
|
|
256179
|
+
return Number.isFinite(major) ? major : undefined;
|
|
256180
|
+
}
|
|
256181
|
+
function unsupportedMessage(nodeVersion) {
|
|
256182
|
+
return [
|
|
256183
|
+
`${UNSUPPORTED_NODE_TOKEN} required=${REQUIRED_NODE_RANGE} detected=${nodeVersion}`,
|
|
256184
|
+
`Malloy Publisher supports Node.js ${MIN_NODE_MAJOR} and newer, but this process is running Node.js ${nodeVersion}.`,
|
|
256185
|
+
`Publisher and every Malloy library it depends on declare Node ${REQUIRED_NODE_RANGE}, so this`,
|
|
256186
|
+
`runtime is untested and has failed in ways that never mention Node.`,
|
|
256187
|
+
`Upgrade Node, then run the command again: https://nodejs.org`,
|
|
256188
|
+
` nvm: nvm install ${MIN_NODE_MAJOR} && nvm use ${MIN_NODE_MAJOR}`,
|
|
256189
|
+
` mise: mise use -g node@${MIN_NODE_MAJOR} then open a new shell before running the command`,
|
|
256190
|
+
""
|
|
256191
|
+
].join(`
|
|
256192
|
+
`);
|
|
256193
|
+
}
|
|
256194
|
+
function assertSupportedNodeVersion(options = {}) {
|
|
256195
|
+
const verdict = evaluateRuntime({
|
|
256196
|
+
nodeVersion: "nodeVersion" in options && options.nodeVersion !== undefined ? options.nodeVersion : process.version,
|
|
256197
|
+
bunVersion: "bunVersion" in options ? options.bunVersion : process.versions.bun
|
|
256198
|
+
});
|
|
256199
|
+
if (verdict.supported) {
|
|
256200
|
+
return;
|
|
256201
|
+
}
|
|
256202
|
+
const write = options.write ?? writeToStderr;
|
|
256203
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
256204
|
+
try {
|
|
256205
|
+
write(verdict.message ?? "");
|
|
256206
|
+
} catch {}
|
|
256207
|
+
exit(1);
|
|
256208
|
+
}
|
|
256209
|
+
function writeToStderr(text) {
|
|
256210
|
+
try {
|
|
256211
|
+
fs.writeSync(2, text);
|
|
256212
|
+
} catch {
|
|
256213
|
+
process.stderr.write(text);
|
|
256214
|
+
}
|
|
256215
|
+
}
|
|
256216
|
+
|
|
256144
256217
|
// ../../node_modules/zod/v3/external.js
|
|
256145
256218
|
var exports_external = {};
|
|
256146
256219
|
__export(exports_external, {
|
|
@@ -261182,23 +261255,23 @@ class CompileController {
|
|
|
261182
261255
|
// src/config.ts
|
|
261183
261256
|
init_constants();
|
|
261184
261257
|
init_logger();
|
|
261185
|
-
import
|
|
261258
|
+
import fs2 from "fs";
|
|
261186
261259
|
import path from "path";
|
|
261187
261260
|
import { fileURLToPath } from "url";
|
|
261188
261261
|
var BUNDLED_DEFAULT_CONFIG_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "default-publisher.config.json");
|
|
261189
261262
|
function resolvePublisherConfigPath(serverRoot) {
|
|
261190
261263
|
const explicitPath = process.env.PUBLISHER_CONFIG_PATH;
|
|
261191
261264
|
if (explicitPath && explicitPath.length > 0) {
|
|
261192
|
-
if (!
|
|
261265
|
+
if (!fs2.existsSync(explicitPath)) {
|
|
261193
261266
|
return null;
|
|
261194
261267
|
}
|
|
261195
261268
|
return { path: explicitPath, isBundledDefault: false };
|
|
261196
261269
|
}
|
|
261197
261270
|
const serverRootPath = path.join(serverRoot, PUBLISHER_CONFIG_NAME);
|
|
261198
|
-
if (
|
|
261271
|
+
if (fs2.existsSync(serverRootPath)) {
|
|
261199
261272
|
return { path: serverRootPath, isBundledDefault: false };
|
|
261200
261273
|
}
|
|
261201
|
-
if (process.env.PUBLISHER_USE_BUNDLED_DEFAULT === "true" &&
|
|
261274
|
+
if (process.env.PUBLISHER_USE_BUNDLED_DEFAULT === "true" && fs2.existsSync(BUNDLED_DEFAULT_CONFIG_PATH)) {
|
|
261202
261275
|
return { path: BUNDLED_DEFAULT_CONFIG_PATH, isBundledDefault: true };
|
|
261203
261276
|
}
|
|
261204
261277
|
return null;
|
|
@@ -261300,6 +261373,7 @@ var getEmbeddingConfig = () => {
|
|
|
261300
261373
|
}
|
|
261301
261374
|
return { apiKey, model, baseUrl, dimensions };
|
|
261302
261375
|
};
|
|
261376
|
+
var schemaEmbeddingEnabled = () => parseBoolEnv("EMBEDDING_INDEX_CONNECTION_SCHEMA") ?? false;
|
|
261303
261377
|
var DEFAULT_SCHEDULER_INTERVAL_MS = 60000;
|
|
261304
261378
|
var MIN_SCHEDULER_INTERVAL_MS = 1000;
|
|
261305
261379
|
var DEFAULT_SCHEDULER_MAX_FIRES_PER_TICK = 10;
|
|
@@ -261430,7 +261504,7 @@ var getPublisherConfigDir = (serverRoot) => {
|
|
|
261430
261504
|
return null;
|
|
261431
261505
|
}
|
|
261432
261506
|
try {
|
|
261433
|
-
if (!
|
|
261507
|
+
if (!fs2.statSync(resolved.path).isFile()) {
|
|
261434
261508
|
return null;
|
|
261435
261509
|
}
|
|
261436
261510
|
} catch {
|
|
@@ -261455,7 +261529,7 @@ var getPublisherConfig = (serverRoot) => {
|
|
|
261455
261529
|
}
|
|
261456
261530
|
let rawConfig;
|
|
261457
261531
|
try {
|
|
261458
|
-
const fileContent =
|
|
261532
|
+
const fileContent = fs2.readFileSync(publisherConfigPath, "utf8");
|
|
261459
261533
|
rawConfig = JSON.parse(fileContent);
|
|
261460
261534
|
} catch (error) {
|
|
261461
261535
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -261694,7 +261768,7 @@ function ensureCapTelemetry() {
|
|
|
261694
261768
|
const meter2 = publisherMeter();
|
|
261695
261769
|
if (!capExceededCounter) {
|
|
261696
261770
|
capExceededCounter = meter2.createCounter("publisher_query_cap_exceeded_total", {
|
|
261697
|
-
description: "
|
|
261771
|
+
description: "413s for an oversized response. cap_type: rows|bytes exceeded that cap; unserializable could not be turned into JSON at all, so no cap need have been exceeded. source: connection_sql|model_query|notebook_cell."
|
|
261698
261772
|
});
|
|
261699
261773
|
}
|
|
261700
261774
|
if (!configGaugesInstalled) {
|
|
@@ -265072,7 +265146,7 @@ var {
|
|
|
265072
265146
|
} = axios_default;
|
|
265073
265147
|
|
|
265074
265148
|
// src/service/connection.ts
|
|
265075
|
-
import
|
|
265149
|
+
import fs3 from "fs/promises";
|
|
265076
265150
|
|
|
265077
265151
|
// src/ducklake_version.ts
|
|
265078
265152
|
var MIN_CATALOG_FORMAT = "1.0";
|
|
@@ -265326,6 +265400,9 @@ function openSshProxy(ssh, target) {
|
|
|
265326
265400
|
});
|
|
265327
265401
|
}
|
|
265328
265402
|
|
|
265403
|
+
// src/service/query_metadata.ts
|
|
265404
|
+
import * as crypto3 from "node:crypto";
|
|
265405
|
+
|
|
265329
265406
|
// src/query_metadata_metrics.ts
|
|
265330
265407
|
var resetHooks = [];
|
|
265331
265408
|
function lazyCounter(name, description) {
|
|
@@ -265408,7 +265485,7 @@ function queryMetadataBudgetWarning(declared) {
|
|
|
265408
265485
|
return `queryMetadata declares ${declared} properties; the server adds up to ` + `${RESERVED_CONTEXT_PROPERTIES} of its own to every statement, so a bag over ` + `${authorBudget} loses its least specific properties at query time`;
|
|
265409
265486
|
}
|
|
265410
265487
|
function mintCorrelationId() {
|
|
265411
|
-
return
|
|
265488
|
+
return crypto3.randomUUID();
|
|
265412
265489
|
}
|
|
265413
265490
|
var CONTEXT_SHED_ORDER = [
|
|
265414
265491
|
"version",
|
|
@@ -266896,8 +266973,8 @@ async function deleteDuckLakeConnectionFile(connectionName, environmentPath) {
|
|
|
266896
266973
|
assertSafeEnvironmentPath(environmentPath);
|
|
266897
266974
|
const ducklakePath = safeJoinUnderRoot(environmentPath, `${connectionName}_ducklake.duckdb`);
|
|
266898
266975
|
try {
|
|
266899
|
-
await
|
|
266900
|
-
await
|
|
266976
|
+
await fs3.access(ducklakePath);
|
|
266977
|
+
await fs3.rm(ducklakePath);
|
|
266901
266978
|
logger.info(`Removed DuckLake connection file ${connectionName}_ducklake.duckdb from ${environmentPath}`);
|
|
266902
266979
|
} catch (error) {
|
|
266903
266980
|
if (error.code === "ENOENT") {
|
|
@@ -267406,6 +267483,7 @@ class ConnectionService {
|
|
|
267406
267483
|
}
|
|
267407
267484
|
|
|
267408
267485
|
// src/service/db_utils.ts
|
|
267486
|
+
init_errors();
|
|
267409
267487
|
init_logger();
|
|
267410
267488
|
var import_bigquery = __toESM(require_src121(), 1);
|
|
267411
267489
|
import { ClientSecretCredential } from "@azure/identity";
|
|
@@ -267414,6 +267492,50 @@ import { ContainerClient } from "@azure/storage-blob";
|
|
|
267414
267492
|
// src/service/gcs_s3_utils.ts
|
|
267415
267493
|
init_logger();
|
|
267416
267494
|
var import_client_s3 = __toESM(require_dist_cjs75(), 1);
|
|
267495
|
+
|
|
267496
|
+
// src/service/introspection_sql.ts
|
|
267497
|
+
init_logger();
|
|
267498
|
+
var BACKSLASH_ESCAPE_DIALECTS = new Set(["databricks", "mysql", "snowflake"]);
|
|
267499
|
+
var UNSUPPORTED_LITERAL_DIALECTS = new Map([
|
|
267500
|
+
[
|
|
267501
|
+
"bigquery",
|
|
267502
|
+
"GoogleSQL does not accept '' as an escaped quote, so this function cannot build a correct BigQuery literal. BigQuery introspection goes through the @google-cloud/bigquery client instead of building SQL."
|
|
267503
|
+
]
|
|
267504
|
+
]);
|
|
267505
|
+
var DOUBLED_ESCAPE_DIALECTS = new Set([
|
|
267506
|
+
"ducklake",
|
|
267507
|
+
"duckdb",
|
|
267508
|
+
"motherduck",
|
|
267509
|
+
"postgres",
|
|
267510
|
+
"publisher",
|
|
267511
|
+
"trino"
|
|
267512
|
+
]);
|
|
267513
|
+
function sqlLiteral(value, connectionType) {
|
|
267514
|
+
const dialect = (connectionType ?? "").toLowerCase();
|
|
267515
|
+
const unsupported = UNSUPPORTED_LITERAL_DIALECTS.get(dialect);
|
|
267516
|
+
if (unsupported) {
|
|
267517
|
+
throw new Error(`Cannot build a SQL literal for "${dialect}". ${unsupported}`);
|
|
267518
|
+
}
|
|
267519
|
+
const backslash = BACKSLASH_ESCAPE_DIALECTS.has(dialect);
|
|
267520
|
+
if (dialect && !backslash && !DOUBLED_ESCAPE_DIALECTS.has(dialect)) {
|
|
267521
|
+
throw new Error(`Unclassified SQL dialect "${connectionType}": add it to BACKSLASH_ESCAPE_DIALECTS or DOUBLED_ESCAPE_DIALECTS in introspection_sql.ts before building SQL for it, or to UNSUPPORTED_LITERAL_DIALECTS if, like BigQuery, it does not accept '' as an escaped quote.`);
|
|
267522
|
+
}
|
|
267523
|
+
const escaped = backslash ? value.replace(/\\/g, "\\\\") : value;
|
|
267524
|
+
return escaped.replace(/'/g, "''");
|
|
267525
|
+
}
|
|
267526
|
+
var INTROSPECTION_ROW_LIMIT = 1e5;
|
|
267527
|
+
async function runIntrospectionSQL(malloyConnection, sql) {
|
|
267528
|
+
const result = await malloyConnection.runSQL(sql, {
|
|
267529
|
+
rowLimit: INTROSPECTION_ROW_LIMIT
|
|
267530
|
+
});
|
|
267531
|
+
const rowCount = Array.isArray(result) ? result.length : result?.rows?.length ?? 0;
|
|
267532
|
+
if (rowCount === INTROSPECTION_ROW_LIMIT) {
|
|
267533
|
+
logger.warn("Schema introspection hit the row cap; the result may be truncated", { rowLimit: INTROSPECTION_ROW_LIMIT });
|
|
267534
|
+
}
|
|
267535
|
+
return result;
|
|
267536
|
+
}
|
|
267537
|
+
|
|
267538
|
+
// src/service/gcs_s3_utils.ts
|
|
267417
267539
|
function gcsConnectionToCredentials(gcsConnection) {
|
|
267418
267540
|
return {
|
|
267419
267541
|
type: "gcs",
|
|
@@ -267532,22 +267654,22 @@ async function getTableSchema(malloyConnection, credentials, bucketName, fileKey
|
|
|
267532
267654
|
let describeQuery;
|
|
267533
267655
|
switch (fileType) {
|
|
267534
267656
|
case "csv":
|
|
267535
|
-
describeQuery = `DESCRIBE SELECT * FROM read_csv('${uri}', auto_detect=true) LIMIT 1`;
|
|
267657
|
+
describeQuery = `DESCRIBE SELECT * FROM read_csv('${sqlLiteral(uri, "duckdb")}', auto_detect=true) LIMIT 1`;
|
|
267536
267658
|
break;
|
|
267537
267659
|
case "parquet":
|
|
267538
|
-
describeQuery = `DESCRIBE SELECT * FROM read_parquet('${uri}') LIMIT 1`;
|
|
267660
|
+
describeQuery = `DESCRIBE SELECT * FROM read_parquet('${sqlLiteral(uri, "duckdb")}') LIMIT 1`;
|
|
267539
267661
|
break;
|
|
267540
267662
|
case "json":
|
|
267541
|
-
describeQuery = `DESCRIBE SELECT * FROM read_json('${uri}', auto_detect=true) LIMIT 1`;
|
|
267663
|
+
describeQuery = `DESCRIBE SELECT * FROM read_json('${sqlLiteral(uri, "duckdb")}', auto_detect=true) LIMIT 1`;
|
|
267542
267664
|
break;
|
|
267543
267665
|
case "jsonl":
|
|
267544
|
-
describeQuery = `DESCRIBE SELECT * FROM read_json('${uri}', format='newline_delimited', auto_detect=true) LIMIT 1`;
|
|
267666
|
+
describeQuery = `DESCRIBE SELECT * FROM read_json('${sqlLiteral(uri, "duckdb")}', format='newline_delimited', auto_detect=true) LIMIT 1`;
|
|
267545
267667
|
break;
|
|
267546
267668
|
default:
|
|
267547
267669
|
logger.warn(`Unsupported file type for ${fileKey}`);
|
|
267548
267670
|
return { resource: uri, columns: [] };
|
|
267549
267671
|
}
|
|
267550
|
-
const result = await malloyConnection
|
|
267672
|
+
const result = await runIntrospectionSQL(malloyConnection, describeQuery);
|
|
267551
267673
|
const rows = standardizeRunSQLResult(result);
|
|
267552
267674
|
const columns = rows.map((row) => {
|
|
267553
267675
|
const typedRow = row;
|
|
@@ -267655,12 +267777,19 @@ async function listCloudDirectorySchemas(credentials) {
|
|
|
267655
267777
|
}
|
|
267656
267778
|
|
|
267657
267779
|
// src/service/db_utils.ts
|
|
267658
|
-
function sqlInFilter(columnName, values) {
|
|
267780
|
+
function sqlInFilter(columnName, values, connectionType) {
|
|
267659
267781
|
if (!values || values.length === 0)
|
|
267660
267782
|
return "";
|
|
267661
|
-
const escaped = values.map((v) => `'${v
|
|
267783
|
+
const escaped = values.map((v) => `'${sqlLiteral(v, connectionType)}'`);
|
|
267662
267784
|
return `AND ${columnName} IN (${escaped.join(", ")})`;
|
|
267663
267785
|
}
|
|
267786
|
+
var SAFE_SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
267787
|
+
function assertSafeSqlIdentifier(value, what) {
|
|
267788
|
+
if (!SAFE_SQL_IDENTIFIER.test(value)) {
|
|
267789
|
+
throw new InvalidArgumentError(`Invalid ${what} "${value}": expected a plain identifier (letters, digits, underscore or dollar, not starting with a digit).`);
|
|
267790
|
+
}
|
|
267791
|
+
return value;
|
|
267792
|
+
}
|
|
267664
267793
|
function groupColumnRowsIntoTables(rows, buildResource) {
|
|
267665
267794
|
const tableMap = new Map;
|
|
267666
267795
|
for (const row of rows) {
|
|
@@ -267748,7 +267877,7 @@ async function getSchemasForPostgres(connection, malloyConnection) {
|
|
|
267748
267877
|
throw new Error("Postgres connection is required");
|
|
267749
267878
|
}
|
|
267750
267879
|
try {
|
|
267751
|
-
const result = await malloyConnection
|
|
267880
|
+
const result = await runIntrospectionSQL(malloyConnection, "SELECT row_to_json(t) as row FROM (SELECT schema_name FROM information_schema.schemata ORDER BY schema_name) t");
|
|
267752
267881
|
const rows = standardizeRunSQLResult2(result);
|
|
267753
267882
|
return rows.map((row) => {
|
|
267754
267883
|
const typedRow = row;
|
|
@@ -267785,13 +267914,13 @@ async function getSchemasForSnowflake(connection, malloyConnection) {
|
|
|
267785
267914
|
const schema = connection.snowflakeConnection.schema;
|
|
267786
267915
|
const filters = [];
|
|
267787
267916
|
if (database) {
|
|
267788
|
-
filters.push(`CATALOG_NAME = '${database}'`);
|
|
267917
|
+
filters.push(`CATALOG_NAME = '${sqlLiteral(database, connection.type)}'`);
|
|
267789
267918
|
}
|
|
267790
267919
|
if (schema) {
|
|
267791
|
-
filters.push(`SCHEMA_NAME = '${schema}'`);
|
|
267920
|
+
filters.push(`SCHEMA_NAME = '${sqlLiteral(schema, connection.type)}'`);
|
|
267792
267921
|
}
|
|
267793
267922
|
const whereClause = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
|
|
267794
|
-
const result = await malloyConnection
|
|
267923
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT CATALOG_NAME, SCHEMA_NAME, SCHEMA_OWNER FROM ${database ? `${database}.` : ""}INFORMATION_SCHEMA.SCHEMATA ${whereClause} ORDER BY SCHEMA_NAME`);
|
|
267795
267924
|
const rows = standardizeRunSQLResult2(result);
|
|
267796
267925
|
return rows.map((row) => {
|
|
267797
267926
|
const typedRow = row;
|
|
@@ -267818,7 +267947,7 @@ async function getSchemasForTrino(connection, malloyConnection) {
|
|
|
267818
267947
|
let allRows = [];
|
|
267819
267948
|
if (connection.trinoConnection.catalog) {
|
|
267820
267949
|
const catalog = connection.trinoConnection.catalog;
|
|
267821
|
-
const result = await malloyConnection
|
|
267950
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT schema_name FROM ${assertSafeSqlIdentifier(catalog, "catalog name")}.information_schema.schemata ORDER BY schema_name`);
|
|
267822
267951
|
const rows = standardizeRunSQLResult2(result);
|
|
267823
267952
|
allRows = rows.map((row) => {
|
|
267824
267953
|
const r = row;
|
|
@@ -267828,14 +267957,14 @@ async function getSchemasForTrino(connection, malloyConnection) {
|
|
|
267828
267957
|
};
|
|
267829
267958
|
});
|
|
267830
267959
|
} else {
|
|
267831
|
-
const catalogsResult = await malloyConnection
|
|
267960
|
+
const catalogsResult = await runIntrospectionSQL(malloyConnection, `SHOW CATALOGS`);
|
|
267832
267961
|
const catalogNames = standardizeRunSQLResult2(catalogsResult).map((row) => {
|
|
267833
267962
|
const r = row;
|
|
267834
267963
|
return String(r.Catalog ?? r.catalog ?? "");
|
|
267835
267964
|
});
|
|
267836
267965
|
for (const catalog of catalogNames) {
|
|
267837
267966
|
try {
|
|
267838
|
-
const result = await malloyConnection
|
|
267967
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT schema_name FROM ${assertSafeSqlIdentifier(catalog, "catalog name")}.information_schema.schemata ORDER BY schema_name`);
|
|
267839
267968
|
const rows = standardizeRunSQLResult2(result);
|
|
267840
267969
|
for (const row of rows) {
|
|
267841
267970
|
const r = row;
|
|
@@ -267858,6 +267987,8 @@ async function getSchemasForTrino(connection, malloyConnection) {
|
|
|
267858
267987
|
};
|
|
267859
267988
|
});
|
|
267860
267989
|
} catch (error) {
|
|
267990
|
+
if (error instanceof BadRequestError)
|
|
267991
|
+
throw error;
|
|
267861
267992
|
logger.error(`Error getting schemas for Trino connection ${connection.name}`, { error });
|
|
267862
267993
|
throw new Error(`Failed to get schemas for Trino connection ${connection.name}: ${error.message}`);
|
|
267863
267994
|
}
|
|
@@ -267871,7 +268002,7 @@ async function getSchemasForDatabricks(connection, malloyConnection) {
|
|
|
267871
268002
|
let allRows = [];
|
|
267872
268003
|
if (connection.databricksConnection.defaultCatalog) {
|
|
267873
268004
|
const catalog = connection.databricksConnection.defaultCatalog;
|
|
267874
|
-
const result = await malloyConnection
|
|
268005
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT schema_name FROM ${assertSafeSqlIdentifier(catalog, "catalog name")}.information_schema.schemata ORDER BY schema_name`);
|
|
267875
268006
|
const rows = standardizeRunSQLResult2(result);
|
|
267876
268007
|
allRows = rows.map((row) => {
|
|
267877
268008
|
const r = row;
|
|
@@ -267881,14 +268012,14 @@ async function getSchemasForDatabricks(connection, malloyConnection) {
|
|
|
267881
268012
|
};
|
|
267882
268013
|
});
|
|
267883
268014
|
} else {
|
|
267884
|
-
const catalogsResult = await malloyConnection
|
|
268015
|
+
const catalogsResult = await runIntrospectionSQL(malloyConnection, `SHOW CATALOGS`);
|
|
267885
268016
|
const catalogNames = standardizeRunSQLResult2(catalogsResult).map((row) => {
|
|
267886
268017
|
const r = row;
|
|
267887
268018
|
return String(r.catalog ?? r.Catalog ?? r.catalog_name ?? "");
|
|
267888
268019
|
});
|
|
267889
268020
|
for (const catalog of catalogNames) {
|
|
267890
268021
|
try {
|
|
267891
|
-
const result = await malloyConnection
|
|
268022
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT schema_name FROM ${assertSafeSqlIdentifier(catalog, "catalog name")}.information_schema.schemata ORDER BY schema_name`);
|
|
267892
268023
|
const rows = standardizeRunSQLResult2(result);
|
|
267893
268024
|
for (const row of rows) {
|
|
267894
268025
|
const r = row;
|
|
@@ -267912,6 +268043,8 @@ async function getSchemasForDatabricks(connection, malloyConnection) {
|
|
|
267912
268043
|
};
|
|
267913
268044
|
});
|
|
267914
268045
|
} catch (error) {
|
|
268046
|
+
if (error instanceof BadRequestError)
|
|
268047
|
+
throw error;
|
|
267915
268048
|
logger.error(`Error getting schemas for Databricks connection ${connection.name}`, { error });
|
|
267916
268049
|
throw new Error(`Failed to get schemas for Databricks connection ${connection.name}: ${error.message}`);
|
|
267917
268050
|
}
|
|
@@ -267921,7 +268054,7 @@ async function getSchemasForDuckDB(connection, malloyConnection) {
|
|
|
267921
268054
|
throw new Error("DuckDB connection is required");
|
|
267922
268055
|
}
|
|
267923
268056
|
try {
|
|
267924
|
-
const result = await malloyConnection
|
|
268057
|
+
const result = await runIntrospectionSQL(malloyConnection, "SELECT DISTINCT schema_name,catalog_name FROM information_schema.schemata ORDER BY catalog_name,schema_name");
|
|
267925
268058
|
const rows = standardizeRunSQLResult2(result);
|
|
267926
268059
|
const schemas = rows.map((row) => {
|
|
267927
268060
|
const typedRow = row;
|
|
@@ -267977,8 +268110,8 @@ async function getSchemasForMotherDuck(connection, malloyConnection) {
|
|
|
267977
268110
|
}
|
|
267978
268111
|
try {
|
|
267979
268112
|
const database = connection.motherduckConnection.database;
|
|
267980
|
-
const whereClause = database ? `WHERE catalog_name = '${database}'` : "";
|
|
267981
|
-
const result = await malloyConnection
|
|
268113
|
+
const whereClause = database ? `WHERE catalog_name = '${sqlLiteral(database, connection.type)}'` : "";
|
|
268114
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT DISTINCT schema_name FROM information_schema.schemata ${whereClause} ORDER BY schema_name`);
|
|
267982
268115
|
const rows = standardizeRunSQLResult2(result);
|
|
267983
268116
|
return rows.map((row) => {
|
|
267984
268117
|
const typedRow = row;
|
|
@@ -267996,8 +268129,8 @@ async function getSchemasForMotherDuck(connection, malloyConnection) {
|
|
|
267996
268129
|
}
|
|
267997
268130
|
async function getSchemasForDuckLake(connection, malloyConnection) {
|
|
267998
268131
|
try {
|
|
267999
|
-
const catalogName = connection.name;
|
|
268000
|
-
const result = await malloyConnection
|
|
268132
|
+
const catalogName = connection.name ?? "";
|
|
268133
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${sqlLiteral(catalogName, connection.type)}' ORDER BY schema_name`);
|
|
268001
268134
|
const rows = standardizeRunSQLResult2(result);
|
|
268002
268135
|
return rows.map((row) => {
|
|
268003
268136
|
const typedRow = row;
|
|
@@ -268188,28 +268321,29 @@ function isDataFile2(key) {
|
|
|
268188
268321
|
const lowerKey = key.toLowerCase();
|
|
268189
268322
|
return lowerKey.endsWith(".csv") || lowerKey.endsWith(".parquet") || lowerKey.endsWith(".json") || lowerKey.endsWith(".jsonl") || lowerKey.endsWith(".ndjson");
|
|
268190
268323
|
}
|
|
268324
|
+
var DUCKDB_DIALECT = "duckdb";
|
|
268191
268325
|
async function describeRemoteFile(malloyConnection, fileUri) {
|
|
268192
268326
|
const pathWithoutQuery = fileUri.split("?")[0];
|
|
268193
268327
|
const fileType = getFileType2(pathWithoutQuery);
|
|
268194
268328
|
let describeQuery;
|
|
268195
268329
|
switch (fileType) {
|
|
268196
268330
|
case "csv":
|
|
268197
|
-
describeQuery = `DESCRIBE SELECT * FROM read_csv('${fileUri}', auto_detect=true) LIMIT 1`;
|
|
268331
|
+
describeQuery = `DESCRIBE SELECT * FROM read_csv('${sqlLiteral(fileUri, DUCKDB_DIALECT)}', auto_detect=true) LIMIT 1`;
|
|
268198
268332
|
break;
|
|
268199
268333
|
case "parquet":
|
|
268200
|
-
describeQuery = `DESCRIBE SELECT * FROM read_parquet('${fileUri}') LIMIT 1`;
|
|
268334
|
+
describeQuery = `DESCRIBE SELECT * FROM read_parquet('${sqlLiteral(fileUri, DUCKDB_DIALECT)}') LIMIT 1`;
|
|
268201
268335
|
break;
|
|
268202
268336
|
case "json":
|
|
268203
|
-
describeQuery = `DESCRIBE SELECT * FROM read_json('${fileUri}', auto_detect=true) LIMIT 1`;
|
|
268337
|
+
describeQuery = `DESCRIBE SELECT * FROM read_json('${sqlLiteral(fileUri, DUCKDB_DIALECT)}', auto_detect=true) LIMIT 1`;
|
|
268204
268338
|
break;
|
|
268205
268339
|
case "jsonl":
|
|
268206
|
-
describeQuery = `DESCRIBE SELECT * FROM read_json('${fileUri}', format='newline_delimited', auto_detect=true) LIMIT 1`;
|
|
268340
|
+
describeQuery = `DESCRIBE SELECT * FROM read_json('${sqlLiteral(fileUri, DUCKDB_DIALECT)}', format='newline_delimited', auto_detect=true) LIMIT 1`;
|
|
268207
268341
|
break;
|
|
268208
268342
|
default:
|
|
268209
268343
|
logger.warn(`Unsupported file type for file: ${fileUri}`);
|
|
268210
268344
|
return { resource: fileUri, columns: [] };
|
|
268211
268345
|
}
|
|
268212
|
-
const result = await malloyConnection
|
|
268346
|
+
const result = await runIntrospectionSQL(malloyConnection, describeQuery);
|
|
268213
268347
|
const rows = standardizeRunSQLResult2(result);
|
|
268214
268348
|
const columns = rows.map((row) => {
|
|
268215
268349
|
const typedRow = row;
|
|
@@ -268322,7 +268456,7 @@ async function listTablesForMySQL(connection, schemaName, malloyConnection, tabl
|
|
|
268322
268456
|
throw new Error("Mysql connection is required");
|
|
268323
268457
|
}
|
|
268324
268458
|
try {
|
|
268325
|
-
const result = await malloyConnection
|
|
268459
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM information_schema.columns WHERE table_schema = '${sqlLiteral(schemaName, connection.type)}' ${sqlInFilter("TABLE_NAME", tableNames, connection.type)} ORDER BY TABLE_NAME, ORDINAL_POSITION`);
|
|
268326
268460
|
const rows = standardizeRunSQLResult2(result);
|
|
268327
268461
|
return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
|
|
268328
268462
|
} catch (error) {
|
|
@@ -268335,7 +268469,7 @@ async function listTablesForPostgres(connection, schemaName, malloyConnection, t
|
|
|
268335
268469
|
throw new Error("Postgres connection is required");
|
|
268336
268470
|
}
|
|
268337
268471
|
try {
|
|
268338
|
-
const result = await malloyConnection
|
|
268472
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT row_to_json(t) as row FROM (SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${sqlLiteral(schemaName, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position) t`);
|
|
268339
268473
|
const rows = standardizeRunSQLResult2(result);
|
|
268340
268474
|
return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
|
|
268341
268475
|
} catch (error) {
|
|
@@ -268361,12 +268495,15 @@ async function listTablesForSnowflake(connection, schemaName, malloyConnection,
|
|
|
268361
268495
|
if (!databaseName) {
|
|
268362
268496
|
throw new Error(`Cannot resolve database for schema "${schemaName}": provide DATABASE.SCHEMA or configure a database on the connection`);
|
|
268363
268497
|
}
|
|
268498
|
+
assertSafeSqlIdentifier(databaseName, "database name");
|
|
268364
268499
|
const qualifiedSchema = `${databaseName}.${schemaOnly}`;
|
|
268365
|
-
const result = await malloyConnection
|
|
268500
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM ${databaseName}.INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '${sqlLiteral(schemaOnly, connection.type)}' ${sqlInFilter("TABLE_NAME", tableNames, connection.type)} ORDER BY TABLE_NAME, ORDINAL_POSITION`);
|
|
268366
268501
|
const rows = standardizeRunSQLResult2(result);
|
|
268367
268502
|
return groupColumnRowsIntoTables(rows, (t) => `${qualifiedSchema}.${t}`);
|
|
268368
268503
|
} catch (error) {
|
|
268369
268504
|
logger.error(`Error getting tables for Snowflake schema ${schemaName} in connection ${connection.name}`, { error });
|
|
268505
|
+
if (error instanceof BadRequestError)
|
|
268506
|
+
throw error;
|
|
268370
268507
|
throw new Error(`Failed to get tables for Snowflake schema ${schemaName} in connection ${connection.name}: ${error.message}`);
|
|
268371
268508
|
}
|
|
268372
268509
|
}
|
|
@@ -268385,7 +268522,7 @@ async function listTablesForTrino(connection, schemaName, malloyConnection, tabl
|
|
|
268385
268522
|
} else {
|
|
268386
268523
|
const dotIdx = schemaName.indexOf(".");
|
|
268387
268524
|
if (dotIdx > 0) {
|
|
268388
|
-
catalogPrefix = `${schemaName.substring(0, dotIdx)}.`;
|
|
268525
|
+
catalogPrefix = `${assertSafeSqlIdentifier(schemaName.substring(0, dotIdx), "catalog name")}.`;
|
|
268389
268526
|
schemaOnly = schemaName.substring(dotIdx + 1);
|
|
268390
268527
|
} else {
|
|
268391
268528
|
catalogPrefix = "";
|
|
@@ -268393,11 +268530,13 @@ async function listTablesForTrino(connection, schemaName, malloyConnection, tabl
|
|
|
268393
268530
|
}
|
|
268394
268531
|
resourcePrefix = schemaName;
|
|
268395
268532
|
}
|
|
268396
|
-
const result = await malloyConnection
|
|
268533
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT table_name, column_name, data_type FROM ${catalogPrefix}information_schema.columns WHERE table_schema = '${sqlLiteral(schemaOnly, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position`);
|
|
268397
268534
|
const rows = standardizeRunSQLResult2(result);
|
|
268398
268535
|
return groupColumnRowsIntoTables(rows, (t) => `${resourcePrefix}.${t}`);
|
|
268399
268536
|
} catch (error) {
|
|
268400
268537
|
logger.error(`Error getting tables for Trino schema ${schemaName} in connection ${connection.name}`, { error });
|
|
268538
|
+
if (error instanceof BadRequestError)
|
|
268539
|
+
throw error;
|
|
268401
268540
|
throw new Error(`Failed to get tables for Trino schema ${schemaName} in connection ${connection.name}: ${error.message}`);
|
|
268402
268541
|
}
|
|
268403
268542
|
}
|
|
@@ -268416,7 +268555,7 @@ async function listTablesForDatabricks(connection, schemaName, malloyConnection,
|
|
|
268416
268555
|
} else {
|
|
268417
268556
|
const dotIdx = schemaName.indexOf(".");
|
|
268418
268557
|
if (dotIdx > 0) {
|
|
268419
|
-
catalogPrefix = `${schemaName.substring(0, dotIdx)}.`;
|
|
268558
|
+
catalogPrefix = `${assertSafeSqlIdentifier(schemaName.substring(0, dotIdx), "catalog name")}.`;
|
|
268420
268559
|
schemaOnly = schemaName.substring(dotIdx + 1);
|
|
268421
268560
|
} else {
|
|
268422
268561
|
catalogPrefix = "";
|
|
@@ -268424,11 +268563,13 @@ async function listTablesForDatabricks(connection, schemaName, malloyConnection,
|
|
|
268424
268563
|
}
|
|
268425
268564
|
resourcePrefix = schemaName;
|
|
268426
268565
|
}
|
|
268427
|
-
const result = await malloyConnection
|
|
268566
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT table_name, column_name, data_type FROM ${catalogPrefix}information_schema.columns WHERE table_schema = '${sqlLiteral(schemaOnly, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position`);
|
|
268428
268567
|
const rows = standardizeRunSQLResult2(result);
|
|
268429
268568
|
return groupColumnRowsIntoTables(rows, (t) => `${resourcePrefix}.${t}`);
|
|
268430
268569
|
} catch (error) {
|
|
268431
268570
|
logger.error(`Error getting tables for Databricks schema ${schemaName} in connection ${connection.name}`, { error });
|
|
268571
|
+
if (error instanceof BadRequestError)
|
|
268572
|
+
throw error;
|
|
268432
268573
|
throw new Error(`Failed to get tables for Databricks schema ${schemaName} in connection ${connection.name}: ${error.message}`);
|
|
268433
268574
|
}
|
|
268434
268575
|
}
|
|
@@ -268464,12 +268605,12 @@ async function listTablesForDuckDB(connection, schemaName, malloyConnection, tab
|
|
|
268464
268605
|
}
|
|
268465
268606
|
const dotIdx = schemaName.indexOf(".");
|
|
268466
268607
|
if (dotIdx < 0) {
|
|
268467
|
-
throw new
|
|
268608
|
+
throw new InvalidArgumentError(`DuckDB schema name must be qualified as "catalog.schema", got "${schemaName}". List this connection's schemas and use one of those names verbatim.`);
|
|
268468
268609
|
}
|
|
268469
268610
|
const catalogName = schemaName.substring(0, dotIdx);
|
|
268470
268611
|
const actualSchemaName = schemaName.substring(dotIdx + 1);
|
|
268471
268612
|
try {
|
|
268472
|
-
const result = await malloyConnection
|
|
268613
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${sqlLiteral(actualSchemaName, connection.type)}' AND table_catalog = '${sqlLiteral(catalogName, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position`);
|
|
268473
268614
|
const rows = standardizeRunSQLResult2(result);
|
|
268474
268615
|
return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
|
|
268475
268616
|
} catch (error) {
|
|
@@ -268482,7 +268623,7 @@ async function listTablesForMotherDuck(connection, schemaName, malloyConnection,
|
|
|
268482
268623
|
throw new Error("MotherDuck connection is required");
|
|
268483
268624
|
}
|
|
268484
268625
|
try {
|
|
268485
|
-
const result = await malloyConnection
|
|
268626
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${sqlLiteral(schemaName, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position`);
|
|
268486
268627
|
const rows = standardizeRunSQLResult2(result);
|
|
268487
268628
|
return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
|
|
268488
268629
|
} catch (error) {
|
|
@@ -268497,7 +268638,7 @@ async function listTablesForDuckLake(connection, schemaName, malloyConnection, t
|
|
|
268497
268638
|
const catalogName = schemaName.split(".")[0];
|
|
268498
268639
|
const actualSchemaName = schemaName.split(".")[1];
|
|
268499
268640
|
try {
|
|
268500
|
-
const result = await malloyConnection
|
|
268641
|
+
const result = await runIntrospectionSQL(malloyConnection, `SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${sqlLiteral(actualSchemaName, connection.type)}' AND table_catalog = '${sqlLiteral(catalogName, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position`);
|
|
268501
268642
|
const rows = standardizeRunSQLResult2(result);
|
|
268502
268643
|
return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
|
|
268503
268644
|
} catch (error) {
|
|
@@ -268682,7 +268823,7 @@ class ConnectionController {
|
|
|
268682
268823
|
const pkg = await environment.getPackage(onlyPackage);
|
|
268683
268824
|
return await pkg.getMalloyConnection(connectionName);
|
|
268684
268825
|
}
|
|
268685
|
-
throw new BadRequestError(`Ambiguous "duckdb" connection lookup: environment "${environmentName}" has multiple packages. ` + `
|
|
268826
|
+
throw new BadRequestError(`Ambiguous "duckdb" connection lookup: environment "${environmentName}" has multiple packages, ` + `and the "duckdb" sandbox exists once per package. Name one of: ${packages.map((p) => p.name).filter(Boolean).join(", ")}. ` + `Over MCP pass it as the packageName argument; over REST use ` + `/environments/${environmentName}/packages/{packageName}/connections/duckdb/...`);
|
|
268686
268827
|
} else {
|
|
268687
268828
|
return await environment.getMalloyConnection(connectionName);
|
|
268688
268829
|
}
|
|
@@ -269168,17 +269309,6 @@ class PackageController {
|
|
|
269168
269309
|
var import_render_validator = __toESM(require_dist11(), 1);
|
|
269169
269310
|
init_constants();
|
|
269170
269311
|
init_errors();
|
|
269171
|
-
|
|
269172
|
-
// src/json_utils.ts
|
|
269173
|
-
var MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
|
269174
|
-
function bigIntReplacer(_key, value) {
|
|
269175
|
-
if (typeof value === "bigint") {
|
|
269176
|
-
return value > MAX_SAFE_BIGINT || value < -MAX_SAFE_BIGINT ? value.toString() : Number(value);
|
|
269177
|
-
}
|
|
269178
|
-
return value;
|
|
269179
|
-
}
|
|
269180
|
-
|
|
269181
|
-
// src/controller/query.controller.ts
|
|
269182
269312
|
init_logger();
|
|
269183
269313
|
class QueryController {
|
|
269184
269314
|
environmentStore;
|
|
@@ -269203,7 +269333,7 @@ class QueryController {
|
|
|
269203
269333
|
} else {
|
|
269204
269334
|
const {
|
|
269205
269335
|
result,
|
|
269206
|
-
|
|
269336
|
+
serializedResult,
|
|
269207
269337
|
rowLimit,
|
|
269208
269338
|
rowLimitSource,
|
|
269209
269339
|
queryCorrelationId
|
|
@@ -269225,10 +269355,10 @@ class QueryController {
|
|
|
269225
269355
|
return null;
|
|
269226
269356
|
}
|
|
269227
269357
|
}
|
|
269228
|
-
}), getQueryTimeoutMs());
|
|
269358
|
+
}, compactJson ? "compact" : "full"), getQueryTimeoutMs());
|
|
269229
269359
|
const renderLogs = import_render_validator.validateRenderTags(result);
|
|
269230
269360
|
return {
|
|
269231
|
-
result:
|
|
269361
|
+
result: serializedResult,
|
|
269232
269362
|
resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}/models/${modelPath}/query`,
|
|
269233
269363
|
renderLogs: renderLogs.length > 0 ? renderLogs : undefined,
|
|
269234
269364
|
queryRowLimit: rowLimit,
|
|
@@ -271148,8 +271278,8 @@ function tryAcquire(sync, alreadyAcquiredError = E_ALREADY_LOCKED) {
|
|
|
271148
271278
|
|
|
271149
271279
|
// src/service/environment_store.ts
|
|
271150
271280
|
var import_extract_zip = __toESM(require_extract_zip(), 1);
|
|
271151
|
-
import
|
|
271152
|
-
import * as
|
|
271281
|
+
import crypto5 from "crypto";
|
|
271282
|
+
import * as fs9 from "fs";
|
|
271153
271283
|
import * as os2 from "os";
|
|
271154
271284
|
import * as path9 from "path";
|
|
271155
271285
|
|
|
@@ -276692,10 +276822,221 @@ class StorageManager {
|
|
|
276692
276822
|
import { MalloyError as MalloyError4, Runtime as Runtime3 } from "@malloydata/malloy";
|
|
276693
276823
|
init_constants();
|
|
276694
276824
|
init_errors();
|
|
276695
|
-
import
|
|
276696
|
-
import * as
|
|
276825
|
+
import crypto4 from "crypto";
|
|
276826
|
+
import * as fs8 from "fs";
|
|
276697
276827
|
import * as path8 from "path";
|
|
276698
276828
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
276829
|
+
|
|
276830
|
+
// src/service/authorize.ts
|
|
276831
|
+
init_errors();
|
|
276832
|
+
var AUTHORIZE_TAG = String.raw`##?\(\s*authorize\s*\)`;
|
|
276833
|
+
var AUTHORIZE_ANNOTATION_ANYWHERE = new RegExp(AUTHORIZE_TAG);
|
|
276834
|
+
var AUTHORIZE_ANNOTATION_PREFIX = new RegExp(`^${AUTHORIZE_TAG}`);
|
|
276835
|
+
function assertNoCallerAuthorizeAnnotation(callerText) {
|
|
276836
|
+
if (!AUTHORIZE_ANNOTATION_ANYWHERE.test(callerText))
|
|
276837
|
+
return;
|
|
276838
|
+
throw new BadRequestError("An `authorize` annotation is not permitted in caller-submitted Malloy " + "text. Access gates are declared by the model author on the source; a " + "request cannot introduce, replace, or relax one. To validate a gate " + "you are authoring, save it to the package's model file and reload the " + "package — model load validates every `#(authorize)` annotation it " + "declares.");
|
|
276839
|
+
}
|
|
276840
|
+
function buildAuthorizeProbe(exprs, givenDecls = []) {
|
|
276841
|
+
const selects = exprs.map((expr, i) => `__auth_${i} is (${expr})`).join(`
|
|
276842
|
+
`);
|
|
276843
|
+
const givenBlock = givenDecls.length > 0 ? `given:
|
|
276844
|
+
${givenDecls.map((g) => ` ${g.name} :: ${g.type}`).join(`
|
|
276845
|
+
`)}
|
|
276846
|
+
|
|
276847
|
+
` : "";
|
|
276848
|
+
return `${givenBlock}run: duckdb.sql("SELECT 1 AS __authorize_probe_row") -> {
|
|
276849
|
+
select:
|
|
276850
|
+
${selects}
|
|
276851
|
+
limit: 1
|
|
276852
|
+
}`;
|
|
276853
|
+
}
|
|
276854
|
+
var GIVEN_REF_PATTERN = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
276855
|
+
var STRING_LITERAL_PATTERN = /'(?:\\.|[^'\\])*'/g;
|
|
276856
|
+
function referencedGivenNames(expr) {
|
|
276857
|
+
const scanned = expr.replace(STRING_LITERAL_PATTERN, "''");
|
|
276858
|
+
const names = [];
|
|
276859
|
+
const seen = new Set;
|
|
276860
|
+
for (const match of scanned.matchAll(GIVEN_REF_PATTERN)) {
|
|
276861
|
+
const name = match[1];
|
|
276862
|
+
if (!seen.has(name)) {
|
|
276863
|
+
seen.add(name);
|
|
276864
|
+
names.push(name);
|
|
276865
|
+
}
|
|
276866
|
+
}
|
|
276867
|
+
return names;
|
|
276868
|
+
}
|
|
276869
|
+
function inferGivenType(value) {
|
|
276870
|
+
if (typeof value === "string")
|
|
276871
|
+
return "string";
|
|
276872
|
+
if (typeof value === "number" || typeof value === "bigint")
|
|
276873
|
+
return "number";
|
|
276874
|
+
if (typeof value === "boolean")
|
|
276875
|
+
return "boolean";
|
|
276876
|
+
if (value instanceof Date)
|
|
276877
|
+
return "timestamp";
|
|
276878
|
+
if (Array.isArray(value)) {
|
|
276879
|
+
if (value.length === 0)
|
|
276880
|
+
return null;
|
|
276881
|
+
const elementType = inferGivenType(value[0]);
|
|
276882
|
+
return elementType ? `${elementType}[]` : null;
|
|
276883
|
+
}
|
|
276884
|
+
return null;
|
|
276885
|
+
}
|
|
276886
|
+
function bindProbeGivens(expr, givens, declaredTypes) {
|
|
276887
|
+
const decls = [];
|
|
276888
|
+
const bound = {};
|
|
276889
|
+
for (const name of referencedGivenNames(expr)) {
|
|
276890
|
+
if (!(name in givens))
|
|
276891
|
+
continue;
|
|
276892
|
+
const value = givens[name];
|
|
276893
|
+
const type = declaredTypes?.get(name) ?? inferGivenType(value);
|
|
276894
|
+
if (!type)
|
|
276895
|
+
continue;
|
|
276896
|
+
decls.push({ name, type });
|
|
276897
|
+
bound[name] = value;
|
|
276898
|
+
}
|
|
276899
|
+
return { decls, bound };
|
|
276900
|
+
}
|
|
276901
|
+
function isProbeTrue(cell) {
|
|
276902
|
+
return cell === true || cell === 1 || cell === "true";
|
|
276903
|
+
}
|
|
276904
|
+
async function runProbe(executor, probeText, givens) {
|
|
276905
|
+
const result = await executor.loadQuery(probeText).run({ rowLimit: 1, givens });
|
|
276906
|
+
const row = result?.data?.value?.[0];
|
|
276907
|
+
return !!(row && isProbeTrue(row.__auth_0));
|
|
276908
|
+
}
|
|
276909
|
+
async function evaluateAuthorize(executor, exprs, givens, declaredTypes, options) {
|
|
276910
|
+
const selfContainedFirst = options?.selfContainedFirst ?? false;
|
|
276911
|
+
const ambientPrefix = options?.ambientPrefix ?? 0;
|
|
276912
|
+
for (const [index, expr] of exprs.entries()) {
|
|
276913
|
+
if (selfContainedFirst && index >= ambientPrefix) {
|
|
276914
|
+
if (await evaluateSelfContainedFirst(executor, expr, givens, declaredTypes)) {
|
|
276915
|
+
return true;
|
|
276916
|
+
}
|
|
276917
|
+
continue;
|
|
276918
|
+
}
|
|
276919
|
+
try {
|
|
276920
|
+
if (await runProbe(executor, buildAuthorizeProbe([expr]), givens)) {
|
|
276921
|
+
return true;
|
|
276922
|
+
}
|
|
276923
|
+
continue;
|
|
276924
|
+
} catch {}
|
|
276925
|
+
try {
|
|
276926
|
+
const { decls, bound } = bindProbeGivens(expr, givens, declaredTypes);
|
|
276927
|
+
if (decls.length === 0)
|
|
276928
|
+
continue;
|
|
276929
|
+
if (await runProbe(executor, buildAuthorizeProbe([expr], decls), bound)) {
|
|
276930
|
+
return true;
|
|
276931
|
+
}
|
|
276932
|
+
} catch {
|
|
276933
|
+
continue;
|
|
276934
|
+
}
|
|
276935
|
+
}
|
|
276936
|
+
return false;
|
|
276937
|
+
}
|
|
276938
|
+
async function evaluateSelfContainedFirst(executor, expr, givens, declaredTypes) {
|
|
276939
|
+
const referenced = referencedGivenNames(expr);
|
|
276940
|
+
if (referenced.length === 0) {
|
|
276941
|
+
try {
|
|
276942
|
+
return await runProbe(executor, buildAuthorizeProbe([expr]), {});
|
|
276943
|
+
} catch {
|
|
276944
|
+
return false;
|
|
276945
|
+
}
|
|
276946
|
+
}
|
|
276947
|
+
const { decls, bound } = bindProbeGivens(expr, givens, declaredTypes);
|
|
276948
|
+
if (decls.length !== referenced.length) {
|
|
276949
|
+
return false;
|
|
276950
|
+
}
|
|
276951
|
+
try {
|
|
276952
|
+
return await runProbe(executor, buildAuthorizeProbe([expr], decls), bound);
|
|
276953
|
+
} catch {
|
|
276954
|
+
try {
|
|
276955
|
+
return await runProbe(executor, buildAuthorizeProbe([expr]), givens);
|
|
276956
|
+
} catch {
|
|
276957
|
+
return false;
|
|
276958
|
+
}
|
|
276959
|
+
}
|
|
276960
|
+
}
|
|
276961
|
+
async function validateAuthorizeProbes(compiler, sources) {
|
|
276962
|
+
for (const source of sources) {
|
|
276963
|
+
const exprs = source.authorize;
|
|
276964
|
+
if (!exprs || exprs.length === 0)
|
|
276965
|
+
continue;
|
|
276966
|
+
try {
|
|
276967
|
+
await compiler.loadQuery(buildAuthorizeProbe(exprs)).getPreparedQuery();
|
|
276968
|
+
} catch (err) {
|
|
276969
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
276970
|
+
throw new ModelCompilationError({
|
|
276971
|
+
message: `Invalid #(authorize) annotation on source "${source.name ?? "(unnamed)"}" [${exprs.join(" | ")}]: ${detail}`
|
|
276972
|
+
});
|
|
276973
|
+
}
|
|
276974
|
+
}
|
|
276975
|
+
}
|
|
276976
|
+
function parseAuthorizeAnnotation(annotation) {
|
|
276977
|
+
const trimmed2 = annotation.trim();
|
|
276978
|
+
const prefix = AUTHORIZE_ANNOTATION_PREFIX.exec(trimmed2);
|
|
276979
|
+
if (!prefix)
|
|
276980
|
+
return null;
|
|
276981
|
+
return unwrapQuotedExpression(trimmed2.slice(prefix[0].length).trim());
|
|
276982
|
+
}
|
|
276983
|
+
function collectAuthorizeExprs(annotations) {
|
|
276984
|
+
const exprs = [];
|
|
276985
|
+
for (const annotation of annotations) {
|
|
276986
|
+
const expr = parseAuthorizeAnnotation(annotation);
|
|
276987
|
+
if (expr !== null) {
|
|
276988
|
+
exprs.push(expr);
|
|
276989
|
+
}
|
|
276990
|
+
}
|
|
276991
|
+
return exprs;
|
|
276992
|
+
}
|
|
276993
|
+
function unwrapQuotedExpression(body) {
|
|
276994
|
+
if (body.length < 2 || body[0] !== '"') {
|
|
276995
|
+
throw new Error(`authorize annotation expression must be a double-quoted string, got: ${body || "(empty)"}`);
|
|
276996
|
+
}
|
|
276997
|
+
let expr = "";
|
|
276998
|
+
let i = 1;
|
|
276999
|
+
let closed = false;
|
|
277000
|
+
for (;i < body.length; i++) {
|
|
277001
|
+
const ch = body[i];
|
|
277002
|
+
if (ch === "\\" && i + 1 < body.length) {
|
|
277003
|
+
const next = body[i + 1];
|
|
277004
|
+
if (next === '"' || next === "\\") {
|
|
277005
|
+
expr += next;
|
|
277006
|
+
i++;
|
|
277007
|
+
continue;
|
|
277008
|
+
}
|
|
277009
|
+
}
|
|
277010
|
+
if (ch === '"') {
|
|
277011
|
+
closed = true;
|
|
277012
|
+
i++;
|
|
277013
|
+
break;
|
|
277014
|
+
}
|
|
277015
|
+
expr += ch;
|
|
277016
|
+
}
|
|
277017
|
+
if (!closed) {
|
|
277018
|
+
throw new Error(`authorize annotation has mismatched quotes: ${body}`);
|
|
277019
|
+
}
|
|
277020
|
+
const rest = body.slice(i).trim();
|
|
277021
|
+
if (rest.length > 0) {
|
|
277022
|
+
throw new Error(`authorize annotation has unexpected content after the expression: ${rest}`);
|
|
277023
|
+
}
|
|
277024
|
+
if (expr.trim().length === 0) {
|
|
277025
|
+
throw new Error("authorize annotation has an empty expression body");
|
|
277026
|
+
}
|
|
277027
|
+
return expr;
|
|
277028
|
+
}
|
|
277029
|
+
|
|
277030
|
+
// src/authorize_metrics.ts
|
|
277031
|
+
var guardRejectionCounter = null;
|
|
277032
|
+
function recordAuthorizeGuardRejection(field) {
|
|
277033
|
+
guardRejectionCounter ??= publisherMeter().createCounter("publisher_authorize_guard_rejected_total", {
|
|
277034
|
+
description: "Requests rejected with 400 for declaring an `#(authorize)` annotation in caller-submitted Malloy text. Label: field ('query'|'source_name'|'query_name'|'compile_source')."
|
|
277035
|
+
});
|
|
277036
|
+
guardRejectionCounter.add(1, { field });
|
|
277037
|
+
}
|
|
277038
|
+
|
|
277039
|
+
// src/service/environment.ts
|
|
276699
277040
|
init_logger();
|
|
276700
277041
|
|
|
276701
277042
|
// src/materialization_metrics.ts
|
|
@@ -276783,7 +277124,7 @@ function recordChainedStorageBuild(outcome) {
|
|
|
276783
277124
|
}
|
|
276784
277125
|
|
|
276785
277126
|
// src/utils.ts
|
|
276786
|
-
import * as
|
|
277127
|
+
import * as fs4 from "fs";
|
|
276787
277128
|
import * as path5 from "path";
|
|
276788
277129
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
276789
277130
|
var URL_READER = {
|
|
@@ -276792,7 +277133,7 @@ var URL_READER = {
|
|
|
276792
277133
|
if (url2.protocol == "file:") {
|
|
276793
277134
|
path6 = fileURLToPath3(url2);
|
|
276794
277135
|
}
|
|
276795
|
-
return
|
|
277136
|
+
return fs4.promises.readFile(path6, "utf8");
|
|
276796
277137
|
}
|
|
276797
277138
|
};
|
|
276798
277139
|
function ignoreDotfiles(file) {
|
|
@@ -276806,7 +277147,7 @@ function errMessage(err) {
|
|
|
276806
277147
|
init_logger();
|
|
276807
277148
|
var import_client_s32 = __toESM(require_dist_cjs75(), 1);
|
|
276808
277149
|
import { Storage } from "@google-cloud/storage";
|
|
276809
|
-
import * as
|
|
277150
|
+
import * as fs5 from "fs/promises";
|
|
276810
277151
|
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
276811
277152
|
var gcsClient;
|
|
276812
277153
|
var s3Client;
|
|
@@ -276835,9 +277176,9 @@ async function readManifestBytes(uri) {
|
|
|
276835
277176
|
return res.Body.transformToString();
|
|
276836
277177
|
}
|
|
276837
277178
|
if (uri.startsWith("file://")) {
|
|
276838
|
-
return
|
|
277179
|
+
return fs5.readFile(fileURLToPath4(uri), "utf8");
|
|
276839
277180
|
}
|
|
276840
|
-
return
|
|
277181
|
+
return fs5.readFile(uri, "utf8");
|
|
276841
277182
|
}
|
|
276842
277183
|
async function fetchManifestEntries(uri) {
|
|
276843
277184
|
const raw = await readManifestBytes(uri);
|
|
@@ -276877,7 +277218,7 @@ function splitManifestEntries(entries, source) {
|
|
|
276877
277218
|
}
|
|
276878
277219
|
|
|
276879
277220
|
// src/service/package.ts
|
|
276880
|
-
import * as
|
|
277221
|
+
import * as fs7 from "fs/promises";
|
|
276881
277222
|
import * as path7 from "path";
|
|
276882
277223
|
import"@malloydata/db-duckdb/native";
|
|
276883
277224
|
import { DuckDBConnection as DuckDBConnection3 } from "@malloydata/db-duckdb";
|
|
@@ -277267,210 +277608,10 @@ async function assertServesInDuckDB(sourceName, binding, connections) {
|
|
|
277267
277608
|
init_errors();
|
|
277268
277609
|
init_constants();
|
|
277269
277610
|
init_logger();
|
|
277270
|
-
import { Annotations as
|
|
277611
|
+
import { Annotations as Annotations2 } from "@malloydata/malloy";
|
|
277271
277612
|
|
|
277272
277613
|
// src/service/materialization_eligibility.ts
|
|
277273
277614
|
init_errors();
|
|
277274
|
-
|
|
277275
|
-
// src/service/authorize.ts
|
|
277276
|
-
init_errors();
|
|
277277
|
-
var SOURCE_PREFIX = "#(authorize)";
|
|
277278
|
-
var FILE_PREFIX = "##(authorize)";
|
|
277279
|
-
function buildAuthorizeProbe(exprs, givenDecls = []) {
|
|
277280
|
-
const selects = exprs.map((expr, i) => `__auth_${i} is (${expr})`).join(`
|
|
277281
|
-
`);
|
|
277282
|
-
const givenBlock = givenDecls.length > 0 ? `given:
|
|
277283
|
-
${givenDecls.map((g) => ` ${g.name} :: ${g.type}`).join(`
|
|
277284
|
-
`)}
|
|
277285
|
-
|
|
277286
|
-
` : "";
|
|
277287
|
-
return `${givenBlock}run: duckdb.sql("SELECT 1 AS __authorize_probe_row") -> {
|
|
277288
|
-
select:
|
|
277289
|
-
${selects}
|
|
277290
|
-
limit: 1
|
|
277291
|
-
}`;
|
|
277292
|
-
}
|
|
277293
|
-
var GIVEN_REF_PATTERN = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
277294
|
-
var STRING_LITERAL_PATTERN = /'(?:\\.|[^'\\])*'/g;
|
|
277295
|
-
function referencedGivenNames(expr) {
|
|
277296
|
-
const scanned = expr.replace(STRING_LITERAL_PATTERN, "''");
|
|
277297
|
-
const names = [];
|
|
277298
|
-
const seen = new Set;
|
|
277299
|
-
for (const match of scanned.matchAll(GIVEN_REF_PATTERN)) {
|
|
277300
|
-
const name = match[1];
|
|
277301
|
-
if (!seen.has(name)) {
|
|
277302
|
-
seen.add(name);
|
|
277303
|
-
names.push(name);
|
|
277304
|
-
}
|
|
277305
|
-
}
|
|
277306
|
-
return names;
|
|
277307
|
-
}
|
|
277308
|
-
function inferGivenType(value) {
|
|
277309
|
-
if (typeof value === "string")
|
|
277310
|
-
return "string";
|
|
277311
|
-
if (typeof value === "number" || typeof value === "bigint")
|
|
277312
|
-
return "number";
|
|
277313
|
-
if (typeof value === "boolean")
|
|
277314
|
-
return "boolean";
|
|
277315
|
-
if (value instanceof Date)
|
|
277316
|
-
return "timestamp";
|
|
277317
|
-
if (Array.isArray(value)) {
|
|
277318
|
-
if (value.length === 0)
|
|
277319
|
-
return null;
|
|
277320
|
-
const elementType = inferGivenType(value[0]);
|
|
277321
|
-
return elementType ? `${elementType}[]` : null;
|
|
277322
|
-
}
|
|
277323
|
-
return null;
|
|
277324
|
-
}
|
|
277325
|
-
function bindProbeGivens(expr, givens, declaredTypes) {
|
|
277326
|
-
const decls = [];
|
|
277327
|
-
const bound = {};
|
|
277328
|
-
for (const name of referencedGivenNames(expr)) {
|
|
277329
|
-
if (!(name in givens))
|
|
277330
|
-
continue;
|
|
277331
|
-
const value = givens[name];
|
|
277332
|
-
const type = declaredTypes?.get(name) ?? inferGivenType(value);
|
|
277333
|
-
if (!type)
|
|
277334
|
-
continue;
|
|
277335
|
-
decls.push({ name, type });
|
|
277336
|
-
bound[name] = value;
|
|
277337
|
-
}
|
|
277338
|
-
return { decls, bound };
|
|
277339
|
-
}
|
|
277340
|
-
function isProbeTrue(cell) {
|
|
277341
|
-
return cell === true || cell === 1 || cell === "true";
|
|
277342
|
-
}
|
|
277343
|
-
async function runProbe(executor, probeText, givens) {
|
|
277344
|
-
const result = await executor.loadQuery(probeText).run({ rowLimit: 1, givens });
|
|
277345
|
-
const row = result?.data?.value?.[0];
|
|
277346
|
-
return !!(row && isProbeTrue(row.__auth_0));
|
|
277347
|
-
}
|
|
277348
|
-
async function evaluateAuthorize(executor, exprs, givens, declaredTypes, options) {
|
|
277349
|
-
const selfContainedFirst = options?.selfContainedFirst ?? false;
|
|
277350
|
-
for (const expr of exprs) {
|
|
277351
|
-
if (selfContainedFirst) {
|
|
277352
|
-
if (await evaluateSelfContainedFirst(executor, expr, givens, declaredTypes)) {
|
|
277353
|
-
return true;
|
|
277354
|
-
}
|
|
277355
|
-
continue;
|
|
277356
|
-
}
|
|
277357
|
-
try {
|
|
277358
|
-
if (await runProbe(executor, buildAuthorizeProbe([expr]), givens)) {
|
|
277359
|
-
return true;
|
|
277360
|
-
}
|
|
277361
|
-
continue;
|
|
277362
|
-
} catch {}
|
|
277363
|
-
try {
|
|
277364
|
-
const { decls, bound } = bindProbeGivens(expr, givens, declaredTypes);
|
|
277365
|
-
if (decls.length === 0)
|
|
277366
|
-
continue;
|
|
277367
|
-
if (await runProbe(executor, buildAuthorizeProbe([expr], decls), bound)) {
|
|
277368
|
-
return true;
|
|
277369
|
-
}
|
|
277370
|
-
} catch {
|
|
277371
|
-
continue;
|
|
277372
|
-
}
|
|
277373
|
-
}
|
|
277374
|
-
return false;
|
|
277375
|
-
}
|
|
277376
|
-
async function evaluateSelfContainedFirst(executor, expr, givens, declaredTypes) {
|
|
277377
|
-
const referenced = referencedGivenNames(expr);
|
|
277378
|
-
if (referenced.length === 0) {
|
|
277379
|
-
try {
|
|
277380
|
-
return await runProbe(executor, buildAuthorizeProbe([expr]), {});
|
|
277381
|
-
} catch {
|
|
277382
|
-
return false;
|
|
277383
|
-
}
|
|
277384
|
-
}
|
|
277385
|
-
const { decls, bound } = bindProbeGivens(expr, givens, declaredTypes);
|
|
277386
|
-
if (decls.length !== referenced.length) {
|
|
277387
|
-
return false;
|
|
277388
|
-
}
|
|
277389
|
-
try {
|
|
277390
|
-
return await runProbe(executor, buildAuthorizeProbe([expr], decls), bound);
|
|
277391
|
-
} catch {
|
|
277392
|
-
try {
|
|
277393
|
-
return await runProbe(executor, buildAuthorizeProbe([expr]), givens);
|
|
277394
|
-
} catch {
|
|
277395
|
-
return false;
|
|
277396
|
-
}
|
|
277397
|
-
}
|
|
277398
|
-
}
|
|
277399
|
-
async function validateAuthorizeProbes(compiler, sources) {
|
|
277400
|
-
for (const source of sources) {
|
|
277401
|
-
const exprs = source.authorize;
|
|
277402
|
-
if (!exprs || exprs.length === 0)
|
|
277403
|
-
continue;
|
|
277404
|
-
try {
|
|
277405
|
-
await compiler.loadQuery(buildAuthorizeProbe(exprs)).getPreparedQuery();
|
|
277406
|
-
} catch (err) {
|
|
277407
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
277408
|
-
throw new ModelCompilationError({
|
|
277409
|
-
message: `Invalid #(authorize) annotation on source "${source.name ?? "(unnamed)"}" [${exprs.join(" | ")}]: ${detail}`
|
|
277410
|
-
});
|
|
277411
|
-
}
|
|
277412
|
-
}
|
|
277413
|
-
}
|
|
277414
|
-
function parseAuthorizeAnnotation(annotation) {
|
|
277415
|
-
const trimmed2 = annotation.trim();
|
|
277416
|
-
let body;
|
|
277417
|
-
if (trimmed2.startsWith(FILE_PREFIX)) {
|
|
277418
|
-
body = trimmed2.slice(FILE_PREFIX.length).trim();
|
|
277419
|
-
} else if (trimmed2.startsWith(SOURCE_PREFIX)) {
|
|
277420
|
-
body = trimmed2.slice(SOURCE_PREFIX.length).trim();
|
|
277421
|
-
} else {
|
|
277422
|
-
return null;
|
|
277423
|
-
}
|
|
277424
|
-
return unwrapQuotedExpression(body);
|
|
277425
|
-
}
|
|
277426
|
-
function collectAuthorizeExprs(annotations) {
|
|
277427
|
-
const exprs = [];
|
|
277428
|
-
for (const annotation of annotations) {
|
|
277429
|
-
const expr = parseAuthorizeAnnotation(annotation);
|
|
277430
|
-
if (expr !== null) {
|
|
277431
|
-
exprs.push(expr);
|
|
277432
|
-
}
|
|
277433
|
-
}
|
|
277434
|
-
return exprs;
|
|
277435
|
-
}
|
|
277436
|
-
function unwrapQuotedExpression(body) {
|
|
277437
|
-
if (body.length < 2 || body[0] !== '"') {
|
|
277438
|
-
throw new Error(`authorize annotation expression must be a double-quoted string, got: ${body || "(empty)"}`);
|
|
277439
|
-
}
|
|
277440
|
-
let expr = "";
|
|
277441
|
-
let i = 1;
|
|
277442
|
-
let closed = false;
|
|
277443
|
-
for (;i < body.length; i++) {
|
|
277444
|
-
const ch = body[i];
|
|
277445
|
-
if (ch === "\\" && i + 1 < body.length) {
|
|
277446
|
-
const next = body[i + 1];
|
|
277447
|
-
if (next === '"' || next === "\\") {
|
|
277448
|
-
expr += next;
|
|
277449
|
-
i++;
|
|
277450
|
-
continue;
|
|
277451
|
-
}
|
|
277452
|
-
}
|
|
277453
|
-
if (ch === '"') {
|
|
277454
|
-
closed = true;
|
|
277455
|
-
i++;
|
|
277456
|
-
break;
|
|
277457
|
-
}
|
|
277458
|
-
expr += ch;
|
|
277459
|
-
}
|
|
277460
|
-
if (!closed) {
|
|
277461
|
-
throw new Error(`authorize annotation has mismatched quotes: ${body}`);
|
|
277462
|
-
}
|
|
277463
|
-
const rest = body.slice(i).trim();
|
|
277464
|
-
if (rest.length > 0) {
|
|
277465
|
-
throw new Error(`authorize annotation has unexpected content after the expression: ${rest}`);
|
|
277466
|
-
}
|
|
277467
|
-
if (expr.trim().length === 0) {
|
|
277468
|
-
throw new Error("authorize annotation has an empty expression body");
|
|
277469
|
-
}
|
|
277470
|
-
return expr;
|
|
277471
|
-
}
|
|
277472
|
-
|
|
277473
|
-
// src/service/materialization_eligibility.ts
|
|
277474
277615
|
function assertMaterializationEligible(persistSource) {
|
|
277475
277616
|
const sourceName = persistSource.name;
|
|
277476
277617
|
let unbound;
|
|
@@ -277612,13 +277753,9 @@ function walkForAuthorize(node, seen, depth) {
|
|
|
277612
277753
|
|
|
277613
277754
|
// src/service/model.ts
|
|
277614
277755
|
import {
|
|
277615
|
-
Annotations as Annotations2,
|
|
277616
277756
|
API,
|
|
277617
277757
|
FixedConnectionMap,
|
|
277618
277758
|
InMemoryURLReader as InMemoryURLReader2,
|
|
277619
|
-
isBasicArray,
|
|
277620
|
-
isJoined,
|
|
277621
|
-
isRepeatedRecord,
|
|
277622
277759
|
isSourceDef as isSourceDef2,
|
|
277623
277760
|
MalloyConfig as MalloyConfig2,
|
|
277624
277761
|
MalloyError as MalloyError2,
|
|
@@ -277629,7 +277766,7 @@ import {
|
|
|
277629
277766
|
MalloySQLParser,
|
|
277630
277767
|
MalloySQLStatementType
|
|
277631
277768
|
} from "@malloydata/malloy-sql";
|
|
277632
|
-
import * as
|
|
277769
|
+
import * as fs6 from "fs/promises";
|
|
277633
277770
|
import { readFileSync } from "fs";
|
|
277634
277771
|
import { createRequire as createRequire2 } from "module";
|
|
277635
277772
|
import * as path6 from "path";
|
|
@@ -277765,10 +277902,32 @@ function modelAnnotations(modelDef) {
|
|
|
277765
277902
|
}
|
|
277766
277903
|
return folded ?? {};
|
|
277767
277904
|
}
|
|
277905
|
+
function ownModelNotes(modelDef) {
|
|
277906
|
+
const registry = modelDef.modelAnnotations ?? {};
|
|
277907
|
+
const isSameDocument = (id) => id === modelDef.modelID || id.startsWith("internal://");
|
|
277908
|
+
const seen = new Set;
|
|
277909
|
+
const texts = [];
|
|
277910
|
+
const visit = (id) => {
|
|
277911
|
+
if (seen.has(id) || !isSameDocument(id))
|
|
277912
|
+
return;
|
|
277913
|
+
seen.add(id);
|
|
277914
|
+
const entry = registry[id];
|
|
277915
|
+
if (!entry)
|
|
277916
|
+
return;
|
|
277917
|
+
for (const dep of entry.inheritsFrom)
|
|
277918
|
+
visit(dep);
|
|
277919
|
+
texts.push(...ownLevelNoteTexts(entry.ownNotes));
|
|
277920
|
+
};
|
|
277921
|
+
visit(modelDef.modelID);
|
|
277922
|
+
return texts;
|
|
277923
|
+
}
|
|
277768
277924
|
function annotationTexts(annote) {
|
|
277769
277925
|
const texts = new Annotations(annote).texts();
|
|
277770
277926
|
return texts.length > 0 ? texts : undefined;
|
|
277771
277927
|
}
|
|
277928
|
+
function ownLevelNoteTexts(annote) {
|
|
277929
|
+
return [...annote?.blockNotes ?? [], ...annote?.notes ?? []].map((note) => note.text);
|
|
277930
|
+
}
|
|
277772
277931
|
|
|
277773
277932
|
// src/service/filter.ts
|
|
277774
277933
|
var VALID_FILTER_TYPES = new Set([
|
|
@@ -277984,22 +278143,47 @@ function resolveModelQueryRowLimit(userLimit, { defaultLimit, maxRows }) {
|
|
|
277984
278143
|
function queryRowLimitSource(userLimit) {
|
|
277985
278144
|
return userLimit && userLimit > 0 ? "query" : "server_default";
|
|
277986
278145
|
}
|
|
277987
|
-
function
|
|
278146
|
+
function assertWithinModelRowLimit(rowCount, maxRows, source) {
|
|
277988
278147
|
if (maxRows > 0 && rowCount > maxRows) {
|
|
277989
278148
|
recordQueryCapExceeded("rows", source);
|
|
277990
278149
|
throw new PayloadTooLargeError(`Query returned more than ${maxRows} rows. Refine the query (add a LIMIT or more selective WHERE) or raise PUBLISHER_MAX_QUERY_ROWS.`);
|
|
277991
278150
|
}
|
|
277992
|
-
|
|
278151
|
+
}
|
|
278152
|
+
function assertWithinModelByteLimit(serialized, maxBytes, source) {
|
|
278153
|
+
if (maxBytes <= 0)
|
|
278154
|
+
return;
|
|
278155
|
+
const serializedBytes = Buffer.byteLength(serialized, "utf8");
|
|
278156
|
+
if (serializedBytes > maxBytes) {
|
|
277993
278157
|
recordQueryCapExceeded("bytes", source);
|
|
277994
278158
|
throw new PayloadTooLargeError(`Query response exceeded ${maxBytes} bytes (was ${serializedBytes}). Project fewer columns, add a LIMIT, or raise PUBLISHER_MAX_RESPONSE_BYTES.`);
|
|
277995
278159
|
}
|
|
277996
278160
|
}
|
|
278161
|
+
function stringifyQueryResponse(response, rowCount, maxBytes, source, replacer) {
|
|
278162
|
+
try {
|
|
278163
|
+
return JSON.stringify(response, replacer);
|
|
278164
|
+
} catch (error) {
|
|
278165
|
+
if (!(error instanceof RangeError) || /call stack/i.test(error.message)) {
|
|
278166
|
+
throw error;
|
|
278167
|
+
}
|
|
278168
|
+
recordQueryCapExceeded("unserializable", source);
|
|
278169
|
+
throw new ResponseUnserializableError(`Query response could not be serialized: the ${rowCount}-row result is too large to turn into JSON${maxBytes > 0 ? ` (byte cap: ${maxBytes})` : ""}. Project fewer columns, add a LIMIT, or filter wide values.`);
|
|
278170
|
+
}
|
|
278171
|
+
}
|
|
278172
|
+
|
|
278173
|
+
// src/json_utils.ts
|
|
278174
|
+
var MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
|
278175
|
+
function bigIntReplacer(_key, value) {
|
|
278176
|
+
if (typeof value === "bigint") {
|
|
278177
|
+
return value > MAX_SAFE_BIGINT || value < -MAX_SAFE_BIGINT ? value.toString() : Number(value);
|
|
278178
|
+
}
|
|
278179
|
+
return value;
|
|
278180
|
+
}
|
|
277997
278181
|
|
|
277998
278182
|
// src/service/query_text.ts
|
|
277999
278183
|
function extractRunTargetSourceName(query) {
|
|
278000
278184
|
if (!query)
|
|
278001
278185
|
return;
|
|
278002
|
-
const runMatch = query.match(/run\s*:\s*(?:`([^`]+)`|(\w+))
|
|
278186
|
+
const runMatch = query.match(/run\s*:\s*(?:`([^`]+)`|(\w+))/);
|
|
278003
278187
|
const arrowMatch = query.match(/^\s*(?:`([^`]+)`|(\w+))\s*->/m);
|
|
278004
278188
|
return runMatch?.[1] ?? runMatch?.[2] ?? arrowMatch?.[1] ?? arrowMatch?.[2];
|
|
278005
278189
|
}
|
|
@@ -278020,6 +278204,7 @@ import {
|
|
|
278020
278204
|
function extractSourcesFromModelDef(modelDef, givens, onParseError) {
|
|
278021
278205
|
const filterMap = new Map;
|
|
278022
278206
|
const authorizeMap = new Map;
|
|
278207
|
+
const ownAuthorizeSources = [];
|
|
278023
278208
|
const fileLevelAuthorize = collectAuthorizeExprs((modelAnnotations(modelDef).notes ?? []).map((note) => note.text));
|
|
278024
278209
|
const sources = Object.values(modelDef.contents).filter((obj) => isSourceDef(obj)).map((sourceObj) => {
|
|
278025
278210
|
const struct = sourceObj;
|
|
@@ -278057,16 +278242,34 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
|
|
|
278057
278242
|
onParseError?.(sourceName, err);
|
|
278058
278243
|
}
|
|
278059
278244
|
}
|
|
278060
|
-
const ownNotes = (struct.annotations
|
|
278245
|
+
const ownNotes = ownLevelNoteTexts(struct.annotations);
|
|
278246
|
+
const ownGates = collectAuthorizeExprs(ownNotes);
|
|
278247
|
+
let inheritedGates = [];
|
|
278248
|
+
if (ownGates.length === 0) {
|
|
278249
|
+
for (let cur2 = struct.annotations?.inherits;cur2; cur2 = cur2.inherits) {
|
|
278250
|
+
const exprs = collectAuthorizeExprs(ownLevelNoteTexts(cur2));
|
|
278251
|
+
if (exprs.length > 0) {
|
|
278252
|
+
inheritedGates = exprs;
|
|
278253
|
+
break;
|
|
278254
|
+
}
|
|
278255
|
+
}
|
|
278256
|
+
}
|
|
278061
278257
|
const effective = [
|
|
278062
278258
|
...fileLevelAuthorize,
|
|
278063
|
-
...
|
|
278259
|
+
...ownGates.length > 0 ? ownGates : inheritedGates
|
|
278064
278260
|
];
|
|
278065
278261
|
let authorize;
|
|
278066
278262
|
if (effective.length > 0) {
|
|
278067
278263
|
authorizeMap.set(sourceName, effective);
|
|
278068
278264
|
authorize = effective;
|
|
278069
278265
|
}
|
|
278266
|
+
const ownEffective = [...fileLevelAuthorize, ...ownGates];
|
|
278267
|
+
if (ownEffective.length > 0) {
|
|
278268
|
+
ownAuthorizeSources.push({
|
|
278269
|
+
name: sourceName,
|
|
278270
|
+
authorize: ownEffective
|
|
278271
|
+
});
|
|
278272
|
+
}
|
|
278070
278273
|
const views = struct.fields.filter((field) => field.type === "turtle").filter((turtle) => turtle.pipeline.map((stage) => stage.type).every((type) => type === "reduce")).map((turtle) => ({
|
|
278071
278274
|
name: turtle.as || turtle.name,
|
|
278072
278275
|
annotations: annotationTexts(turtle.annotations)
|
|
@@ -278080,7 +278283,7 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
|
|
|
278080
278283
|
authorize
|
|
278081
278284
|
};
|
|
278082
278285
|
});
|
|
278083
|
-
return { sources, filterMap, authorizeMap };
|
|
278286
|
+
return { sources, filterMap, authorizeMap, ownAuthorizeSources };
|
|
278084
278287
|
}
|
|
278085
278288
|
function extractQueriesFromModelDef(modelDef) {
|
|
278086
278289
|
const isNamedQuery = (obj) => obj.type === "query";
|
|
@@ -278093,9 +278296,7 @@ function extractQueriesFromModelDef(modelDef) {
|
|
|
278093
278296
|
|
|
278094
278297
|
// src/service/model.ts
|
|
278095
278298
|
var MALLOY_VERSION = createRequire2(import.meta.url)("@malloydata/malloy/package.json").version;
|
|
278096
|
-
|
|
278097
|
-
return field.type === "record" || isBasicArray(field) || isRepeatedRecord(field);
|
|
278098
|
-
}
|
|
278299
|
+
var ANCESTOR_WALK_MAX_DEPTH = 32;
|
|
278099
278300
|
function quoteMalloyIdentifier(name) {
|
|
278100
278301
|
return "`" + (name ?? "").replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
|
|
278101
278302
|
}
|
|
@@ -278123,6 +278324,7 @@ class Model {
|
|
|
278123
278324
|
discoveryCurationEnabled = false;
|
|
278124
278325
|
queryBoundary = { mode: "all", exploresDeclared: false, isQueryEntryPoint: true };
|
|
278125
278326
|
freshnessResolver;
|
|
278327
|
+
entryPointGatesBySource = new Map;
|
|
278126
278328
|
meter = publisherMeter();
|
|
278127
278329
|
queryExecutionHistogram = this.meter.createHistogram("malloy_model_query_duration", {
|
|
278128
278330
|
description: "How long it takes to execute a Malloy model query",
|
|
@@ -278147,6 +278349,18 @@ class Model {
|
|
|
278147
278349
|
} catch {
|
|
278148
278350
|
this.fileLevelAuthorize = [];
|
|
278149
278351
|
}
|
|
278352
|
+
try {
|
|
278353
|
+
this.entryPointGatesBySource = this.computeEntryPointGatesBySource();
|
|
278354
|
+
} catch {
|
|
278355
|
+
this.entryPointGatesBySource = new Map;
|
|
278356
|
+
}
|
|
278357
|
+
for (const source of this.sources ?? []) {
|
|
278358
|
+
if (!source.name)
|
|
278359
|
+
continue;
|
|
278360
|
+
const exprs = this.entryPointGatesBySource.get(source.name)?.flatMap((g) => g.exprs);
|
|
278361
|
+
if (exprs && exprs.length > 0)
|
|
278362
|
+
source.authorize = exprs;
|
|
278363
|
+
}
|
|
278150
278364
|
try {
|
|
278151
278365
|
this.authorizeReferencedGivenNames = this.computeAuthorizeReferencedGivenNames();
|
|
278152
278366
|
} catch {
|
|
@@ -278191,17 +278405,24 @@ class Model {
|
|
|
278191
278405
|
}
|
|
278192
278406
|
};
|
|
278193
278407
|
addExprs(this.fileLevelAuthorize);
|
|
278408
|
+
for (const gates of this.entryPointGatesBySource.values()) {
|
|
278409
|
+
for (const { exprs } of gates)
|
|
278410
|
+
addExprs(exprs);
|
|
278411
|
+
}
|
|
278412
|
+
return names;
|
|
278413
|
+
}
|
|
278414
|
+
computeEntryPointGatesBySource() {
|
|
278415
|
+
const byName = new Map;
|
|
278194
278416
|
const modelDef = this.modelDef;
|
|
278195
278417
|
if (!modelDef)
|
|
278196
|
-
return
|
|
278418
|
+
return byName;
|
|
278197
278419
|
for (const entry of Object.values(modelDef.contents)) {
|
|
278198
278420
|
if (!isSourceDef2(entry))
|
|
278199
278421
|
continue;
|
|
278200
|
-
|
|
278201
|
-
|
|
278202
|
-
}
|
|
278422
|
+
const name = entry.as ?? entry.name;
|
|
278423
|
+
byName.set(name, this.collectEntryPointGates(entry, modelDef, new Set, true));
|
|
278203
278424
|
}
|
|
278204
|
-
return
|
|
278425
|
+
return byName;
|
|
278205
278426
|
}
|
|
278206
278427
|
hasAuthorize() {
|
|
278207
278428
|
return this.fileLevelAuthorize.length > 0 || (this.sources?.some((s) => (s.authorize?.length ?? 0) > 0) ?? false);
|
|
@@ -278213,9 +278434,16 @@ class Model {
|
|
|
278213
278434
|
return this.fileLevelAuthorize;
|
|
278214
278435
|
}
|
|
278215
278436
|
async assertAuthorized(sourceName, givens) {
|
|
278437
|
+
const gates = sourceName ? this.entryPointGatesBySource.get(sourceName) : undefined;
|
|
278438
|
+
if (gates) {
|
|
278439
|
+
for (const { label, exprs, selfContained, ambientPrefix } of gates) {
|
|
278440
|
+
await this.assertAuthorizedExprs(label, exprs, givens, selfContained, ambientPrefix);
|
|
278441
|
+
}
|
|
278442
|
+
return;
|
|
278443
|
+
}
|
|
278216
278444
|
await this.assertAuthorizedExprs(sourceName ?? "(query)", this.effectiveAuthorizeFor(sourceName), givens);
|
|
278217
278445
|
}
|
|
278218
|
-
async assertAuthorizedExprs(label, exprs, givens, selfContainedFirst = false) {
|
|
278446
|
+
async assertAuthorizedExprs(label, exprs, givens, selfContainedFirst = false, ambientPrefix = 0) {
|
|
278219
278447
|
if (exprs.length === 0)
|
|
278220
278448
|
return;
|
|
278221
278449
|
const deny = () => {
|
|
@@ -278225,7 +278453,7 @@ class Model {
|
|
|
278225
278453
|
deny();
|
|
278226
278454
|
let passed = false;
|
|
278227
278455
|
try {
|
|
278228
|
-
passed = await evaluateAuthorize(this.modelMaterializer, exprs, givens, this.givenDeclaredTypes(), { selfContainedFirst });
|
|
278456
|
+
passed = await evaluateAuthorize(this.modelMaterializer, exprs, givens, this.givenDeclaredTypes(), { selfContainedFirst, ambientPrefix });
|
|
278229
278457
|
} catch (err) {
|
|
278230
278458
|
logger.debug("Authorize probe failed; denying", {
|
|
278231
278459
|
sourceName: label,
|
|
@@ -278240,27 +278468,19 @@ class Model {
|
|
|
278240
278468
|
async assertAuthorizedForAllSources(runnable, givens) {
|
|
278241
278469
|
const ownSourceName = await this.resolveAuthorizeSourceFromRunnable(runnable);
|
|
278242
278470
|
await this.assertAuthorized(ownSourceName, givens);
|
|
278243
|
-
const { struct, modelDef, compositeResolvedSourceDef
|
|
278471
|
+
const { struct, modelDef, compositeResolvedSourceDef } = await this.resolveRunTargetStruct(runnable);
|
|
278244
278472
|
const seen = new Set;
|
|
278245
|
-
const
|
|
278246
|
-
if (modelDef) {
|
|
278247
|
-
const runTargetLabel = ownSourceName ?? "(run target)";
|
|
278248
|
-
for (const field of extendSources) {
|
|
278249
|
-
const { resolved, denyGate } = this.classifyJoinedField(field, runTargetLabel);
|
|
278250
|
-
if (denyGate) {
|
|
278251
|
-
joinedGates.push(denyGate);
|
|
278252
|
-
continue;
|
|
278253
|
-
}
|
|
278254
|
-
if (!resolved)
|
|
278255
|
-
continue;
|
|
278256
|
-
joinedGates.push(...this.collectAllReachableGates(resolved, modelDef, seen));
|
|
278257
|
-
}
|
|
278258
|
-
}
|
|
278473
|
+
const entryPointGates = this.collectEntryPointGates(struct, modelDef, seen, true);
|
|
278259
278474
|
if (compositeResolvedSourceDef && modelDef) {
|
|
278260
|
-
|
|
278475
|
+
entryPointGates.push(...this.collectEntryPointGates(compositeResolvedSourceDef, modelDef, seen, true));
|
|
278261
278476
|
}
|
|
278262
|
-
for (const {
|
|
278263
|
-
|
|
278477
|
+
for (const {
|
|
278478
|
+
label,
|
|
278479
|
+
exprs,
|
|
278480
|
+
selfContained,
|
|
278481
|
+
ambientPrefix
|
|
278482
|
+
} of entryPointGates) {
|
|
278483
|
+
await this.assertAuthorizedExprs(label, exprs, givens, selfContained, ambientPrefix);
|
|
278264
278484
|
}
|
|
278265
278485
|
}
|
|
278266
278486
|
async resolveRunTargetStruct(runnable) {
|
|
@@ -278271,112 +278491,120 @@ class Model {
|
|
|
278271
278491
|
return {
|
|
278272
278492
|
struct: undefined,
|
|
278273
278493
|
modelDef: undefined,
|
|
278274
|
-
compositeResolvedSourceDef: undefined
|
|
278275
|
-
extendSources: []
|
|
278494
|
+
compositeResolvedSourceDef: undefined
|
|
278276
278495
|
};
|
|
278277
278496
|
const structRef = prepared._query?.structRef;
|
|
278278
278497
|
const struct = typeof structRef === "string" ? modelDef.contents[structRef] : structRef;
|
|
278279
|
-
const extendSources = (prepared._query?.pipeline ?? []).flatMap((segment) => segment.extendSource ?? []);
|
|
278280
278498
|
return {
|
|
278281
278499
|
struct: struct && typeof struct === "object" ? struct : undefined,
|
|
278282
278500
|
modelDef,
|
|
278283
|
-
compositeResolvedSourceDef: prepared._query?.compositeResolvedSourceDef
|
|
278284
|
-
extendSources
|
|
278501
|
+
compositeResolvedSourceDef: prepared._query?.compositeResolvedSourceDef
|
|
278285
278502
|
};
|
|
278286
278503
|
} catch {
|
|
278287
278504
|
return {
|
|
278288
278505
|
struct: undefined,
|
|
278289
278506
|
modelDef: undefined,
|
|
278290
|
-
compositeResolvedSourceDef: undefined
|
|
278291
|
-
extendSources: []
|
|
278507
|
+
compositeResolvedSourceDef: undefined
|
|
278292
278508
|
};
|
|
278293
278509
|
}
|
|
278294
278510
|
}
|
|
278295
|
-
gateExprsForOwnAnnotations(struct) {
|
|
278296
|
-
const ownNotes = (struct.annotations
|
|
278511
|
+
gateExprsForOwnAnnotations(struct, modelDef) {
|
|
278512
|
+
const ownNotes = ownLevelNoteTexts(struct.annotations);
|
|
278297
278513
|
try {
|
|
278298
|
-
|
|
278299
|
-
|
|
278300
|
-
|
|
278301
|
-
|
|
278514
|
+
const own = collectAuthorizeExprs(ownNotes);
|
|
278515
|
+
if (own.length > 0) {
|
|
278516
|
+
return {
|
|
278517
|
+
exprs: [...this.fileLevelAuthorize, ...own],
|
|
278518
|
+
fromAncestor: false,
|
|
278519
|
+
ambientPrefix: this.fileLevelAuthorize.length
|
|
278520
|
+
};
|
|
278521
|
+
}
|
|
278522
|
+
const ancestor = this.ancestorGateExprs(struct, modelDef);
|
|
278523
|
+
return {
|
|
278524
|
+
exprs: [...this.fileLevelAuthorize, ...ancestor],
|
|
278525
|
+
fromAncestor: ancestor.length > 0,
|
|
278526
|
+
ambientPrefix: this.fileLevelAuthorize.length
|
|
278527
|
+
};
|
|
278302
278528
|
} catch {
|
|
278303
|
-
return ["false"];
|
|
278529
|
+
return { exprs: ["false"], fromAncestor: false, ambientPrefix: 0 };
|
|
278304
278530
|
}
|
|
278305
278531
|
}
|
|
278306
|
-
|
|
278307
|
-
|
|
278308
|
-
|
|
278309
|
-
|
|
278310
|
-
|
|
278311
|
-
|
|
278312
|
-
|
|
278313
|
-
modelPath: this.modelPath,
|
|
278314
|
-
parentSource: parentLabel,
|
|
278315
|
-
fieldName: field.name,
|
|
278316
|
-
fieldType: field.type
|
|
278317
|
-
});
|
|
278318
|
-
return {
|
|
278319
|
-
denyGate: {
|
|
278320
|
-
label: `${parentLabel} (unresolvable joined source)`,
|
|
278321
|
-
exprs: ["false"],
|
|
278322
|
-
selfContained: false
|
|
278323
|
-
}
|
|
278324
|
-
};
|
|
278532
|
+
ancestorGateExprs(struct, modelDef, seen = new Set) {
|
|
278533
|
+
let inherited = struct.annotations?.inherits;
|
|
278534
|
+
for (let depth = 0;inherited && depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
|
|
278535
|
+
const exprs2 = collectAuthorizeExprs(ownLevelNoteTexts(inherited));
|
|
278536
|
+
if (exprs2.length > 0)
|
|
278537
|
+
return exprs2;
|
|
278538
|
+
inherited = inherited.inherits;
|
|
278325
278539
|
}
|
|
278326
|
-
|
|
278540
|
+
if (inherited)
|
|
278541
|
+
return ["false"];
|
|
278542
|
+
seen.add(struct);
|
|
278543
|
+
if (seen.size > ANCESTOR_WALK_MAX_DEPTH)
|
|
278544
|
+
return ["false"];
|
|
278545
|
+
const declared = this.resolveDeclaredSource(struct, modelDef);
|
|
278546
|
+
if (declared.kind === "unresolvable")
|
|
278547
|
+
return ["false"];
|
|
278548
|
+
if (declared.kind === "none" || seen.has(declared.source))
|
|
278549
|
+
return [];
|
|
278550
|
+
const exprs = collectAuthorizeExprs(ownLevelNoteTexts(declared.source.annotations));
|
|
278551
|
+
return exprs.length > 0 ? exprs : this.ancestorGateExprs(declared.source, modelDef, seen);
|
|
278327
278552
|
}
|
|
278328
|
-
|
|
278553
|
+
resolveDeclaredSource(struct, modelDef) {
|
|
278554
|
+
if (!modelDef)
|
|
278555
|
+
return { kind: "none" };
|
|
278556
|
+
let sawBrokenEntry = false;
|
|
278557
|
+
for (const id of [struct.referenceID, struct.sourceID]) {
|
|
278558
|
+
const entry = id ? modelDef.sourceRegistry?.[id]?.entry : undefined;
|
|
278559
|
+
if (!entry)
|
|
278560
|
+
continue;
|
|
278561
|
+
const declared = entry.type === "source_registry_reference" ? modelDef.contents[entry.name] : entry;
|
|
278562
|
+
if (declared === struct)
|
|
278563
|
+
continue;
|
|
278564
|
+
if (!declared || !isSourceDef2(declared)) {
|
|
278565
|
+
sawBrokenEntry = true;
|
|
278566
|
+
continue;
|
|
278567
|
+
}
|
|
278568
|
+
return { kind: "resolved", source: declared };
|
|
278569
|
+
}
|
|
278570
|
+
return sawBrokenEntry ? { kind: "unresolvable" } : { kind: "none" };
|
|
278571
|
+
}
|
|
278572
|
+
collectEntryPointGates(struct, modelDef, seen = new Set, treatAsOwnGate = false) {
|
|
278329
278573
|
if (!struct || !modelDef || seen.has(struct))
|
|
278330
278574
|
return [];
|
|
278331
278575
|
seen.add(struct);
|
|
278332
278576
|
const results = [];
|
|
278333
278577
|
const label = struct.as ?? struct.name;
|
|
278334
|
-
const
|
|
278578
|
+
const {
|
|
278579
|
+
exprs: ownExprs,
|
|
278580
|
+
fromAncestor,
|
|
278581
|
+
ambientPrefix
|
|
278582
|
+
} = this.gateExprsForOwnAnnotations(struct, modelDef);
|
|
278335
278583
|
if (ownExprs.length > 0) {
|
|
278336
278584
|
results.push({
|
|
278337
278585
|
label,
|
|
278338
278586
|
exprs: ownExprs,
|
|
278339
|
-
selfContained: !treatAsOwnGate
|
|
278587
|
+
selfContained: fromAncestor || !treatAsOwnGate,
|
|
278588
|
+
ambientPrefix
|
|
278340
278589
|
});
|
|
278341
278590
|
}
|
|
278342
|
-
for (const field of struct.fields) {
|
|
278343
|
-
const { resolved, denyGate } = this.classifyJoinedField(field, label);
|
|
278344
|
-
if (denyGate) {
|
|
278345
|
-
results.push(denyGate);
|
|
278346
|
-
continue;
|
|
278347
|
-
}
|
|
278348
|
-
if (!resolved)
|
|
278349
|
-
continue;
|
|
278350
|
-
const joinedSource = resolved;
|
|
278351
|
-
results.push(...this.collectAllReachableGates(joinedSource, modelDef, seen));
|
|
278352
|
-
if (joinedSource.type === "composite") {
|
|
278353
|
-
const members = joinedSource.sources;
|
|
278354
|
-
for (const member of members) {
|
|
278355
|
-
results.push(...this.collectAllReachableGates(member, modelDef, seen));
|
|
278356
|
-
}
|
|
278357
|
-
}
|
|
278358
|
-
}
|
|
278359
278591
|
const duck = struct;
|
|
278360
278592
|
if (duck.type === "query_source") {
|
|
278361
278593
|
const ref = duck.query?.structRef;
|
|
278362
278594
|
const base = typeof ref === "string" ? modelDef.contents[ref] : ref;
|
|
278363
278595
|
if (base && isSourceDef2(base)) {
|
|
278364
|
-
results.push(...this.
|
|
278596
|
+
results.push(...this.collectEntryPointGates(base, modelDef, seen));
|
|
278597
|
+
} else {
|
|
278598
|
+
results.push({
|
|
278599
|
+
label,
|
|
278600
|
+
exprs: ["false"],
|
|
278601
|
+
selfContained: true,
|
|
278602
|
+
ambientPrefix: 0
|
|
278603
|
+
});
|
|
278365
278604
|
}
|
|
278366
278605
|
const resolved = duck.query?.compositeResolvedSourceDef;
|
|
278367
278606
|
if (resolved) {
|
|
278368
|
-
results.push(...this.
|
|
278369
|
-
}
|
|
278370
|
-
const innerJoins = (duck.query?.pipeline ?? []).flatMap((segment) => segment.extendSource ?? []);
|
|
278371
|
-
for (const field of innerJoins) {
|
|
278372
|
-
const { resolved: innerJoinSource, denyGate: innerJoinDenyGate } = this.classifyJoinedField(field, label);
|
|
278373
|
-
if (innerJoinDenyGate) {
|
|
278374
|
-
results.push(innerJoinDenyGate);
|
|
278375
|
-
continue;
|
|
278376
|
-
}
|
|
278377
|
-
if (!innerJoinSource)
|
|
278378
|
-
continue;
|
|
278379
|
-
results.push(...this.collectAllReachableGates(innerJoinSource, modelDef, seen));
|
|
278607
|
+
results.push(...this.collectEntryPointGates(resolved, modelDef, seen));
|
|
278380
278608
|
}
|
|
278381
278609
|
}
|
|
278382
278610
|
return results;
|
|
@@ -278434,7 +278662,7 @@ class Model {
|
|
|
278434
278662
|
sources = sourceResult.sources;
|
|
278435
278663
|
filterMap = sourceResult.filterMap;
|
|
278436
278664
|
queries = Model.getQueries(modelDef);
|
|
278437
|
-
await validateAuthorizeProbes(modelMaterializer,
|
|
278665
|
+
await validateAuthorizeProbes(modelMaterializer, sourceResult.ownAuthorizeSources);
|
|
278438
278666
|
const imports = modelDef.imports || [];
|
|
278439
278667
|
const importedSourceNames = new Set;
|
|
278440
278668
|
for (const importLocation of imports) {
|
|
@@ -278662,7 +278890,7 @@ class Model {
|
|
|
278662
278890
|
logger.warn(`Invalid renderer configuration on '${target.label}': ${errors2.map((e) => e.message).join("; ")}`);
|
|
278663
278891
|
for (const e of errors2) {
|
|
278664
278892
|
findings.push({
|
|
278665
|
-
|
|
278893
|
+
subject: target.label,
|
|
278666
278894
|
message: e.message,
|
|
278667
278895
|
severity: "error"
|
|
278668
278896
|
});
|
|
@@ -278814,7 +279042,7 @@ class Model {
|
|
|
278814
279042
|
return { ...b, schema, refinements };
|
|
278815
279043
|
}).filter((b) => b.schema.length > 0);
|
|
278816
279044
|
}
|
|
278817
|
-
async getQueryResults(sourceName, queryName, query, filterParams, bypassFilters, givens, abortSignal, queryMetadataInput) {
|
|
279045
|
+
async getQueryResults(sourceName, queryName, query, filterParams, bypassFilters, givens, abortSignal, queryMetadataInput, responseShape = "full") {
|
|
278818
279046
|
const startTime = performance.now();
|
|
278819
279047
|
if (this.compilationError) {
|
|
278820
279048
|
if (this.compilationError instanceof MalloyError2 || this.compilationError instanceof ModelCompilationError) {
|
|
@@ -278830,18 +279058,33 @@ class Model {
|
|
|
278830
279058
|
if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
|
|
278831
279059
|
throw new BadRequestError("Model has no queryable entities.");
|
|
278832
279060
|
const boundary = this.assertQueryBoundaryEarly(sourceName, queryName, query);
|
|
278833
|
-
const
|
|
279061
|
+
const surfaceName = extractRunTargetSourceName(query);
|
|
279062
|
+
const earlySource = sourceName || (queryName ? this.queries?.find((q) => q.name === queryName)?.sourceName : undefined) || (surfaceName && !this.sources?.some((s) => s.name === surfaceName) ? this.queries?.find((q) => q.name === surfaceName)?.sourceName : undefined) || surfaceName;
|
|
278834
279063
|
if (earlySource) {
|
|
278835
279064
|
await this.assertAuthorized(earlySource, givens ?? {});
|
|
278836
279065
|
}
|
|
278837
279066
|
try {
|
|
279067
|
+
for (const [field, callerText] of [
|
|
279068
|
+
["query", query],
|
|
279069
|
+
["source_name", sourceName],
|
|
279070
|
+
["query_name", queryName]
|
|
279071
|
+
]) {
|
|
279072
|
+
if (!callerText)
|
|
279073
|
+
continue;
|
|
279074
|
+
try {
|
|
279075
|
+
assertNoCallerAuthorizeAnnotation(callerText);
|
|
279076
|
+
} catch (err) {
|
|
279077
|
+
recordAuthorizeGuardRejection(field);
|
|
279078
|
+
throw err;
|
|
279079
|
+
}
|
|
279080
|
+
}
|
|
278838
279081
|
let queryString;
|
|
278839
279082
|
if (!sourceName && !queryName && query) {
|
|
278840
279083
|
queryString = `
|
|
278841
279084
|
` + query;
|
|
278842
279085
|
} else if (queryName && !query) {
|
|
278843
279086
|
queryString = `
|
|
278844
|
-
run: ${sourceName ? sourceName
|
|
279087
|
+
run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMalloyIdentifier(queryName)}`;
|
|
278845
279088
|
} else {
|
|
278846
279089
|
const endTime = performance.now();
|
|
278847
279090
|
const executionTime2 = endTime - startTime;
|
|
@@ -279016,9 +279259,10 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
279016
279259
|
servedFrom = "live_fallback";
|
|
279017
279260
|
executionTime = performance.now() - startTime;
|
|
279018
279261
|
}
|
|
279262
|
+
assertWithinModelRowLimit(queryResults.totalRows, maxRows, "model_query");
|
|
279019
279263
|
const wrappedResult = API.util.wrapResult(queryResults);
|
|
279020
|
-
const
|
|
279021
|
-
|
|
279264
|
+
const serializedResult = stringifyQueryResponse(responseShape === "compact" ? queryResults.data.value : wrappedResult, queryResults.totalRows, maxBytes, "model_query", responseShape === "compact" ? bigIntReplacer : undefined);
|
|
279265
|
+
assertWithinModelByteLimit(serializedResult, maxBytes, "model_query");
|
|
279022
279266
|
this.queryExecutionHistogram.record(executionTime, {
|
|
279023
279267
|
"malloy.model.path": this.modelPath,
|
|
279024
279268
|
"malloy.model.query.name": queryName,
|
|
@@ -279032,6 +279276,7 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
279032
279276
|
});
|
|
279033
279277
|
return {
|
|
279034
279278
|
result: wrappedResult,
|
|
279279
|
+
serializedResult,
|
|
279035
279280
|
compactResult: queryResults.data.value,
|
|
279036
279281
|
modelInfo: this.modelInfo,
|
|
279037
279282
|
dataStyles: this.dataStyles,
|
|
@@ -279097,8 +279342,8 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
279097
279342
|
queryInfo: cell.queryInfo ? JSON.stringify(cell.queryInfo) : undefined
|
|
279098
279343
|
};
|
|
279099
279344
|
});
|
|
279100
|
-
const allAnnotations = this.modelDef ?
|
|
279101
|
-
|
|
279345
|
+
const allAnnotations = this.modelDef ? ownModelNotes(this.modelDef) : [];
|
|
279346
|
+
const notebook = {
|
|
279102
279347
|
type: "notebook",
|
|
279103
279348
|
packageName: this.packageName,
|
|
279104
279349
|
modelPath: this.modelPath,
|
|
@@ -279109,6 +279354,7 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
279109
279354
|
annotations: allAnnotations,
|
|
279110
279355
|
notebookCells
|
|
279111
279356
|
};
|
|
279357
|
+
return notebook;
|
|
279112
279358
|
}
|
|
279113
279359
|
async executeNotebookCell(cellIndex, filterParams, bypassFilters, givens, abortSignal, queryMetadataInput) {
|
|
279114
279360
|
if (this.compilationError) {
|
|
@@ -279169,9 +279415,12 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
279169
279415
|
});
|
|
279170
279416
|
const query = (await runnableToExecute.getPreparedQuery())._query;
|
|
279171
279417
|
queryName = query.as || query.name;
|
|
279172
|
-
|
|
279418
|
+
if (result?._queryResult) {
|
|
279419
|
+
assertWithinModelRowLimit(result.totalRows, cellMaxRows, "notebook_cell");
|
|
279420
|
+
}
|
|
279421
|
+
queryResult = result?._queryResult && this.modelInfo && stringifyQueryResponse(API.util.wrapResult(result), result.totalRows, cellMaxBytes, "notebook_cell");
|
|
279173
279422
|
if (result?._queryResult && queryResult) {
|
|
279174
|
-
|
|
279423
|
+
assertWithinModelByteLimit(queryResult, cellMaxBytes, "notebook_cell");
|
|
279175
279424
|
}
|
|
279176
279425
|
} catch (error) {
|
|
279177
279426
|
if (error instanceof FilterValidationError) {
|
|
@@ -279215,7 +279464,7 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
279215
279464
|
static async getModelRuntime(packagePath, modelPath, malloyConfig, options) {
|
|
279216
279465
|
const fullModelPath = path6.join(packagePath, modelPath);
|
|
279217
279466
|
try {
|
|
279218
|
-
if (!(await
|
|
279467
|
+
if (!(await fs6.stat(fullModelPath)).isFile()) {
|
|
279219
279468
|
throw new ModelNotFoundError(`${modelPath} is not a file.`);
|
|
279220
279469
|
}
|
|
279221
279470
|
} catch {
|
|
@@ -279253,8 +279502,12 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
279253
279502
|
return extractQueriesFromModelDef(modelDef);
|
|
279254
279503
|
}
|
|
279255
279504
|
static getSources(modelDef, givens) {
|
|
279256
|
-
const { sources, filterMap } = extractSourcesFromModelDef(modelDef, givens, (sourceName, err) => logger.warn(`Failed to parse filter annotations on source "${sourceName}"`, { error: err }));
|
|
279257
|
-
return {
|
|
279505
|
+
const { sources, filterMap, ownAuthorizeSources } = extractSourcesFromModelDef(modelDef, givens, (sourceName, err) => logger.warn(`Failed to parse filter annotations on source "${sourceName}"`, { error: err }));
|
|
279506
|
+
return {
|
|
279507
|
+
sources,
|
|
279508
|
+
filterMap,
|
|
279509
|
+
ownAuthorizeSources
|
|
279510
|
+
};
|
|
279258
279511
|
}
|
|
279259
279512
|
static async getModelMaterializer(runtime, importBaseURL, modelURL, modelPath) {
|
|
279260
279513
|
if (modelPath.endsWith(MODEL_FILE_SUFFIX)) {
|
|
@@ -279284,7 +279537,7 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
279284
279537
|
let fileContents = undefined;
|
|
279285
279538
|
let parse = undefined;
|
|
279286
279539
|
try {
|
|
279287
|
-
fileContents = await
|
|
279540
|
+
fileContents = await fs6.readFile(modelURL, "utf8");
|
|
279288
279541
|
} catch {
|
|
279289
279542
|
throw new ModelNotFoundError("Model not found: " + modelPath);
|
|
279290
279543
|
}
|
|
@@ -279377,7 +279630,7 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
279377
279630
|
async getFileText(packagePath) {
|
|
279378
279631
|
const fullPath = path6.join(packagePath, this.modelPath);
|
|
279379
279632
|
try {
|
|
279380
|
-
return await
|
|
279633
|
+
return await fs6.readFile(fullPath, "utf8");
|
|
279381
279634
|
} catch {
|
|
279382
279635
|
throw new ModelNotFoundError(`Model file not found: ${this.modelPath}`);
|
|
279383
279636
|
}
|
|
@@ -279631,7 +279884,7 @@ function detectDroppedPersistSources(malloyModel, recognizedNames) {
|
|
|
279631
279884
|
continue;
|
|
279632
279885
|
let isPersist = false;
|
|
279633
279886
|
try {
|
|
279634
|
-
isPersist = new
|
|
279887
|
+
isPersist = new Annotations2(def.annotations).parseAsTag("@").tag.has("persist");
|
|
279635
279888
|
} catch {
|
|
279636
279889
|
continue;
|
|
279637
279890
|
}
|
|
@@ -279758,7 +280011,7 @@ function collectSourceEligibility(sources) {
|
|
|
279758
280011
|
}
|
|
279759
280012
|
|
|
279760
280013
|
// src/service/materialization_config_validation.ts
|
|
279761
|
-
function metadataWarnings(level, metadata,
|
|
280014
|
+
function metadataWarnings(level, metadata, subject) {
|
|
279762
280015
|
if (!metadata)
|
|
279763
280016
|
return [];
|
|
279764
280017
|
const budget = queryMetadataBudgetWarning(Object.keys(metadata).length);
|
|
@@ -279768,7 +280021,7 @@ function metadataWarnings(level, metadata, target) {
|
|
|
279768
280021
|
...budget ? [budget] : []
|
|
279769
280022
|
].map((message) => ({
|
|
279770
280023
|
message: `${level}: ${message}`,
|
|
279771
|
-
...
|
|
280024
|
+
...subject ? { subject } : {}
|
|
279772
280025
|
}));
|
|
279773
280026
|
}
|
|
279774
280027
|
function materializationConfigWarnings(input) {
|
|
@@ -279779,7 +280032,7 @@ function materializationConfigWarnings(input) {
|
|
|
279779
280032
|
}
|
|
279780
280033
|
const seen = new Set;
|
|
279781
280034
|
return warnings.filter((warning) => {
|
|
279782
|
-
const key = `${warning.
|
|
280035
|
+
const key = `${warning.subject ?? ""}\x00${warning.message}`;
|
|
279783
280036
|
if (seen.has(key))
|
|
279784
280037
|
return false;
|
|
279785
280038
|
seen.add(key);
|
|
@@ -279979,11 +280232,11 @@ class Package {
|
|
|
279979
280232
|
if (!cleanupDirectoryOnFailure) {
|
|
279980
280233
|
logger.info(`Preserving existing package directory after failed load: ${packagePath}`);
|
|
279981
280234
|
} else {
|
|
279982
|
-
const stat6 = await
|
|
280235
|
+
const stat6 = await fs7.lstat(packagePath).catch(() => null);
|
|
279983
280236
|
if (stat6?.isSymbolicLink()) {
|
|
279984
280237
|
logger.info(`Skipping cleanup of symlinked package path on failure: ${packagePath}`);
|
|
279985
280238
|
} else {
|
|
279986
|
-
await
|
|
280239
|
+
await fs7.rm(packagePath, { recursive: true, force: true });
|
|
279987
280240
|
logger.info(`Cleaned up failed package directory: ${packagePath}`);
|
|
279988
280241
|
}
|
|
279989
280242
|
}
|
|
@@ -280054,10 +280307,15 @@ class Package {
|
|
|
280054
280307
|
}
|
|
280055
280308
|
const model = Model.fromSerialized(packageName, packagePath, malloyConfig, sm);
|
|
280056
280309
|
for (const w of await model.validateRenderTags()) {
|
|
280057
|
-
renderTagWarnings.push({
|
|
280310
|
+
renderTagWarnings.push({
|
|
280311
|
+
model: sm.modelPath,
|
|
280312
|
+
subject: w.subject,
|
|
280313
|
+
message: w.message,
|
|
280314
|
+
severity: w.severity
|
|
280315
|
+
});
|
|
280058
280316
|
}
|
|
280059
280317
|
if (sm.modelPath.endsWith(MODEL_FILE_SUFFIX)) {
|
|
280060
|
-
const modelSource = await
|
|
280318
|
+
const modelSource = await fs7.readFile(path7.join(packagePath, sm.modelPath), "utf-8");
|
|
280061
280319
|
assertPersistNamesQuoted(modelSource, sm.modelPath);
|
|
280062
280320
|
}
|
|
280063
280321
|
models.set(sm.modelPath, model);
|
|
@@ -280281,7 +280539,7 @@ class Package {
|
|
|
280281
280539
|
droppedPersistWarnings() {
|
|
280282
280540
|
return this.droppedPersistSources.map((d) => ({
|
|
280283
280541
|
model: d.modelPath,
|
|
280284
|
-
|
|
280542
|
+
subject: d.name,
|
|
280285
280543
|
message: `is annotated '#@ persist' but was not recognized as a ` + `materializable source, so nothing is materialized and it is served ` + `live. Only query/aggregate sources build; a filtered pass-through ` + `does not. Persist a query source, or invoke a parameterized source ` + `with a bound argument.`
|
|
280286
280544
|
}));
|
|
280287
280545
|
}
|
|
@@ -280297,7 +280555,7 @@ class Package {
|
|
|
280297
280555
|
const message = mode === "off" ? `declares storage="${storage}" but PERSIST_STORAGE_MODE is off; ` + `the annotation is ignored and the source is served live from ` + `its own warehouse.` : `is materialized into storage "${storage}" but ` + `PERSIST_STORAGE_MODE is write-only; the serve path is not ` + `routed to the materialized table (served live).`;
|
|
280298
280556
|
warnings.push({
|
|
280299
280557
|
model: source.modelPath ?? "",
|
|
280300
|
-
|
|
280558
|
+
subject: source.name,
|
|
280301
280559
|
message
|
|
280302
280560
|
});
|
|
280303
280561
|
}
|
|
@@ -280476,7 +280734,12 @@ class Package {
|
|
|
280476
280734
|
const model = Model.fromSerialized(this.packageName, this.packagePath, this.malloyConfig, sm, { buildManifest });
|
|
280477
280735
|
try {
|
|
280478
280736
|
for (const w of await model.validateRenderTags()) {
|
|
280479
|
-
renderTagWarnings.push({
|
|
280737
|
+
renderTagWarnings.push({
|
|
280738
|
+
model: sm.modelPath,
|
|
280739
|
+
subject: w.subject,
|
|
280740
|
+
message: w.message,
|
|
280741
|
+
severity: w.severity
|
|
280742
|
+
});
|
|
280480
280743
|
}
|
|
280481
280744
|
nextModels.set(sm.modelPath, model);
|
|
280482
280745
|
} catch (renderErr) {
|
|
@@ -280589,7 +280852,7 @@ class Package {
|
|
|
280589
280852
|
static async validatePackageManifestExistsOrThrowError(packagePath) {
|
|
280590
280853
|
const packageConfigPath = safeJoinUnderRoot(packagePath, PACKAGE_MANIFEST_NAME);
|
|
280591
280854
|
try {
|
|
280592
|
-
await
|
|
280855
|
+
await fs7.stat(packageConfigPath);
|
|
280593
280856
|
} catch {
|
|
280594
280857
|
logger.error(`Can't find ${packageConfigPath}`);
|
|
280595
280858
|
throw new PackageNotFoundError(`Package manifest for ${packagePath} does not exist.`);
|
|
@@ -280738,7 +281001,7 @@ class Environment {
|
|
|
280738
281001
|
return;
|
|
280739
281002
|
const readmePath = path8.join(this.environmentPath, "README.md");
|
|
280740
281003
|
try {
|
|
280741
|
-
await
|
|
281004
|
+
await fs8.promises.writeFile(readmePath, readme, "utf-8");
|
|
280742
281005
|
logger.info(`Updated README.md for environment ${this.environmentName}`);
|
|
280743
281006
|
} catch (err) {
|
|
280744
281007
|
logger.error(`Failed to write README.md`, { error: err });
|
|
@@ -280772,7 +281035,7 @@ class Environment {
|
|
|
280772
281035
|
}
|
|
280773
281036
|
static async create(environmentName, environmentPath, connections, storageDestinations = []) {
|
|
280774
281037
|
assertSafeEnvironmentPath(environmentPath);
|
|
280775
|
-
if (!(await
|
|
281038
|
+
if (!(await fs8.promises.stat(environmentPath))?.isDirectory()) {
|
|
280776
281039
|
throw new EnvironmentNotFoundError(`Environment path ${environmentPath} not found`);
|
|
280777
281040
|
}
|
|
280778
281041
|
logger.info(`Creating environment with connection configuration`);
|
|
@@ -280790,7 +281053,7 @@ class Environment {
|
|
|
280790
281053
|
async reloadEnvironmentMetadata() {
|
|
280791
281054
|
let readme = "";
|
|
280792
281055
|
try {
|
|
280793
|
-
readme = (await
|
|
281056
|
+
readme = (await fs8.promises.readFile(safeJoinUnderRoot(this.environmentPath, README_NAME))).toString();
|
|
280794
281057
|
} catch {}
|
|
280795
281058
|
this.metadata = {
|
|
280796
281059
|
...this.metadata,
|
|
@@ -280803,6 +281066,12 @@ class Environment {
|
|
|
280803
281066
|
async compileSource(packageName, modelName, source, includeSql = false, givens) {
|
|
280804
281067
|
assertSafePackageName(packageName);
|
|
280805
281068
|
assertSafeRelativeModelPath(modelName);
|
|
281069
|
+
try {
|
|
281070
|
+
assertNoCallerAuthorizeAnnotation(source);
|
|
281071
|
+
} catch (err) {
|
|
281072
|
+
recordAuthorizeGuardRejection("compile_source");
|
|
281073
|
+
throw err;
|
|
281074
|
+
}
|
|
280806
281075
|
if (modelName.endsWith(NOTEBOOK_FILE_SUFFIX)) {
|
|
280807
281076
|
throw new BadRequestError(`Cannot compile against a notebook ("${modelName}"). ` + `/compile takes a .malloy model path for namespace context.`);
|
|
280808
281077
|
}
|
|
@@ -280813,7 +281082,7 @@ class Environment {
|
|
|
280813
281082
|
const virtualUri = virtualUrl.toString();
|
|
280814
281083
|
let modelContent = "";
|
|
280815
281084
|
try {
|
|
280816
|
-
modelContent = await
|
|
281085
|
+
modelContent = await fs8.promises.readFile(modelPath, "utf8");
|
|
280817
281086
|
} catch {}
|
|
280818
281087
|
const fullSource = modelContent ? `${modelContent}
|
|
280819
281088
|
${source}` : source;
|
|
@@ -280914,7 +281183,7 @@ ${source}` : source;
|
|
|
280914
281183
|
try {
|
|
280915
281184
|
const destinationRoot = storageDestinationRoot(this.environmentPath);
|
|
280916
281185
|
if (this.destinations.length > 0) {
|
|
280917
|
-
|
|
281186
|
+
fs8.mkdirSync(destinationRoot, { recursive: true });
|
|
280918
281187
|
}
|
|
280919
281188
|
this.destinationMalloyConfig = buildEnvironmentMalloyConfig(this.destinations, destinationRoot);
|
|
280920
281189
|
} catch (error) {
|
|
@@ -281034,10 +281303,10 @@ ${source}` : source;
|
|
|
281034
281303
|
return this.getOrCreatePackageMutex(packageName).runExclusive(fn);
|
|
281035
281304
|
}
|
|
281036
281305
|
allocateStagingPath(packageName) {
|
|
281037
|
-
return safeJoinUnderRoot(this.environmentPath, STAGING_DIR_NAME, `${packageName}-${
|
|
281306
|
+
return safeJoinUnderRoot(this.environmentPath, STAGING_DIR_NAME, `${packageName}-${crypto4.randomUUID()}`);
|
|
281038
281307
|
}
|
|
281039
281308
|
allocateRetiredPath(packageName) {
|
|
281040
|
-
return safeJoinUnderRoot(this.environmentPath, RETIRED_DIR_NAME, `${packageName}-${
|
|
281309
|
+
return safeJoinUnderRoot(this.environmentPath, RETIRED_DIR_NAME, `${packageName}-${crypto4.randomUUID()}`);
|
|
281041
281310
|
}
|
|
281042
281311
|
static async sweepStaleInstallDirs(environmentPath) {
|
|
281043
281312
|
assertSafeEnvironmentPath(environmentPath);
|
|
@@ -281048,7 +281317,7 @@ ${source}` : source;
|
|
|
281048
281317
|
if (path8.basename(dir) !== dirName)
|
|
281049
281318
|
continue;
|
|
281050
281319
|
try {
|
|
281051
|
-
await
|
|
281320
|
+
await fs8.promises.rm(dir, { recursive: true, force: true });
|
|
281052
281321
|
} catch (err) {
|
|
281053
281322
|
logger.warn(`Failed to sweep stale ${dirName} dir at ${dir}`, {
|
|
281054
281323
|
error: err
|
|
@@ -281149,7 +281418,7 @@ ${source}` : source;
|
|
|
281149
281418
|
async addPackage(packageName, options = {}) {
|
|
281150
281419
|
assertSafePackageName(packageName);
|
|
281151
281420
|
const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
281152
|
-
if (!await
|
|
281421
|
+
if (!await fs8.promises.access(packagePath).then(() => true).catch(() => false) || !(await fs8.promises.stat(packagePath))?.isDirectory()) {
|
|
281153
281422
|
throw new PackageNotFoundError(`Package ${packageName} not found`);
|
|
281154
281423
|
}
|
|
281155
281424
|
this.assertCanAdmitNewPackage(packageName, "add a new package", options.allowAdmission === true);
|
|
@@ -281181,7 +281450,7 @@ ${source}` : source;
|
|
|
281181
281450
|
async installPackage(packageName, downloader, validate) {
|
|
281182
281451
|
assertSafePackageName(packageName);
|
|
281183
281452
|
const stagingPath = this.allocateStagingPath(packageName);
|
|
281184
|
-
await
|
|
281453
|
+
await fs8.promises.mkdir(path8.dirname(stagingPath), { recursive: true });
|
|
281185
281454
|
logger.debug("install.phase1.download.started", {
|
|
281186
281455
|
environmentName: this.environmentName,
|
|
281187
281456
|
packageName,
|
|
@@ -281191,7 +281460,7 @@ ${source}` : source;
|
|
|
281191
281460
|
try {
|
|
281192
281461
|
await downloader(stagingPath);
|
|
281193
281462
|
} catch (err) {
|
|
281194
|
-
await
|
|
281463
|
+
await fs8.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
|
|
281195
281464
|
throw err;
|
|
281196
281465
|
}
|
|
281197
281466
|
logger.debug("install.phase1.download.completed", {
|
|
@@ -281207,13 +281476,13 @@ ${source}` : source;
|
|
|
281207
281476
|
const canonicalPath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
281208
281477
|
let retiredPath;
|
|
281209
281478
|
const oldPackage = this.packages.get(packageName);
|
|
281210
|
-
const oldExistsOnDisk = await
|
|
281479
|
+
const oldExistsOnDisk = await fs8.promises.access(canonicalPath).then(() => true).catch(() => false);
|
|
281211
281480
|
if (oldExistsOnDisk) {
|
|
281212
281481
|
retiredPath = this.allocateRetiredPath(packageName);
|
|
281213
|
-
await
|
|
281482
|
+
await fs8.promises.mkdir(path8.dirname(retiredPath), {
|
|
281214
281483
|
recursive: true
|
|
281215
281484
|
});
|
|
281216
|
-
await
|
|
281485
|
+
await fs8.promises.rename(canonicalPath, retiredPath);
|
|
281217
281486
|
logger.debug("install.phase2.retired_old", {
|
|
281218
281487
|
environmentName: this.environmentName,
|
|
281219
281488
|
packageName,
|
|
@@ -281222,7 +281491,7 @@ ${source}` : source;
|
|
|
281222
281491
|
}
|
|
281223
281492
|
let newPackage;
|
|
281224
281493
|
try {
|
|
281225
|
-
await
|
|
281494
|
+
await fs8.promises.rename(stagingPath, canonicalPath);
|
|
281226
281495
|
this.setPackageStatus(packageName, "loading" /* LOADING */);
|
|
281227
281496
|
newPackage = await Package.create(this.environmentName, packageName, canonicalPath, () => this.malloyConfig.malloyConfig, true);
|
|
281228
281497
|
this.attachDestinationServeConfig(newPackage);
|
|
@@ -281236,11 +281505,11 @@ ${source}` : source;
|
|
|
281236
281505
|
canonicalPath
|
|
281237
281506
|
});
|
|
281238
281507
|
} catch (err) {
|
|
281239
|
-
await
|
|
281508
|
+
await fs8.promises.rm(canonicalPath, { recursive: true, force: true }).catch(() => {});
|
|
281240
281509
|
let restored = false;
|
|
281241
281510
|
if (retiredPath) {
|
|
281242
281511
|
try {
|
|
281243
|
-
await
|
|
281512
|
+
await fs8.promises.rename(retiredPath, canonicalPath);
|
|
281244
281513
|
restored = true;
|
|
281245
281514
|
} catch (restoreErr) {
|
|
281246
281515
|
logger.error("Failed to restore retired package after install rollback", {
|
|
@@ -281250,7 +281519,7 @@ ${source}` : source;
|
|
|
281250
281519
|
});
|
|
281251
281520
|
}
|
|
281252
281521
|
}
|
|
281253
|
-
await
|
|
281522
|
+
await fs8.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
|
|
281254
281523
|
if (oldPackage && restored) {
|
|
281255
281524
|
this.setPackageStatus(packageName, "serving" /* SERVING */);
|
|
281256
281525
|
} else {
|
|
@@ -281284,7 +281553,7 @@ ${source}` : source;
|
|
|
281284
281553
|
packageName,
|
|
281285
281554
|
retiredPath: pathToClean
|
|
281286
281555
|
});
|
|
281287
|
-
|
|
281556
|
+
fs8.promises.rm(pathToClean, { recursive: true, force: true }).catch((err) => {
|
|
281288
281557
|
logger.warn(`Failed to clean up retired package directory ${pathToClean}`, { error: err });
|
|
281289
281558
|
});
|
|
281290
281559
|
});
|
|
@@ -281417,7 +281686,7 @@ ${source}` : source;
|
|
|
281417
281686
|
try {
|
|
281418
281687
|
let existingManifest = {};
|
|
281419
281688
|
try {
|
|
281420
|
-
const content = await
|
|
281689
|
+
const content = await fs8.promises.readFile(manifestPath, "utf-8");
|
|
281421
281690
|
existingManifest = JSON.parse(content);
|
|
281422
281691
|
} catch (_err) {
|
|
281423
281692
|
logger.warn(`Could not read manifest for ${packageName}`);
|
|
@@ -281437,7 +281706,7 @@ ${source}` : source;
|
|
|
281437
281706
|
...resolvedScope !== undefined ? { scope: resolvedScope } : {},
|
|
281438
281707
|
...materializationBlock !== undefined ? { materialization: materializationBlock } : {}
|
|
281439
281708
|
};
|
|
281440
|
-
await
|
|
281709
|
+
await fs8.promises.writeFile(manifestPath, JSON.stringify(updatedManifest, null, 2), "utf-8");
|
|
281441
281710
|
logger.info(`Updated publisher.json for ${packageName}`);
|
|
281442
281711
|
} catch (error) {
|
|
281443
281712
|
logger.error(`Failed to update publisher.json`, { error });
|
|
@@ -281560,10 +281829,10 @@ ${source}` : source;
|
|
|
281560
281829
|
const retiredPath = this.allocateRetiredPath(packageName);
|
|
281561
281830
|
let renamed = false;
|
|
281562
281831
|
try {
|
|
281563
|
-
await
|
|
281832
|
+
await fs8.promises.mkdir(path8.dirname(retiredPath), {
|
|
281564
281833
|
recursive: true
|
|
281565
281834
|
});
|
|
281566
|
-
await
|
|
281835
|
+
await fs8.promises.rename(canonicalPath, retiredPath);
|
|
281567
281836
|
renamed = true;
|
|
281568
281837
|
} catch (err) {
|
|
281569
281838
|
logger.error("Error renaming package directory to retired during unload", {
|
|
@@ -281576,7 +281845,7 @@ ${source}` : source;
|
|
|
281576
281845
|
this.packageStatuses.delete(packageName);
|
|
281577
281846
|
if (renamed) {
|
|
281578
281847
|
setImmediate(() => {
|
|
281579
|
-
|
|
281848
|
+
fs8.promises.rm(retiredPath, { recursive: true, force: true }).catch((err) => {
|
|
281580
281849
|
logger.warn(`Failed to clean up retired package directory ${retiredPath}`, { error: err });
|
|
281581
281850
|
});
|
|
281582
281851
|
});
|
|
@@ -281661,7 +281930,7 @@ ${source}` : source;
|
|
|
281661
281930
|
async deleteDuckDBConnection(connectionName) {
|
|
281662
281931
|
const duckdbPath = path8.join(this.environmentPath, `${connectionName}.duckdb`);
|
|
281663
281932
|
try {
|
|
281664
|
-
await
|
|
281933
|
+
await fs8.promises.rm(duckdbPath, { force: true });
|
|
281665
281934
|
logger.info(`Removed DuckDB connection file ${connectionName} from environment ${this.environmentName}`);
|
|
281666
281935
|
} catch (error) {
|
|
281667
281936
|
logger.error(`Failed to remove DuckDB connection file ${connectionName} from environment ${this.environmentName}`, { error });
|
|
@@ -281722,11 +281991,11 @@ function validateEnvironmentAzureUrls(environment) {
|
|
|
281722
281991
|
}
|
|
281723
281992
|
async function clearMountTarget(targetPath) {
|
|
281724
281993
|
try {
|
|
281725
|
-
const stats = await
|
|
281994
|
+
const stats = await fs9.promises.lstat(targetPath);
|
|
281726
281995
|
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
281727
|
-
await
|
|
281996
|
+
await fs9.promises.rm(targetPath, { recursive: true, force: true });
|
|
281728
281997
|
} else {
|
|
281729
|
-
await
|
|
281998
|
+
await fs9.promises.unlink(targetPath);
|
|
281730
281999
|
}
|
|
281731
282000
|
} catch {}
|
|
281732
282001
|
}
|
|
@@ -281978,7 +282247,7 @@ class EnvironmentStore {
|
|
|
281978
282247
|
if (existingEnvironments.length > 0) {
|
|
281979
282248
|
await Promise.all(existingEnvironments.map(async (dbEnvironment) => {
|
|
281980
282249
|
try {
|
|
281981
|
-
const environmentExists = await
|
|
282250
|
+
const environmentExists = await fs9.promises.access(dbEnvironment.path).then(() => true).catch(() => false);
|
|
281982
282251
|
const environmentConfig = environmentManifest.environments.find((p) => p.name === dbEnvironment.name);
|
|
281983
282252
|
if (!environmentExists) {
|
|
281984
282253
|
if (environmentConfig) {
|
|
@@ -282330,12 +282599,12 @@ class EnvironmentStore {
|
|
|
282330
282599
|
}
|
|
282331
282600
|
async cleanupAndCreatePublisherPath() {
|
|
282332
282601
|
const reInit = process.env.INITIALIZE_STORAGE === "true";
|
|
282333
|
-
await
|
|
282602
|
+
await fs9.promises.mkdir(this.serverRootPath, { recursive: true });
|
|
282334
282603
|
if (reInit) {
|
|
282335
282604
|
const uploadDocsPath2 = path9.join(this.serverRootPath, PUBLISHER_DATA_DIR);
|
|
282336
282605
|
logger.info(`Reinitialization mode: Cleaning up upload documents path ${uploadDocsPath2}`);
|
|
282337
282606
|
try {
|
|
282338
|
-
await
|
|
282607
|
+
await fs9.promises.rm(uploadDocsPath2, {
|
|
282339
282608
|
recursive: true,
|
|
282340
282609
|
force: true
|
|
282341
282610
|
});
|
|
@@ -282350,7 +282619,7 @@ class EnvironmentStore {
|
|
|
282350
282619
|
logger.info(`Using existing publisher path`);
|
|
282351
282620
|
}
|
|
282352
282621
|
const uploadDocsPath = path9.join(this.serverRootPath, PUBLISHER_DATA_DIR);
|
|
282353
|
-
await
|
|
282622
|
+
await fs9.promises.mkdir(uploadDocsPath, { recursive: true });
|
|
282354
282623
|
}
|
|
282355
282624
|
async listEnvironments(skipInitializationCheck = false) {
|
|
282356
282625
|
if (!skipInitializationCheck) {
|
|
@@ -282516,11 +282785,11 @@ class EnvironmentStore {
|
|
|
282516
282785
|
const startedAt = Date.now();
|
|
282517
282786
|
logger.info(`Detected zip file at "${absoluteEnvironmentPath}". Unzipping...`);
|
|
282518
282787
|
const unzippedEnvironmentPath = absoluteEnvironmentPath.replace(".zip", "");
|
|
282519
|
-
await
|
|
282788
|
+
await fs9.promises.rm(unzippedEnvironmentPath, {
|
|
282520
282789
|
recursive: true,
|
|
282521
282790
|
force: true
|
|
282522
282791
|
});
|
|
282523
|
-
await
|
|
282792
|
+
await fs9.promises.mkdir(unzippedEnvironmentPath, { recursive: true });
|
|
282524
282793
|
let entryCount = 0;
|
|
282525
282794
|
let totalUncompressedBytes = 0;
|
|
282526
282795
|
await import_extract_zip.default(absoluteEnvironmentPath, {
|
|
@@ -282570,7 +282839,7 @@ class EnvironmentStore {
|
|
|
282570
282839
|
await this.deleteEnvironmentFromDatabase(environmentName);
|
|
282571
282840
|
if (environmentPath) {
|
|
282572
282841
|
try {
|
|
282573
|
-
await
|
|
282842
|
+
await fs9.promises.rm(environmentPath, {
|
|
282574
282843
|
recursive: true,
|
|
282575
282844
|
force: true
|
|
282576
282845
|
});
|
|
@@ -282592,7 +282861,7 @@ class EnvironmentStore {
|
|
|
282592
282861
|
return { frozenConfig: false, environments: [] };
|
|
282593
282862
|
} else {
|
|
282594
282863
|
try {
|
|
282595
|
-
const entries = await
|
|
282864
|
+
const entries = await fs9.promises.readdir(serverRootPath, {
|
|
282596
282865
|
withFileTypes: true
|
|
282597
282866
|
});
|
|
282598
282867
|
const environments = [];
|
|
@@ -282625,9 +282894,9 @@ class EnvironmentStore {
|
|
|
282625
282894
|
assertSafePackageName(environment.name);
|
|
282626
282895
|
const environmentName = environment.name;
|
|
282627
282896
|
const absoluteEnvironmentPath = safeJoinUnderRoot(this.serverRootPath, PUBLISHER_DATA_DIR, environmentName);
|
|
282628
|
-
await
|
|
282897
|
+
await fs9.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
|
|
282629
282898
|
if (environment.readme) {
|
|
282630
|
-
await
|
|
282899
|
+
await fs9.promises.writeFile(safeJoinUnderRoot(absoluteEnvironmentPath, "README.md"), environment.readme);
|
|
282631
282900
|
}
|
|
282632
282901
|
return absoluteEnvironmentPath;
|
|
282633
282902
|
}
|
|
@@ -282657,7 +282926,7 @@ class EnvironmentStore {
|
|
|
282657
282926
|
assertSafePackageName(environmentName);
|
|
282658
282927
|
const absoluteTargetPath = safeJoinUnderRoot(this.serverRootPath, PUBLISHER_DATA_DIR, environmentName);
|
|
282659
282928
|
const mountErrors = new Map;
|
|
282660
|
-
await
|
|
282929
|
+
await fs9.promises.mkdir(absoluteTargetPath, { recursive: true });
|
|
282661
282930
|
if (!packages || packages.length === 0) {
|
|
282662
282931
|
throw new PackageNotFoundError(`No packages found for environment ${environmentName}`);
|
|
282663
282932
|
}
|
|
@@ -282687,9 +282956,9 @@ class EnvironmentStore {
|
|
|
282687
282956
|
const totalPackages = packages.length;
|
|
282688
282957
|
let mountedCount = 0;
|
|
282689
282958
|
for (const [groupedLocation, packagesForLocation] of locationGroups) {
|
|
282690
|
-
const locationHash =
|
|
282959
|
+
const locationHash = crypto5.createHash("sha256").update(groupedLocation).digest("hex").substring(0, 16);
|
|
282691
282960
|
const tempDownloadPath = safeJoinUnderRoot(absoluteTargetPath, `.temp_${locationHash}`);
|
|
282692
|
-
await
|
|
282961
|
+
await fs9.promises.mkdir(tempDownloadPath, { recursive: true });
|
|
282693
282962
|
logger.info(`Created temporary directory: ${tempDownloadPath}`);
|
|
282694
282963
|
let downloaded = true;
|
|
282695
282964
|
try {
|
|
@@ -282724,7 +282993,7 @@ class EnvironmentStore {
|
|
|
282724
282993
|
sourcePath = safeJoinUnderRoot(tempDownloadPath, groupedLocation);
|
|
282725
282994
|
}
|
|
282726
282995
|
}
|
|
282727
|
-
const sourceExists = await
|
|
282996
|
+
const sourceExists = await fs9.promises.access(sourcePath).then(() => true).catch(() => false);
|
|
282728
282997
|
if (sourceExists) {
|
|
282729
282998
|
const isInPlace = this.inPlaceEnvs.has(environmentName) && this.isLocalPath(_package.location);
|
|
282730
282999
|
if (isInPlace) {
|
|
@@ -282732,16 +283001,16 @@ class EnvironmentStore {
|
|
|
282732
283001
|
const absoluteSourcePath = path9.resolve(sourcePath);
|
|
282733
283002
|
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
282734
283003
|
try {
|
|
282735
|
-
await
|
|
283004
|
+
await fs9.promises.symlink(absoluteSourcePath, absolutePackagePath, linkType);
|
|
282736
283005
|
logger.info(`In-place mount (watch mode): linked package "${packageDir}" -> "${absoluteSourcePath}" (${++mountedCount}/${totalPackages})`);
|
|
282737
283006
|
} catch (linkError) {
|
|
282738
283007
|
const code = linkError?.code ?? String(linkError);
|
|
282739
283008
|
logger.warn(`In-place mount failed for package "${packageDir}" (${code}); falling back to a copy. Source-edit live reload is disabled for this package.`);
|
|
282740
283009
|
await clearMountTarget(absolutePackagePath);
|
|
282741
|
-
await
|
|
283010
|
+
await fs9.promises.mkdir(absolutePackagePath, {
|
|
282742
283011
|
recursive: true
|
|
282743
283012
|
});
|
|
282744
|
-
await
|
|
283013
|
+
await fs9.promises.cp(sourcePath, absolutePackagePath, {
|
|
282745
283014
|
recursive: true
|
|
282746
283015
|
});
|
|
282747
283016
|
logger.info(`Copied package "${packageDir}" (${++mountedCount}/${totalPackages})`);
|
|
@@ -282751,20 +283020,20 @@ class EnvironmentStore {
|
|
|
282751
283020
|
logger.warn(`Watch mode: package "${packageDir}" has remote location "${_package.location}" — falling back to copy. Source-edit live reload won't work for this package; clone the source locally and use a local-dir location to enable it.`);
|
|
282752
283021
|
}
|
|
282753
283022
|
await clearMountTarget(absolutePackagePath);
|
|
282754
|
-
await
|
|
283023
|
+
await fs9.promises.mkdir(absolutePackagePath, {
|
|
282755
283024
|
recursive: true
|
|
282756
283025
|
});
|
|
282757
|
-
await
|
|
283026
|
+
await fs9.promises.cp(sourcePath, absolutePackagePath, {
|
|
282758
283027
|
recursive: true
|
|
282759
283028
|
});
|
|
282760
283029
|
logger.info(`Extracted package "${packageDir}" from ${groupedLocation.startsWith("https://github.com/") && _package.location.includes("/tree/") ? "GitHub subdirectory" : "shared download"} (${++mountedCount}/${totalPackages})`);
|
|
282761
283030
|
}
|
|
282762
283031
|
} else {
|
|
282763
283032
|
await clearMountTarget(absolutePackagePath);
|
|
282764
|
-
await
|
|
283033
|
+
await fs9.promises.mkdir(absolutePackagePath, {
|
|
282765
283034
|
recursive: true
|
|
282766
283035
|
});
|
|
282767
|
-
await
|
|
283036
|
+
await fs9.promises.cp(tempDownloadPath, absolutePackagePath, {
|
|
282768
283037
|
recursive: true
|
|
282769
283038
|
});
|
|
282770
283039
|
logger.info(`Copied entire download as package "${packageDir}" (${++mountedCount}/${totalPackages})`);
|
|
@@ -282777,7 +283046,7 @@ class EnvironmentStore {
|
|
|
282777
283046
|
}
|
|
282778
283047
|
}
|
|
282779
283048
|
try {
|
|
282780
|
-
await
|
|
283049
|
+
await fs9.promises.rm(tempDownloadPath, {
|
|
282781
283050
|
recursive: true,
|
|
282782
283051
|
force: true
|
|
282783
283052
|
});
|
|
@@ -282848,14 +283117,14 @@ class EnvironmentStore {
|
|
|
282848
283117
|
if (environmentPath.endsWith(".zip")) {
|
|
282849
283118
|
environmentPath = await this.unzipEnvironment(environmentPath);
|
|
282850
283119
|
}
|
|
282851
|
-
const environmentDirExists = (await
|
|
283120
|
+
const environmentDirExists = (await fs9.promises.stat(environmentPath))?.isDirectory() ?? false;
|
|
282852
283121
|
if (environmentDirExists) {
|
|
282853
|
-
await
|
|
283122
|
+
await fs9.promises.rm(absoluteTargetPath, {
|
|
282854
283123
|
recursive: true,
|
|
282855
283124
|
force: true
|
|
282856
283125
|
});
|
|
282857
|
-
await
|
|
282858
|
-
await
|
|
283126
|
+
await fs9.promises.mkdir(absoluteTargetPath, { recursive: true });
|
|
283127
|
+
await fs9.promises.cp(environmentPath, absoluteTargetPath, {
|
|
282859
283128
|
recursive: true
|
|
282860
283129
|
});
|
|
282861
283130
|
} else {
|
|
@@ -282874,11 +283143,11 @@ class EnvironmentStore {
|
|
|
282874
283143
|
throw new EnvironmentNotFoundError(`Environment ${environmentName} not found in ${gcsPath}`);
|
|
282875
283144
|
}
|
|
282876
283145
|
if (!isCompressedFile) {
|
|
282877
|
-
await
|
|
283146
|
+
await fs9.promises.rm(absoluteDirPath, {
|
|
282878
283147
|
recursive: true,
|
|
282879
283148
|
force: true
|
|
282880
283149
|
});
|
|
282881
|
-
await
|
|
283150
|
+
await fs9.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
282882
283151
|
} else {
|
|
282883
283152
|
absoluteDirPath = `${absoluteDirPath}.zip`;
|
|
282884
283153
|
}
|
|
@@ -282888,10 +283157,10 @@ class EnvironmentStore {
|
|
|
282888
283157
|
if (file.name.endsWith("/")) {
|
|
282889
283158
|
return;
|
|
282890
283159
|
}
|
|
282891
|
-
await
|
|
283160
|
+
await fs9.promises.mkdir(path9.dirname(absoluteFilePath), {
|
|
282892
283161
|
recursive: true
|
|
282893
283162
|
});
|
|
282894
|
-
return
|
|
283163
|
+
return fs9.promises.writeFile(absoluteFilePath, await file.download());
|
|
282895
283164
|
}));
|
|
282896
283165
|
if (isCompressedFile) {
|
|
282897
283166
|
await this.unzipEnvironment(absoluteDirPath);
|
|
@@ -282905,7 +283174,7 @@ class EnvironmentStore {
|
|
|
282905
283174
|
const prefix = prefixParts.join("/");
|
|
282906
283175
|
if (isCompressedFile) {
|
|
282907
283176
|
const zipFilePath = `${absoluteDirPath}.zip`;
|
|
282908
|
-
await
|
|
283177
|
+
await fs9.promises.mkdir(path9.dirname(zipFilePath), {
|
|
282909
283178
|
recursive: true
|
|
282910
283179
|
});
|
|
282911
283180
|
const command = new import_client_s33.GetObjectCommand({
|
|
@@ -282916,7 +283185,7 @@ class EnvironmentStore {
|
|
|
282916
283185
|
if (!item.Body) {
|
|
282917
283186
|
throw new EnvironmentNotFoundError(`Environment ${environmentName} not found in ${s3Path}`);
|
|
282918
283187
|
}
|
|
282919
|
-
const file =
|
|
283188
|
+
const file = fs9.createWriteStream(zipFilePath);
|
|
282920
283189
|
item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
|
|
282921
283190
|
await new Promise((resolve5, reject) => {
|
|
282922
283191
|
file.on("error", reject);
|
|
@@ -282930,8 +283199,8 @@ class EnvironmentStore {
|
|
|
282930
283199
|
Bucket: bucketName,
|
|
282931
283200
|
Prefix: prefix
|
|
282932
283201
|
});
|
|
282933
|
-
await
|
|
282934
|
-
await
|
|
283202
|
+
await fs9.promises.rm(absoluteDirPath, { recursive: true, force: true });
|
|
283203
|
+
await fs9.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
282935
283204
|
if (!objects.Contents || objects.Contents.length === 0) {
|
|
282936
283205
|
throw new EnvironmentNotFoundError(`Environment ${environmentName} not found in ${s3Path}`);
|
|
282937
283206
|
}
|
|
@@ -282945,7 +283214,7 @@ class EnvironmentStore {
|
|
|
282945
283214
|
return;
|
|
282946
283215
|
}
|
|
282947
283216
|
const absoluteFilePath = safeJoinUnderRoot(absoluteDirPath, relativeFilePath);
|
|
282948
|
-
await
|
|
283217
|
+
await fs9.promises.mkdir(path9.dirname(absoluteFilePath), {
|
|
282949
283218
|
recursive: true
|
|
282950
283219
|
});
|
|
282951
283220
|
const command = new import_client_s33.GetObjectCommand({
|
|
@@ -282956,7 +283225,7 @@ class EnvironmentStore {
|
|
|
282956
283225
|
if (!item.Body) {
|
|
282957
283226
|
return;
|
|
282958
283227
|
}
|
|
282959
|
-
const file =
|
|
283228
|
+
const file = fs9.createWriteStream(absoluteFilePath);
|
|
282960
283229
|
item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
|
|
282961
283230
|
await new Promise((resolve5, reject) => {
|
|
282962
283231
|
file.on("error", reject);
|
|
@@ -282973,11 +283242,11 @@ class EnvironmentStore {
|
|
|
282973
283242
|
}
|
|
282974
283243
|
const { owner, repoName, packagePath } = githubInfo;
|
|
282975
283244
|
const cleanPackagePath = (packagePath?.replace("/tree/main", "") || "").replace(/^\/+/, "");
|
|
282976
|
-
await
|
|
283245
|
+
await fs9.promises.rm(absoluteDirPath, {
|
|
282977
283246
|
recursive: true,
|
|
282978
283247
|
force: true
|
|
282979
283248
|
});
|
|
282980
|
-
await
|
|
283249
|
+
await fs9.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
282981
283250
|
const repoUrl = `https://github.com/${owner}/${repoName}`;
|
|
282982
283251
|
const reporter = new CloneProgressReporter(cloneProgressLabel(`${owner}/${repoName}`, progressContext));
|
|
282983
283252
|
await new Promise((resolve5, reject) => {
|
|
@@ -283003,24 +283272,24 @@ class EnvironmentStore {
|
|
|
283003
283272
|
return;
|
|
283004
283273
|
}
|
|
283005
283274
|
const packageFullPath = safeJoinUnderRoot(absoluteDirPath, cleanPackagePath);
|
|
283006
|
-
const packageExists = await
|
|
283275
|
+
const packageExists = await fs9.promises.access(packageFullPath).then(() => true).catch(() => false);
|
|
283007
283276
|
if (!packageExists) {
|
|
283008
283277
|
throw new Error(`Package path "${cleanPackagePath}" does not exist in the cloned repository.`);
|
|
283009
283278
|
}
|
|
283010
|
-
const dirContents = await
|
|
283279
|
+
const dirContents = await fs9.promises.readdir(absoluteDirPath);
|
|
283011
283280
|
for (const entry of dirContents) {
|
|
283012
283281
|
if (entry !== cleanPackagePath.replace(/^\/+/, "").split("/")[0]) {
|
|
283013
|
-
await
|
|
283282
|
+
await fs9.promises.rm(safeJoinUnderRoot(absoluteDirPath, entry), {
|
|
283014
283283
|
recursive: true,
|
|
283015
283284
|
force: true
|
|
283016
283285
|
});
|
|
283017
283286
|
}
|
|
283018
283287
|
}
|
|
283019
|
-
const packageContents = await
|
|
283288
|
+
const packageContents = await fs9.promises.readdir(packageFullPath);
|
|
283020
283289
|
for (const entry of packageContents) {
|
|
283021
|
-
await
|
|
283290
|
+
await fs9.promises.rename(safeJoinUnderRoot(packageFullPath, entry), safeJoinUnderRoot(absoluteDirPath, entry));
|
|
283022
283291
|
}
|
|
283023
|
-
await
|
|
283292
|
+
await fs9.promises.rm(packageFullPath, { recursive: true, force: true });
|
|
283024
283293
|
}
|
|
283025
283294
|
extractErrorDataFromError(error) {
|
|
283026
283295
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -286004,7 +286273,7 @@ var BACK_PRESSURE_SUGGESTIONS = [
|
|
|
286004
286273
|
"If this persists, raise the limit or scale up the pod."
|
|
286005
286274
|
];
|
|
286006
286275
|
function classifyToolError(operation, identifier, error) {
|
|
286007
|
-
if (error instanceof EnvironmentNotFoundError || error instanceof PackageNotFoundError || error instanceof ModelNotFoundError || error instanceof NotQueryableError) {
|
|
286276
|
+
if (error instanceof EnvironmentNotFoundError || error instanceof PackageNotFoundError || error instanceof ModelNotFoundError || error instanceof NotQueryableError || error instanceof ConnectionNotFoundError) {
|
|
286008
286277
|
return getNotFoundError(identifier);
|
|
286009
286278
|
}
|
|
286010
286279
|
if (error instanceof ServiceUnavailableError) {
|
|
@@ -286013,6 +286282,15 @@ function classifyToolError(operation, identifier, error) {
|
|
|
286013
286282
|
suggestions: [...BACK_PRESSURE_SUGGESTIONS]
|
|
286014
286283
|
};
|
|
286015
286284
|
}
|
|
286285
|
+
if (error instanceof ResponseUnserializableError) {
|
|
286286
|
+
return {
|
|
286287
|
+
message: error.message,
|
|
286288
|
+
suggestions: [
|
|
286289
|
+
"This is not transient. The same query will fail the same way, so change the query rather than retrying it.",
|
|
286290
|
+
"Raising the byte cap will not help, because the response cannot be serialized at any cap. Shrink it instead: project fewer columns, add a LIMIT, or filter out the wide values."
|
|
286291
|
+
]
|
|
286292
|
+
};
|
|
286293
|
+
}
|
|
286016
286294
|
if (error instanceof QueryTimeoutError || error instanceof PayloadTooLargeError) {
|
|
286017
286295
|
return {
|
|
286018
286296
|
message: error.message,
|
|
@@ -286022,6 +286300,15 @@ function classifyToolError(operation, identifier, error) {
|
|
|
286022
286300
|
]
|
|
286023
286301
|
};
|
|
286024
286302
|
}
|
|
286303
|
+
if (error instanceof InvalidArgumentError) {
|
|
286304
|
+
return {
|
|
286305
|
+
message: error.message,
|
|
286306
|
+
suggestions: [
|
|
286307
|
+
"This is not transient. The same arguments will fail the same way, so change them rather than retrying.",
|
|
286308
|
+
"The message above names what was wrong. If it names an expected format, use that format exactly."
|
|
286309
|
+
]
|
|
286310
|
+
};
|
|
286311
|
+
}
|
|
286025
286312
|
if (error instanceof MalloyError5 || error instanceof ModelCompilationError || error instanceof AccessDeniedError || error instanceof BadRequestError) {
|
|
286026
286313
|
return getMalloyErrorDetails(operation, identifier, error);
|
|
286027
286314
|
}
|
|
@@ -286139,7 +286426,7 @@ var COMPILE_DESCRIPTION = `Compile-check Malloy source against a model and retur
|
|
|
286139
286426
|
|
|
286140
286427
|
## Parameters
|
|
286141
286428
|
- environmentName, packageName, modelPath (required): the model whose namespace the source compiles against. The source is appended to that model, so its imports, sources, and queries are in scope, and modelPath is real context, not a label.
|
|
286142
|
-
- source (required): the Malloy text to validate.
|
|
286429
|
+
- source (required): the Malloy text to validate. An \`#(authorize)\` annotation in it is rejected with a 400 — gates come only from package files; save and reload to validate one.
|
|
286143
286430
|
- includeSql (optional): also return the generated SQL when the source ends in a runnable query. The query is still not executed and no data is scanned.
|
|
286144
286431
|
|
|
286145
286432
|
## Checking part of a source
|
|
@@ -286382,31 +286669,31 @@ var executeQueryShape = {
|
|
|
286382
286669
|
packageName: exports_external.string().describe("Package containing the model. Call malloy_getContext with just environmentName to list its packages."),
|
|
286383
286670
|
modelPath: exports_external.string().describe("Path to the .malloy model file"),
|
|
286384
286671
|
query: exports_external.string().optional().describe("Ad-hoc Malloy query code"),
|
|
286385
|
-
sourceName: exports_external.string().optional().describe("Source name for a view"),
|
|
286386
|
-
queryName: exports_external.string().optional().describe("Named query or view"),
|
|
286672
|
+
sourceName: exports_external.string().optional().describe("Source name for a view. A NAME, not Malloy code: one name exactly as malloy_getContext 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."),
|
|
286673
|
+
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 malloy_getContext returned it. A dotted path (carriers.by_name), a refinement (by_carrier + { limit: 10 }), or anything containing a newline goes in query instead."),
|
|
286387
286674
|
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."),
|
|
286388
286675
|
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.")
|
|
286389
286676
|
};
|
|
286390
286677
|
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).
|
|
286391
286678
|
|
|
286392
286679
|
## Contract rules
|
|
286393
|
-
- Check _limit_hit before reporting any total, count, or "top N". True means the server's default
|
|
286680
|
+
- 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.
|
|
286394
286681
|
- Never sum or count the returned rows to state a total when _limit_hit or _rows_truncated is set. Aggregate in the query instead.
|
|
286395
286682
|
- _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.
|
|
286396
|
-
- Use source, view, and field names exactly as malloy_getContext returned them.
|
|
286683
|
+
- Use source, view, and field names exactly as malloy_getContext 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 second statement) in query.
|
|
286397
286684
|
|
|
286398
286685
|
## Response
|
|
286399
|
-
A JSON object, the same shape Credible's execute_query
|
|
286400
|
-
- rows: flat objects keyed by column name
|
|
286686
|
+
A JSON object, the same shape Credible's execute_query and an in-package data app receive:
|
|
286687
|
+
- rows: flat objects keyed by column name.
|
|
286401
286688
|
- _meta: the Malloy metadata flat rows drop (schema with field types and render tags, annotations, connection_name, query_timezone).
|
|
286402
286689
|
- _query_row_limit: the cap pushed into the SQL, from the query's own limit: or the server default.
|
|
286403
286690
|
- _limit_source: "query" when the cap came from the query's own limit:/top:, "server_default" otherwise.
|
|
286404
|
-
- _limit_hit: the row count equals that cap AND the cap was the server default.
|
|
286691
|
+
- _limit_hit: the row count equals that cap AND the cap was the server default, so a query carrying its own limit:/top: never sets it and exactly that many rows is a complete answer.
|
|
286405
286692
|
- _rows_truncated / _total_rows / _returned_rows: present only when the payload cap dropped rows.
|
|
286406
286693
|
- _query_id: this query's id in the warehouse's own query history. Present only where enabled.
|
|
286407
286694
|
- warning, renderLogErrors: present only when they apply.
|
|
286408
286695
|
|
|
286409
|
-
|
|
286696
|
+
Values above 2^53 are returned as JSON strings so their digits survive.`;
|
|
286410
286697
|
function registerExecuteQueryTool(mcpServer, environmentStore) {
|
|
286411
286698
|
mcpServer.tool("malloy_executeQuery", EXECUTE_QUERY_DESCRIPTION, executeQueryShape, async (params) => {
|
|
286412
286699
|
const {
|
|
@@ -286460,7 +286747,7 @@ function registerExecuteQueryTool(mcpServer, environmentStore) {
|
|
|
286460
286747
|
rowLimit,
|
|
286461
286748
|
rowLimitSource,
|
|
286462
286749
|
queryCorrelationId
|
|
286463
|
-
} = await runWithQueryTimeout((abortSignal) => query ? model.getQueryResults(undefined, undefined, query, filterParams, undefined, givens, abortSignal, queryMetadataInput) : model.getQueryResults(sourceName, queryName, undefined, filterParams, undefined, givens, abortSignal, queryMetadataInput), getQueryTimeoutMs());
|
|
286750
|
+
} = 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());
|
|
286464
286751
|
const { validateRenderTags: validateRenderTags2 } = await Promise.resolve().then(() => __toESM(require_dist11(), 1));
|
|
286465
286752
|
const renderLogs = validateRenderTags2(result);
|
|
286466
286753
|
const resultUri = buildMalloyUri({
|
|
@@ -286861,6 +287148,579 @@ function registerReloadPackageTool(mcpServer, environmentStore) {
|
|
|
286861
287148
|
}
|
|
286862
287149
|
});
|
|
286863
287150
|
}
|
|
287151
|
+
|
|
287152
|
+
// src/mcp/tools/search_database_schema_tool.ts
|
|
287153
|
+
init_logger();
|
|
287154
|
+
|
|
287155
|
+
// src/mcp/tools/schema_index.ts
|
|
287156
|
+
init_logger();
|
|
287157
|
+
var import_lunr3 = __toESM(require_lunr(), 1);
|
|
287158
|
+
import { createHash as createHash2 } from "crypto";
|
|
287159
|
+
var MAX_INDEXED_TABLES = 5000;
|
|
287160
|
+
var MAX_COLUMNS_IN_INDEX_TEXT = 60;
|
|
287161
|
+
var MAX_CACHED_SCHEMAS = 8;
|
|
287162
|
+
var PROVIDER_FAILURE_COOLDOWN_MS2 = 60000;
|
|
287163
|
+
function tableIndexText(entity) {
|
|
287164
|
+
const name = humanizeName(entity.tableName) || entity.tableName;
|
|
287165
|
+
const schema = humanizeName(entity.schemaName) || entity.schemaName;
|
|
287166
|
+
const columns = entity.columns.slice(0, MAX_COLUMNS_IN_INDEX_TEXT);
|
|
287167
|
+
const columnNames = columns.map((c) => humanizeName(c.name) || c.name).filter(Boolean).join(", ");
|
|
287168
|
+
const types2 = Array.from(new Set(columns.map((c) => c.type).filter((t) => Boolean(t)))).join(" ");
|
|
287169
|
+
return [schema, name, columnNames, types2].filter(Boolean).join(" ");
|
|
287170
|
+
}
|
|
287171
|
+
function schemaFingerprint(tables) {
|
|
287172
|
+
const canonical = tables.map((t) => `${t.resource}\x00${t.columns.map((c) => `${c.name}:${c.type ?? ""}`).join(",")}`).sort().join("\x01");
|
|
287173
|
+
return createHash2("sha256").update(canonical).digest("hex");
|
|
287174
|
+
}
|
|
287175
|
+
function sanitizeQuery(query) {
|
|
287176
|
+
return query.replace(/[~^:*+\-"]/g, " ").trim();
|
|
287177
|
+
}
|
|
287178
|
+
function rankLexically(tables, query, limit, cacheKey) {
|
|
287179
|
+
const sanitized = sanitizeQuery(query);
|
|
287180
|
+
if (!sanitized || tables.length === 0)
|
|
287181
|
+
return { hits: [], matched: 0 };
|
|
287182
|
+
const fingerprint = cacheKey ? schemaFingerprint(tables) : "";
|
|
287183
|
+
let cached2 = cacheKey ? lexicalCache.get(cacheKey) : undefined;
|
|
287184
|
+
if (cached2 && cached2.fingerprint !== fingerprint)
|
|
287185
|
+
cached2 = undefined;
|
|
287186
|
+
const byResource = cached2?.byResource ?? new Map(tables.map((t) => [t.resource, t]));
|
|
287187
|
+
const index = cached2?.index ?? import_lunr3.default(function() {
|
|
287188
|
+
this.ref("resource");
|
|
287189
|
+
this.field("text");
|
|
287190
|
+
this.metadataWhitelist = [];
|
|
287191
|
+
for (const table of tables) {
|
|
287192
|
+
this.add({ resource: table.resource, text: tableIndexText(table) });
|
|
287193
|
+
}
|
|
287194
|
+
});
|
|
287195
|
+
if (cacheKey && !cached2) {
|
|
287196
|
+
lexicalCache.delete(cacheKey);
|
|
287197
|
+
lexicalCache.set(cacheKey, { index, byResource, fingerprint });
|
|
287198
|
+
while (lexicalCache.size > MAX_CACHED_SCHEMAS) {
|
|
287199
|
+
const oldest = lexicalCache.keys().next();
|
|
287200
|
+
if (oldest.done)
|
|
287201
|
+
break;
|
|
287202
|
+
lexicalCache.delete(oldest.value);
|
|
287203
|
+
}
|
|
287204
|
+
} else if (cacheKey && cached2) {
|
|
287205
|
+
lexicalCache.delete(cacheKey);
|
|
287206
|
+
lexicalCache.set(cacheKey, cached2);
|
|
287207
|
+
}
|
|
287208
|
+
let hits;
|
|
287209
|
+
try {
|
|
287210
|
+
hits = index.search(sanitized);
|
|
287211
|
+
} catch (error) {
|
|
287212
|
+
logger.warn("[MCP Tool searchDatabaseSchema] lunr search failed", {
|
|
287213
|
+
error: error instanceof Error ? error.message : String(error)
|
|
287214
|
+
});
|
|
287215
|
+
return { hits: [], matched: 0 };
|
|
287216
|
+
}
|
|
287217
|
+
return {
|
|
287218
|
+
hits: hits.slice(0, limit).map((hit) => {
|
|
287219
|
+
const table = byResource.get(hit.ref);
|
|
287220
|
+
return table ? { ...table, score: hit.score } : undefined;
|
|
287221
|
+
}).filter((t) => t !== undefined),
|
|
287222
|
+
matched: hits.length
|
|
287223
|
+
};
|
|
287224
|
+
}
|
|
287225
|
+
function cosineSimilarity(a, b) {
|
|
287226
|
+
if (a.length !== b.length || a.length === 0)
|
|
287227
|
+
return 0;
|
|
287228
|
+
let dot = 0;
|
|
287229
|
+
let magA = 0;
|
|
287230
|
+
let magB = 0;
|
|
287231
|
+
for (let i = 0;i < a.length; i++) {
|
|
287232
|
+
dot += a[i] * b[i];
|
|
287233
|
+
magA += a[i] * a[i];
|
|
287234
|
+
magB += b[i] * b[i];
|
|
287235
|
+
}
|
|
287236
|
+
if (magA === 0 || magB === 0)
|
|
287237
|
+
return 0;
|
|
287238
|
+
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
|
|
287239
|
+
}
|
|
287240
|
+
var vectorCache = new Map;
|
|
287241
|
+
var lexicalCache = new Map;
|
|
287242
|
+
var cooldownUntilMs = new Map;
|
|
287243
|
+
function touchCacheEntry(key) {
|
|
287244
|
+
const entry = vectorCache.get(key);
|
|
287245
|
+
if (entry) {
|
|
287246
|
+
vectorCache.delete(key);
|
|
287247
|
+
vectorCache.set(key, entry);
|
|
287248
|
+
}
|
|
287249
|
+
return entry;
|
|
287250
|
+
}
|
|
287251
|
+
function storeCacheEntry(key, entry) {
|
|
287252
|
+
vectorCache.delete(key);
|
|
287253
|
+
vectorCache.set(key, entry);
|
|
287254
|
+
while (vectorCache.size > MAX_CACHED_SCHEMAS) {
|
|
287255
|
+
const oldest = vectorCache.keys().next();
|
|
287256
|
+
if (oldest.done)
|
|
287257
|
+
break;
|
|
287258
|
+
vectorCache.delete(oldest.value);
|
|
287259
|
+
}
|
|
287260
|
+
}
|
|
287261
|
+
function pruneCooldowns() {
|
|
287262
|
+
if (cooldownUntilMs.size <= MAX_CACHED_SCHEMAS * 4)
|
|
287263
|
+
return;
|
|
287264
|
+
const now = Date.now();
|
|
287265
|
+
for (const [k, until] of cooldownUntilMs) {
|
|
287266
|
+
if (until <= now)
|
|
287267
|
+
cooldownUntilMs.delete(k);
|
|
287268
|
+
}
|
|
287269
|
+
}
|
|
287270
|
+
function providerKeyFor(provider) {
|
|
287271
|
+
return `${provider.model}\x00${provider.dimensions ?? ""}`;
|
|
287272
|
+
}
|
|
287273
|
+
async function tryRankSemantically(args) {
|
|
287274
|
+
const { tables, query, limit, provider, cacheKey } = args;
|
|
287275
|
+
if (tables.length === 0) {
|
|
287276
|
+
return null;
|
|
287277
|
+
}
|
|
287278
|
+
if (tables.length > MAX_INDEXED_TABLES) {
|
|
287279
|
+
logger.warn("[MCP Tool searchDatabaseSchema] Schema exceeds the semantic index cap; ranking lexically", { tableCount: tables.length, cap: MAX_INDEXED_TABLES });
|
|
287280
|
+
return null;
|
|
287281
|
+
}
|
|
287282
|
+
const until = cooldownUntilMs.get(cacheKey);
|
|
287283
|
+
if (until !== undefined && Date.now() < until)
|
|
287284
|
+
return null;
|
|
287285
|
+
const fingerprint = schemaFingerprint(tables);
|
|
287286
|
+
const providerKey = providerKeyFor(provider);
|
|
287287
|
+
let entry = touchCacheEntry(cacheKey);
|
|
287288
|
+
try {
|
|
287289
|
+
if (!entry || entry.fingerprint !== fingerprint || entry.providerKey !== providerKey) {
|
|
287290
|
+
const texts = tables.map(tableIndexText);
|
|
287291
|
+
const vectors = await provider.embedBatch(texts, EMBEDDING_BATCH_TIMEOUT_MS);
|
|
287292
|
+
if (vectors.length !== tables.length) {
|
|
287293
|
+
throw new Error(`Embedding provider returned ${vectors.length} vectors for ${tables.length} inputs`);
|
|
287294
|
+
}
|
|
287295
|
+
entry = {
|
|
287296
|
+
vectors: new Map(tables.map((t, i) => [t.resource, vectors[i]])),
|
|
287297
|
+
fingerprint,
|
|
287298
|
+
providerKey
|
|
287299
|
+
};
|
|
287300
|
+
storeCacheEntry(cacheKey, entry);
|
|
287301
|
+
}
|
|
287302
|
+
const [queryVector] = await provider.embedBatch([query], EMBEDDING_QUERY_TIMEOUT_MS);
|
|
287303
|
+
if (!queryVector)
|
|
287304
|
+
throw new Error("Empty query embedding");
|
|
287305
|
+
const scored = [];
|
|
287306
|
+
for (const table of tables) {
|
|
287307
|
+
const vector = entry.vectors.get(table.resource);
|
|
287308
|
+
if (!vector)
|
|
287309
|
+
continue;
|
|
287310
|
+
const score = cosineSimilarity(queryVector, vector);
|
|
287311
|
+
if (score >= MIN_SIMILARITY)
|
|
287312
|
+
scored.push({ ...table, score });
|
|
287313
|
+
}
|
|
287314
|
+
scored.sort((a, b) => b.score - a.score);
|
|
287315
|
+
return { hits: scored.slice(0, limit), matched: scored.length };
|
|
287316
|
+
} catch (error) {
|
|
287317
|
+
cooldownUntilMs.set(cacheKey, Date.now() + PROVIDER_FAILURE_COOLDOWN_MS2);
|
|
287318
|
+
pruneCooldowns();
|
|
287319
|
+
logger.warn("[MCP Tool searchDatabaseSchema] Embedding failed; ranking lexically", {
|
|
287320
|
+
cacheKey,
|
|
287321
|
+
error: error instanceof Error ? error.message : String(error)
|
|
287322
|
+
});
|
|
287323
|
+
return null;
|
|
287324
|
+
}
|
|
287325
|
+
}
|
|
287326
|
+
async function rankTables(args) {
|
|
287327
|
+
const { tables, query, limit, provider, cacheKey } = args;
|
|
287328
|
+
if (!sanitizeQuery(query)) {
|
|
287329
|
+
return { hits: [], matched: 0, ranking: "lexical", emptyQuery: true };
|
|
287330
|
+
}
|
|
287331
|
+
if (provider) {
|
|
287332
|
+
const semantic = await tryRankSemantically({
|
|
287333
|
+
tables,
|
|
287334
|
+
query,
|
|
287335
|
+
limit,
|
|
287336
|
+
provider,
|
|
287337
|
+
cacheKey
|
|
287338
|
+
});
|
|
287339
|
+
if (semantic !== null)
|
|
287340
|
+
return { ...semantic, ranking: "semantic" };
|
|
287341
|
+
}
|
|
287342
|
+
return {
|
|
287343
|
+
...rankLexically(tables, query, limit, cacheKey),
|
|
287344
|
+
ranking: "lexical"
|
|
287345
|
+
};
|
|
287346
|
+
}
|
|
287347
|
+
|
|
287348
|
+
// src/mcp/tools/search_database_schema_tool.ts
|
|
287349
|
+
var DEFAULT_LIMIT = 20;
|
|
287350
|
+
var MAX_LIMIT = 100;
|
|
287351
|
+
var MAX_COLUMNS_PER_TABLE = 80;
|
|
287352
|
+
var MAX_ARG_CHARS = 256;
|
|
287353
|
+
var searchDatabaseSchemaShape = {
|
|
287354
|
+
environmentName: exports_external.string().max(MAX_ARG_CHARS).optional().describe("Environment to look in. Omit to list the environments and their connections."),
|
|
287355
|
+
connectionName: exports_external.string().max(MAX_ARG_CHARS).optional().describe("Connection to introspect. Omit to list the connections in the environment."),
|
|
287356
|
+
packageName: exports_external.string().max(MAX_ARG_CHARS).optional().describe('Required only for the per-package "duckdb" sandbox connection, which exists once per package.'),
|
|
287357
|
+
schemaName: exports_external.string().max(MAX_ARG_CHARS).optional().describe(`Schema (or dataset/database) to list tables from. Omit to list the connection's schemas and use one of those names verbatim: DuckDB qualifies them as "catalog.schema" (for example "memory.main"), so a bare "main" is rejected.`),
|
|
287358
|
+
tableName: exports_external.string().max(MAX_ARG_CHARS).optional().describe("A single table to return in full, with every column. Requires schemaName."),
|
|
287359
|
+
searchQuery: exports_external.string().max(500).optional().describe(`Plain-English description of the data you are looking for, e.g. "customer orders and shipping addresses". Requires schemaName. Omit to list the schema's tables in order.`),
|
|
287360
|
+
limit: exports_external.number().int().positive().max(MAX_LIMIT).optional().describe(`Maximum tables to return. Default ${DEFAULT_LIMIT}.`),
|
|
287361
|
+
offset: exports_external.number().int().min(0).optional().describe("Tables to skip, for paging a long table listing. Pass back the nextOffset from a previous response. Ignored when searchQuery is set, because ranked results cannot be paged.")
|
|
287362
|
+
};
|
|
287363
|
+
function escapeMalloyString2(value) {
|
|
287364
|
+
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
287365
|
+
}
|
|
287366
|
+
function malloyIdentifier(name) {
|
|
287367
|
+
return `\`${name.replace(/\\/g, "\\\\").replace(/`/g, "\\`")}\``;
|
|
287368
|
+
}
|
|
287369
|
+
function malloySourceSnippet(connectionName, resource, tableName) {
|
|
287370
|
+
const alias = malloyIdentifier(tableName);
|
|
287371
|
+
const connection = malloyIdentifier(connectionName);
|
|
287372
|
+
return `source: ${alias} is ${connection}.table('${escapeMalloyString2(resource)}') extend { }`;
|
|
287373
|
+
}
|
|
287374
|
+
var DUCKDB_FILE_PATH = /^[A-Za-z0-9._~:/?#@!$&*+,=%-]+$/;
|
|
287375
|
+
var STRICT_BARE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
287376
|
+
function isPastableTablePath(resource) {
|
|
287377
|
+
if (!resource)
|
|
287378
|
+
return false;
|
|
287379
|
+
if (resource.includes("/"))
|
|
287380
|
+
return DUCKDB_FILE_PATH.test(resource);
|
|
287381
|
+
if (DATA_FILE_EXTENSION.test(resource))
|
|
287382
|
+
return false;
|
|
287383
|
+
return resource.split(".").every((seg) => STRICT_BARE_IDENT.test(seg));
|
|
287384
|
+
}
|
|
287385
|
+
function canPasteSource(entity) {
|
|
287386
|
+
return Boolean(entity.tableName) && isPastableTablePath(entity.resource);
|
|
287387
|
+
}
|
|
287388
|
+
var DATA_FILE_EXTENSION = /\.(parquet|csv|tsv|json|jsonl|ndjson|xlsx)$/i;
|
|
287389
|
+
function bareTableName2(resource) {
|
|
287390
|
+
const lastSlash = resource.lastIndexOf("/");
|
|
287391
|
+
if (lastSlash >= 0) {
|
|
287392
|
+
return resource.slice(lastSlash + 1).replace(DATA_FILE_EXTENSION, "");
|
|
287393
|
+
}
|
|
287394
|
+
if (DATA_FILE_EXTENSION.test(resource)) {
|
|
287395
|
+
return resource.replace(DATA_FILE_EXTENSION, "");
|
|
287396
|
+
}
|
|
287397
|
+
return resource.slice(resource.lastIndexOf(".") + 1);
|
|
287398
|
+
}
|
|
287399
|
+
var SANDBOX_CONNECTION = "duckdb";
|
|
287400
|
+
async function listConnectionsFor(environment) {
|
|
287401
|
+
const connections = environment.listApiConnections().map((c) => ({ name: c.name ?? "", type: c.type ?? "" }));
|
|
287402
|
+
let packages = [];
|
|
287403
|
+
try {
|
|
287404
|
+
packages = (await environment.listPackages()).map((p) => p.name).filter((n) => Boolean(n));
|
|
287405
|
+
} catch {
|
|
287406
|
+
packages = [];
|
|
287407
|
+
}
|
|
287408
|
+
if (packages.length > 0) {
|
|
287409
|
+
connections.push({
|
|
287410
|
+
name: SANDBOX_CONNECTION,
|
|
287411
|
+
type: "duckdb",
|
|
287412
|
+
scope: "package",
|
|
287413
|
+
packages
|
|
287414
|
+
});
|
|
287415
|
+
}
|
|
287416
|
+
return connections;
|
|
287417
|
+
}
|
|
287418
|
+
function toResponseTable(entity, options) {
|
|
287419
|
+
return {
|
|
287420
|
+
connectionName: entity.connectionName,
|
|
287421
|
+
schemaName: entity.schemaName,
|
|
287422
|
+
tableName: entity.tableName,
|
|
287423
|
+
tablePath: entity.resource,
|
|
287424
|
+
...canPasteSource(entity) ? {
|
|
287425
|
+
malloySource: malloySourceSnippet(entity.connectionName, entity.resource, entity.tableName)
|
|
287426
|
+
} : {},
|
|
287427
|
+
columns: entity.columns.slice(0, options.maxColumns),
|
|
287428
|
+
columnCount: entity.columns.length,
|
|
287429
|
+
...options.score !== undefined ? { score: options.score } : {}
|
|
287430
|
+
};
|
|
287431
|
+
}
|
|
287432
|
+
var SEARCH_DATABASE_SCHEMA_DESCRIPTION = `Find the tables in a database connection, by plain-English description. Use it to model a database you have not modelled yet, or to check schema, table and column names. To search an existing model, use malloy_getContext.
|
|
287433
|
+
|
|
287434
|
+
## Drill down, one level at a time
|
|
287435
|
+
Supply what you know, omit the rest. No arguments lists the environments and their connections; + connectionName lists its schemas; + schemaName lists its tables (up to ${MAX_COLUMNS_PER_TABLE} columns each; add searchQuery to rank them); + tableName returns that one table with every column.
|
|
287436
|
+
|
|
287437
|
+
## Contract rules
|
|
287438
|
+
- Use connectionName, tablePath and column names exactly as returned.
|
|
287439
|
+
- A connection with scope "package" (the "duckdb" sandbox) is per package: pass packageName too, from those it lists.
|
|
287440
|
+
- Schemas marked isHidden are system schemas; your tables are in the others.
|
|
287441
|
+
- malloySource is the ready-to-use \`source:\` line; its identifiers are already quoted, so paste it as-is.
|
|
287442
|
+
- Names and types only: no row value is returned. For a column's values, run malloy_executeQuery against a model using this connection: \`run: c.table('s.t') -> { group_by: col }\`.
|
|
287443
|
+
- No tables for a searchQuery means nothing matched, not an empty schema. Broaden it, or list without one.
|
|
287444
|
+
- An empty schema may still hold data: DuckDB over CSV or Parquet addresses files by path, registering none.
|
|
287445
|
+
- Read warnings: they name anything omitted or ignored.
|
|
287446
|
+
|
|
287447
|
+
## Response
|
|
287448
|
+
JSON: tables (connectionName, schemaName, tableName, tablePath, malloySource, columns, columnCount, and score on a search), plus totalAvailable and returned. A search adds matched and ranking; a listing adds nextOffset when more remain, to pass back as offset.
|
|
287449
|
+
|
|
287450
|
+
## Worked example
|
|
287451
|
+
Start with no arguments and follow what it names. For connection "warehouse", schema "sales":
|
|
287452
|
+
{ "environmentName": "examples", "connectionName": "warehouse", "schemaName": "sales", "searchQuery": "customer orders" }
|
|
287453
|
+
Then paste that table's malloySource verbatim, e.g. source: \`orders\` is \`warehouse\`.table('sales.orders') extend { }`;
|
|
287454
|
+
function registerSearchDatabaseSchemaTool(mcpServer, environmentStore) {
|
|
287455
|
+
const connectionController = new ConnectionController(environmentStore);
|
|
287456
|
+
const unpastableWarning = (entities) => {
|
|
287457
|
+
const n = entities.filter((e) => !canPasteSource(e)).length;
|
|
287458
|
+
if (n === 0)
|
|
287459
|
+
return [];
|
|
287460
|
+
return [
|
|
287461
|
+
`${n} table(s) have a path this server will not vouch for across every dialect it serves, so malloySource is omitted for them rather than risk a line that does not compile. Build it from tablePath: on your own dialect the path may work as-is, or may need the offending segment quoted. Double quotes on DuckDB, Postgres, Snowflake and Trino; backticks on MySQL, BigQuery and Databricks.`
|
|
287462
|
+
];
|
|
287463
|
+
};
|
|
287464
|
+
const assertCanAdmit = async (environmentName) => {
|
|
287465
|
+
(await environmentStore.getEnvironment(environmentName, false)).assertCanAdmitQuery();
|
|
287466
|
+
};
|
|
287467
|
+
mcpServer.tool("malloy_searchDatabaseSchema", SEARCH_DATABASE_SCHEMA_DESCRIPTION, searchDatabaseSchemaShape, async (params) => {
|
|
287468
|
+
const {
|
|
287469
|
+
environmentName,
|
|
287470
|
+
connectionName,
|
|
287471
|
+
packageName,
|
|
287472
|
+
schemaName,
|
|
287473
|
+
tableName,
|
|
287474
|
+
searchQuery,
|
|
287475
|
+
limit,
|
|
287476
|
+
offset
|
|
287477
|
+
} = params;
|
|
287478
|
+
const max = limit ?? DEFAULT_LIMIT;
|
|
287479
|
+
const skip = offset ?? 0;
|
|
287480
|
+
const uri = buildMalloyUri({
|
|
287481
|
+
environment: environmentName,
|
|
287482
|
+
package: packageName
|
|
287483
|
+
}, "search-database-schema");
|
|
287484
|
+
logger.info("[MCP Tool searchDatabaseSchema] Searching schema", {
|
|
287485
|
+
environmentName,
|
|
287486
|
+
connectionName,
|
|
287487
|
+
packageName,
|
|
287488
|
+
schemaName,
|
|
287489
|
+
tableName,
|
|
287490
|
+
searchQuery,
|
|
287491
|
+
limit,
|
|
287492
|
+
offset
|
|
287493
|
+
});
|
|
287494
|
+
try {
|
|
287495
|
+
const ignored = [];
|
|
287496
|
+
const noteIgnored = (entries) => {
|
|
287497
|
+
for (const [name, value] of entries) {
|
|
287498
|
+
if (value !== undefined && value !== "" && !ignored.includes(name)) {
|
|
287499
|
+
ignored.push(name);
|
|
287500
|
+
}
|
|
287501
|
+
}
|
|
287502
|
+
};
|
|
287503
|
+
const pagingArgs = [
|
|
287504
|
+
["limit", limit],
|
|
287505
|
+
["offset", offset]
|
|
287506
|
+
];
|
|
287507
|
+
if (!environmentName) {
|
|
287508
|
+
noteIgnored([
|
|
287509
|
+
["connectionName", connectionName],
|
|
287510
|
+
["packageName", packageName],
|
|
287511
|
+
["schemaName", schemaName],
|
|
287512
|
+
["tableName", tableName],
|
|
287513
|
+
["searchQuery", searchQuery],
|
|
287514
|
+
...pagingArgs
|
|
287515
|
+
]);
|
|
287516
|
+
} else if (!connectionName) {
|
|
287517
|
+
noteIgnored([
|
|
287518
|
+
["packageName", packageName],
|
|
287519
|
+
["schemaName", schemaName],
|
|
287520
|
+
["tableName", tableName],
|
|
287521
|
+
["searchQuery", searchQuery],
|
|
287522
|
+
...pagingArgs
|
|
287523
|
+
]);
|
|
287524
|
+
} else if (!schemaName) {
|
|
287525
|
+
noteIgnored([
|
|
287526
|
+
["tableName", tableName],
|
|
287527
|
+
["searchQuery", searchQuery],
|
|
287528
|
+
...pagingArgs
|
|
287529
|
+
]);
|
|
287530
|
+
} else if (tableName) {
|
|
287531
|
+
noteIgnored([["searchQuery", searchQuery], ...pagingArgs]);
|
|
287532
|
+
} else if (searchQuery !== undefined) {
|
|
287533
|
+
noteIgnored([["offset", offset]]);
|
|
287534
|
+
}
|
|
287535
|
+
if (connectionName && connectionName !== SANDBOX_CONNECTION) {
|
|
287536
|
+
noteIgnored([["packageName", packageName]]);
|
|
287537
|
+
}
|
|
287538
|
+
const ignoredWarning = ignored.length > 0 ? [
|
|
287539
|
+
`Ignored ${ignored.join(", ")}. Each level needs the one above it: environmentName, then connectionName (with packageName for the per-package "duckdb" sandbox), then schemaName, then either tableName or searchQuery. limit and offset apply only to a plain table listing, and offset does nothing on a ranked search because ranked results cannot be paged.`
|
|
287540
|
+
] : [];
|
|
287541
|
+
if (!environmentName) {
|
|
287542
|
+
const environments = await environmentStore.listEnvironments();
|
|
287543
|
+
const results = await Promise.all(environments.map(async (env) => {
|
|
287544
|
+
const name = env.name ?? "";
|
|
287545
|
+
let connections = [];
|
|
287546
|
+
try {
|
|
287547
|
+
const environment = await environmentStore.getEnvironment(name, false);
|
|
287548
|
+
connections = await listConnectionsFor(environment);
|
|
287549
|
+
} catch (error) {
|
|
287550
|
+
logger.debug("[MCP Tool searchDatabaseSchema] Could not list connections", {
|
|
287551
|
+
environmentName: name,
|
|
287552
|
+
error: error instanceof Error ? error.message : String(error)
|
|
287553
|
+
});
|
|
287554
|
+
}
|
|
287555
|
+
return { name, connections };
|
|
287556
|
+
}));
|
|
287557
|
+
return jsonResource(uri, {
|
|
287558
|
+
environments: results,
|
|
287559
|
+
...ignoredWarning.length > 0 ? { warnings: ignoredWarning } : {}
|
|
287560
|
+
});
|
|
287561
|
+
}
|
|
287562
|
+
if (!connectionName) {
|
|
287563
|
+
const environment = await environmentStore.getEnvironment(environmentName, false);
|
|
287564
|
+
const connections = await listConnectionsFor(environment);
|
|
287565
|
+
return jsonResource(uri, {
|
|
287566
|
+
environmentName,
|
|
287567
|
+
connections,
|
|
287568
|
+
...ignoredWarning.length > 0 ? { warnings: ignoredWarning } : {}
|
|
287569
|
+
});
|
|
287570
|
+
}
|
|
287571
|
+
if (!schemaName) {
|
|
287572
|
+
await assertCanAdmit(environmentName);
|
|
287573
|
+
const schemas = await connectionController.listSchemas(environmentName, connectionName, packageName);
|
|
287574
|
+
return jsonResource(uri, {
|
|
287575
|
+
environmentName,
|
|
287576
|
+
connectionName,
|
|
287577
|
+
schemas: schemas.map((s) => ({
|
|
287578
|
+
name: s.name ?? "",
|
|
287579
|
+
isDefault: s.isDefault ?? false,
|
|
287580
|
+
isHidden: s.isHidden ?? false
|
|
287581
|
+
})),
|
|
287582
|
+
...ignoredWarning.length > 0 ? { warnings: ignoredWarning } : {}
|
|
287583
|
+
});
|
|
287584
|
+
}
|
|
287585
|
+
if (tableName) {
|
|
287586
|
+
await assertCanAdmit(environmentName);
|
|
287587
|
+
const tables = await connectionController.listTables(environmentName, connectionName, schemaName, [tableName], packageName);
|
|
287588
|
+
if (tables.length === 0) {
|
|
287589
|
+
return jsonToolError(uri, {
|
|
287590
|
+
message: `Table "${tableName}" not found in schema "${schemaName}" of connection "${connectionName}".`,
|
|
287591
|
+
suggestions: [
|
|
287592
|
+
`List the schema's tables by calling this tool with schemaName "${schemaName}" and no tableName.`,
|
|
287593
|
+
"Check the table name's spelling and case; some warehouses are case-sensitive."
|
|
287594
|
+
]
|
|
287595
|
+
}, { tables: [] });
|
|
287596
|
+
}
|
|
287597
|
+
const exact = tables.filter((t) => bareTableName2(t.resource ?? "") === tableName);
|
|
287598
|
+
const matching = exact.length > 0 ? exact : tables.filter((t) => bareTableName2(t.resource ?? "").toLowerCase() === tableName.toLowerCase());
|
|
287599
|
+
const entities2 = (matching.length > 0 ? matching : tables).map((t) => toEntity(t, connectionName, schemaName));
|
|
287600
|
+
if (matching.length === 0) {
|
|
287601
|
+
return jsonToolError(uri, {
|
|
287602
|
+
message: `Table "${tableName}" not found in schema "${schemaName}" of connection "${connectionName}".`,
|
|
287603
|
+
suggestions: [
|
|
287604
|
+
`This schema lists ${tables.length} table(s). Call this tool with schemaName "${schemaName}" and no tableName to see them.`,
|
|
287605
|
+
"Check the table name's spelling and case; some warehouses are case-sensitive."
|
|
287606
|
+
]
|
|
287607
|
+
}, { tables: [] });
|
|
287608
|
+
}
|
|
287609
|
+
return jsonResource(uri, {
|
|
287610
|
+
environmentName,
|
|
287611
|
+
connectionName,
|
|
287612
|
+
schemaName,
|
|
287613
|
+
tables: entities2.map((e) => toResponseTable(e, {
|
|
287614
|
+
maxColumns: Number.MAX_SAFE_INTEGER
|
|
287615
|
+
})),
|
|
287616
|
+
totalAvailable: entities2.length,
|
|
287617
|
+
returned: entities2.length,
|
|
287618
|
+
...(() => {
|
|
287619
|
+
const w = [
|
|
287620
|
+
...ignoredWarning,
|
|
287621
|
+
...unpastableWarning(entities2)
|
|
287622
|
+
];
|
|
287623
|
+
return w.length > 0 ? { warnings: w } : {};
|
|
287624
|
+
})()
|
|
287625
|
+
});
|
|
287626
|
+
}
|
|
287627
|
+
await assertCanAdmit(environmentName);
|
|
287628
|
+
const allTables = await connectionController.listTables(environmentName, connectionName, schemaName, undefined, packageName);
|
|
287629
|
+
const entities = allTables.map((t) => toEntity(t, connectionName, schemaName));
|
|
287630
|
+
const warnings = [...ignoredWarning];
|
|
287631
|
+
if (entities.length === 0) {
|
|
287632
|
+
warnings.push(`No tables are registered in schema "${schemaName}". If this connection reads data files directly (DuckDB over CSV or Parquet), those files are not listed in a schema; reference them by path instead, for example ${connectionName}.table('data/orders.parquet'). Otherwise check the schema name against the schema list for this connection.`);
|
|
287633
|
+
}
|
|
287634
|
+
let page;
|
|
287635
|
+
let ranking;
|
|
287636
|
+
let nextOffset;
|
|
287637
|
+
let matched;
|
|
287638
|
+
if (searchQuery !== undefined) {
|
|
287639
|
+
const provider = resolveProvider();
|
|
287640
|
+
const ranked = await rankTables({
|
|
287641
|
+
tables: entities,
|
|
287642
|
+
query: searchQuery,
|
|
287643
|
+
limit: max,
|
|
287644
|
+
provider,
|
|
287645
|
+
cacheKey: [
|
|
287646
|
+
environmentName,
|
|
287647
|
+
connectionName === SANDBOX_CONNECTION ? packageName ?? "" : "",
|
|
287648
|
+
connectionName,
|
|
287649
|
+
schemaName
|
|
287650
|
+
].join("\x00")
|
|
287651
|
+
});
|
|
287652
|
+
ranking = ranked.emptyQuery ? undefined : ranked.ranking;
|
|
287653
|
+
matched = ranked.emptyQuery ? undefined : ranked.matched;
|
|
287654
|
+
page = ranked.hits.map((hit) => ({
|
|
287655
|
+
entity: hit,
|
|
287656
|
+
score: hit.score
|
|
287657
|
+
}));
|
|
287658
|
+
if (ranked.emptyQuery) {
|
|
287659
|
+
warnings.push(`searchQuery carried no searchable content, so no ranking was run. Provide words to search for, or omit searchQuery to list the schema's tables.`);
|
|
287660
|
+
} else if (ranked.hits.length === 0 && entities.length > 0) {
|
|
287661
|
+
warnings.push(`No table in "${schemaName}" matched "${searchQuery}". List the schema without a searchQuery to see everything in it.`);
|
|
287662
|
+
}
|
|
287663
|
+
if (ranked.matched > page.length) {
|
|
287664
|
+
warnings.push(ranked.ranking === "lexical" ? `${ranked.matched} tables share at least one term with "${searchQuery}"; the top ${page.length} by relevance are shown. Term-matching counts loosely, so a large number here is normal and does not mean that many tables are relevant. Ranked results cannot be paged: make searchQuery more specific, or raise limit (max ${MAX_LIMIT}) if you want more of this ranking.` : `${ranked.matched} tables scored above the relevance floor for "${searchQuery}"; the top ${page.length} are shown. Ranked results cannot be paged: make searchQuery more specific, or raise limit (max ${MAX_LIMIT}).`);
|
|
287665
|
+
}
|
|
287666
|
+
} else {
|
|
287667
|
+
page = entities.slice(skip, skip + max).map((entity) => ({ entity }));
|
|
287668
|
+
if (skip + max < entities.length) {
|
|
287669
|
+
nextOffset = skip + max;
|
|
287670
|
+
warnings.push(`Showing ${page.length} of ${entities.length} tables. Pass offset ${nextOffset} for the next page, or add a searchQuery to narrow.`);
|
|
287671
|
+
} else if (page.length === 0 && entities.length > 0) {
|
|
287672
|
+
warnings.push(`offset ${skip} is past the end of this schema, which has ${entities.length} tables. Use an offset below ${entities.length}, or omit it to start from the beginning.`);
|
|
287673
|
+
}
|
|
287674
|
+
}
|
|
287675
|
+
warnings.push(...unpastableWarning(page.map(({ entity }) => entity)));
|
|
287676
|
+
const capped = page.filter(({ entity }) => entity.columns.length > MAX_COLUMNS_PER_TABLE).length;
|
|
287677
|
+
if (capped > 0) {
|
|
287678
|
+
warnings.push(`${capped} table(s) have more than ${MAX_COLUMNS_PER_TABLE} columns; only the first ${MAX_COLUMNS_PER_TABLE} are shown. Pass tableName to see all columns of one table.`);
|
|
287679
|
+
}
|
|
287680
|
+
return jsonResource(uri, {
|
|
287681
|
+
environmentName,
|
|
287682
|
+
connectionName,
|
|
287683
|
+
schemaName,
|
|
287684
|
+
tables: page.map(({ entity, score }) => toResponseTable(entity, {
|
|
287685
|
+
maxColumns: MAX_COLUMNS_PER_TABLE,
|
|
287686
|
+
score
|
|
287687
|
+
})),
|
|
287688
|
+
totalAvailable: entities.length,
|
|
287689
|
+
returned: page.length,
|
|
287690
|
+
...matched !== undefined ? { matched } : {},
|
|
287691
|
+
...nextOffset !== undefined ? { nextOffset } : {},
|
|
287692
|
+
...ranking ? { ranking } : {},
|
|
287693
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
287694
|
+
});
|
|
287695
|
+
} catch (error) {
|
|
287696
|
+
const identifier = [environmentName, connectionName, schemaName].filter(Boolean).join("/");
|
|
287697
|
+
return jsonToolError(uri, classifyToolError("searchDatabaseSchema", identifier, error), { tables: [] });
|
|
287698
|
+
}
|
|
287699
|
+
});
|
|
287700
|
+
}
|
|
287701
|
+
function resolveProvider() {
|
|
287702
|
+
try {
|
|
287703
|
+
if (!schemaEmbeddingEnabled())
|
|
287704
|
+
return null;
|
|
287705
|
+
return getEmbeddingProvider();
|
|
287706
|
+
} catch (error) {
|
|
287707
|
+
logger.warn("[MCP Tool searchDatabaseSchema] Embedding config invalid; ranking lexically", { error: error instanceof Error ? error.message : String(error) });
|
|
287708
|
+
return null;
|
|
287709
|
+
}
|
|
287710
|
+
}
|
|
287711
|
+
function toEntity(table, connectionName, schemaName) {
|
|
287712
|
+
const resource = table.resource ?? "";
|
|
287713
|
+
return {
|
|
287714
|
+
connectionName,
|
|
287715
|
+
schemaName,
|
|
287716
|
+
tableName: bareTableName2(resource),
|
|
287717
|
+
resource,
|
|
287718
|
+
columns: (table.columns ?? []).map((c) => ({
|
|
287719
|
+
name: c.name ?? "",
|
|
287720
|
+
...c.type ? { type: c.type } : {}
|
|
287721
|
+
}))
|
|
287722
|
+
};
|
|
287723
|
+
}
|
|
286864
287724
|
// src/mcp/skills/skills_bundle.json
|
|
286865
287725
|
var skills_bundle_default = {
|
|
286866
287726
|
skills: [{ name: "malloy", description: 'Index of all Malloy skills. Use when user asks "malloy help", "what malloy skills are available", "how do I use malloy", or needs guidance on which Malloy skill to use.', body: `# Malloy Skills Index
|
|
@@ -287537,7 +288397,7 @@ Once the definitions are confirmed, write them into the \`.malloy\` model (see y
|
|
|
287537
288397
|
|
|
287538
288398
|
## Output
|
|
287539
288399
|
|
|
287540
|
-
A confirmed source architecture and a confirmed set of field definitions (renames, dimensions, measures, business decisions), held in the conversation and ready to write into the \`.malloy\` model via your modeling workflow.` }, { name: "malloy-discover", description: "Silent data discovery for Malloy modeling. Used at Step 1 of the modeling workflow. Scans tables, columns, distributions, and relationships without user interaction. The agent builds an internal picture before presenting anything.", body: "# Data Discovery (Step 1, Silent)\n\n> **CRITICAL**: Read the model before writing ANY Malloy code. The model defines the sources, connection names, and fields. Never guess connection names.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n> **PREREQUISITE:** Make sure the Malloy MCP tools (`get_context`, `execute_query`, `search_malloy_docs`) are configured and reachable. If they are not, stop and resolve the MCP connection before continuing.\n\n**This step is silent.** The agent does not present findings to the user yet. That happens in the next step (PROPOSE SCOPE).\n\n## Tools\n\n- **`get_context`**: Ground yourself in the package's sources, views, and fields (with their docs). Call FIRST. The sources and their join paths are the schema you build on.\n- **`execute_query`**: Run ad-hoc queries to preview data, verify values, check NULLs, validate assumptions.\n- **`search_malloy_docs`**: Get Malloy syntax help when needed.\n\n## Workflow\n\n```\n1. Check for prior art signals → If found, ask user: \"I found [LookML/dbt] files, use as prior art?\"\n2. If user confirms: read adapter reference → Follow skill:malloy-lookml-review, keep prior-art notes in-conversation\n3. get_context → Ground yourself: sources, views, fields\n4. Inspect source definitions → See ALL fields and join paths for key sources\n5. Derive candidate joins/dimensions/measures → Read them off the model and the data, not a suggestion tool\n6. Define a minimal source if one is missing → Just enough to run execute_query for previews\n7. execute_query(query) → Preview data, verify values, check NULLs, check duplicates\n8. search_malloy_docs(query) → Get syntax help when needed\n9. Proceed to Step 2 (PROPOSE SCOPE)\n```\n\n**If the model has no sources defined** and no LookML files are present, do NOT silently retry or proceed without data. Tell the user: \"No model sources were found. Please check that the package points at a connected data source, then try again.\"\n\n**If the model has no sources defined** but LookML files ARE present (LookML-only mode), skip steps 3-7. Use connection name and table paths from the LookML review. Flag all proposals as unvalidated.\n\n**Key principle:** Query data to verify assumptions. Don't ask the user to confirm values you can check yourself.\n\n**Search docs proactively.** If you discover patterns that need derived/pre-aggregated sources, window functions, or unfamiliar features, call `search_malloy_docs` BEFORE writing code, not just when you hit errors.\n\n## Query File for Discovery\n\n**In the schema-first workflow:** Run ad-hoc queries with `execute_query`. If the source you want to preview is not yet defined in the model, define a minimal one against the connection and table so you can run previews. The real model fields are built in later steps.\n\n```malloy\n// minimal source for previewing data during discovery\nsource: explore is my_conn.table('schema.table') extend {}\n```\n\n**In analysis-first mode:** There is no temp file. The analysis `.malloy` file IS your working file. It grows throughout the session and becomes the input for formalizing into a model. See `skill:malloy-analyze` for that workflow.\n\n## What to Capture\n\nWhen reviewing tables and columns, capture:\n\n### Table-Level\n- All tables with row counts\n- Connection name and schema (CRITICAL, never guess)\n- Table roles: fact, dimension, bridge, lookup, staging, operational\n- Join relationships (FK → PK mappings)\n\n### Column-Level\n- Primary key and foreign key columns\n- Data types (watch for string dates, arrays, JSON)\n- Reserved word columns that need backticking (`Date`, `Type`, `number`, `source`, etc.)\n- Column cardinality and NULL rates (via `execute_query`)\n- Data distributions for key numeric and categorical columns\n\n### Data Quality\n- **Check for duplicate rows** on primary keys. Run `group_by: pk, aggregate: count(), having: count() > 1` on each key table. Duplicates cause `sum()` to return nonsensical values.\n- **Denormalized count columns**: beware pre-aggregated fields (e.g., `order_count` in a customer table) that may conflict with joined counts.\n- **Delimited list columns**: flag string columns containing comma-separated values.\n\n### Data-Driven Validation\n\n**Every recommendation must be grounded in queried data, not schema inference.** During discovery, run `execute_query` to validate assumptions before proposing anything in later steps.\n\n| What to validate | Query to run |\n|-----------------|-------------|\n| **Denormalized vs joined values** | Compare pre-computed columns (e.g., `customers.order_count`) against the actual joined aggregate (`count()` from `orders`). Report discrepancy rate. If >0%, flag for user decision. |\n| **Candidate date fields** | When multiple date/timestamp columns exist, query both. What % of rows differ? By how much? This informs which is canonical. |\n| **Numeric column distributions** | Query min, max, avg, percentiles (p25, p50, p75, p95). These inform tier boundaries and detect outliers. |\n| **Categorical column cardinality** | Query distinct values. A `status` column with 5 values behaves differently from one with 500. |\n| **Column usefulness** | Query NULL rates. Columns that are >95% NULL are candidates for `internal`. |\n| **Join cardinality** | Query FK uniqueness: `group_by: fk_col, aggregate: row_count is count(), having: row_count > 1`. Determines `join_one` vs `join_many`. |\n| **Revenue/amount columns** | When multiple money columns exist (`total`, `subtotal`, `amount`, `price`), query a sample to understand how they relate (does `total = subtotal + tax`?). |\n| **Join key value compatibility** | For every proposed join, sample 5-10 actual values from each side. Check for format mismatches: abbreviations (\"4th Av\" vs \"4 Avenue\"), ordinals (\"23 St\" vs \"23rd St\"), casing, prefixes. Mismatched values mean the join won't work even if column names match. |\n| **Mixed-grain rows** | For each key table, run top-N and bottom-N by primary metric. Look for summary/aggregate rows mixed with detail data (e.g., \"System Total\" rows in a station-level table). These corrupt measures if not filtered out. |\n\n**Never assume from column names.** Always query the data to confirm. A column named `total` could include or exclude tax. A `status` column could have unexpected values. A FK could have orphaned references.\n\n### Example Queries\n\n**Tier boundaries**: query distribution, propose breaks from percentiles:\n```malloy\nrun: orders -> {\n aggregate:\n min_val is min(sale_price), p25 is sale_price.percentile(25)\n median_val is sale_price.percentile(50), p75 is sale_price.percentile(75)\n p95 is sale_price.percentile(95), max_val is max(sale_price)\n}\n```\n\n**Denormalized vs joined**: compare pre-computed column against real aggregate, report match rate:\n```malloy\nrun: customers -> {\n join_many: orders on customer_id = orders.customer_id\n aggregate:\n total is count()\n match is count() { where: order_count = count(orders.order_id) }\n}\n```\n\n**Canonical date**: when multiple date columns exist, check how often they differ:\n```malloy\nrun: orders -> {\n aggregate:\n total is count()\n same_date is count() { where: created_at::date = submitted_at::date }\n max_gap_days is max(days(submitted_at - created_at))\n}\n```\n\n**Revenue columns**: when multiple money columns exist, verify their relationship:\n```malloy\nrun: orders -> {\n aggregate:\n total_eq_parts is count() { where: abs(sale_price - (subtotal + tax)) < 0.01 }\n total is count()\n}\n```\n\n### Schema Shape\n- Is this a star/snowflake schema (use base + joined source layers) or normalized/ER-style (may need 3-stage pattern)?\n- Combined vs split tables: prefer filtered/split tables over combined when both exist.\n\n## Computed Source Detection\n\nFlag potential computed sources when:\n\n1. **Grain mismatch**: the analytical scope requires a grain that no physical table provides (e.g., customer-level metrics from an order-grain table)\n2. **Repeated aggregation patterns**: the same GROUP BY + aggregate pattern would be needed in multiple analyses\n3. **Cross-entity aggregations**: the model or the data implies cross-entity aggregations that require a pre-aggregated entity\n\n## Prior Art Detection\n\nCheck for prior art signals at the start of discovery. If a signal is found and the user confirms, **you MUST read** the corresponding reference skill and follow its instructions.\n\n| Signal | Source Type | Reference to Read |\n|--------|------------|-------------------|\n| `.lkml` files in project or subdirectories | lookml | `skill:malloy-lookml-review` |\n| `dbt_project.yml` in project or parent dirs | dbt | dbt review (future) |\n\nThe reference handles inventory, classification, and produces prior-art notes. Keep those notes in-conversation, then continue with normal discovery below.\n\n**If DB connection available (LookML + DB mode):**\n- Read the model and run `execute_query` as normal\n- Use prior art as additional context, not a replacement for data validation\n- **The LookML connection name is NOT the Malloy connection name.** Always use the connection name from the model.\n\n**If no DB connection (LookML-only mode):**\n- Skip the model-read and `execute_query` steps\n- Use connection name and table paths extracted from prior art source files\n- Flag all proposals in Steps 2-4 as **unvalidated**\n- Proceed directly to Step 2 (PROPOSE SCOPE)\n\n**Prior art findings enhance discovery, they don't replace it.** When a DB connection is available, always validate assumptions against the actual data.\n\n## After Discovery\n\nDo NOT present findings to the user yet.\n\n## Done\n\nStep complete. Output: discovery findings (internal: tables, columns, relationships, data quality, prior art). Continue to the next modeling step (see your modeling workflow).\n\n## Verify Source Joins\n\nWhen reading joins off the model or the data, watch for `join_many` where the actual relationship is many-to-one. Always verify cardinality. Prefer `join_one` when each row in the primary table matches at most one row in the joined table." }, { name: "malloy-document", description: 'Add documentation with #(doc) tags to Malloy models so fields and sources are described in plain language. Use when user asks to "add documentation", "add doc tags", "document the model", or wants fields and sources described for natural-language search and discovery. For declaring parameterizable filters with #(filter), see the malloy-model skill. Filters are a runtime/modeling construct (governance, latency, correctness), not a documentation tag.', body: "# Documenting a Malloy Model\n\nAdd `#(doc)` tags to describe sources and fields in plain language so they are easy to find and understand:\n\n| Tag | Purpose | Goes on |\n|-----|---------|---------|\n| `#(doc)` | Plain-language description for natural-language search | source, dimension, measure, view, join |\n| `#(filter)` | Declare a parameterizable filter (runtime/modeling concern, see `malloy-model`) | source |\n\n`#(doc)` is a standard Malloy annotation. It documents a field or source with a human-readable description that downstream tools can surface and search against.\n\n## #(doc) Tag\n\nAdd before any source, dimension, measure, view, or join. When multiple fields share a keyword, use it once as a block header. Tags and field names are indented under the keyword; tags go on the line(s) directly above the field they annotate.\n\n**Tag ordering** (when a field has multiple tags): `#(doc)` → render tags (`# currency`, `# label`, etc.) → field name. Separate each field group with a blank line:\n\n```malloy\n#(doc) Customer who placed the order\njoin_one: users with user_id\n\ndimension:\n #(doc) Date the order was placed (UTC)\n order_date is created_at::date\n\nmeasure:\n #(doc) Total revenue from all orders in USD\n # currency\n revenue is sum(total)\n```\n\n### Writing Doc Strings for Retrieval\n\nDoc strings power natural-language search: users type plain-English questions and the system matches against your `#(doc)` strings. Write descriptions that match how analysts would search:\n\n- **Include business meaning**, not code mechanics: what it represents, not how it's implemented\n- **Include units** (USD, count, percentage) and valid values for categorical fields\n- **Avoid Malloy jargon**: never use \"filterable\", \"groupable\", \"dimension\", \"measure\", \"aggregation\"\n\n**Good examples:**\n- `#(doc) Total revenue from completed orders in USD` matches \"what was our revenue?\"\n- `#(doc) Customer signup date (UTC)` matches \"when did the customer join?\"\n- `#(doc) Order status: pending, processing, shipped, delivered, cancelled` matches \"what are the order statuses?\"\n\n**Bad examples:**\n- `#(doc) Filterable dimension for order status`: no analyst searches for \"filterable\"\n- `#(doc) Groupable by region`: \"groupable\" is a system concept\n- `#(doc) Aggregation of total sales`: \"aggregation\" doesn't match natural queries\n\n## #(filter): see `malloy-model`\n\n`#(filter)` is also a `#(...)`-shaped annotation, but unlike `#(doc)` it's a **runtime/modeling construct**: it shapes governance, query latency, and correctness, not discoverability. The full reference (syntax, filter types, `required` / `implicit` flags, and when each applies) lives in `malloy-model` § Parameterizable Filters with `#(filter)` alongside the other source-authoring constructs.\n\nOne rule worth knowing here: filters live on the source, never on the consumer. Ad-hoc reports and notebooks that import a source inherit its filters automatically; they do not (and cannot) declare new ones.\n\n## `internal:` and `private:`: column-level access in a source\n\n`#(doc)` describes what's exposed. Two access modifiers control what's exposed in the first place, and both live **inside** a source's `include {}` block. They are about the source's public API and data sensitivity, not about documentation, so reach for them when curating which columns callers can pick.\n\n| Mechanism | Layer | Why you reach for it |\n|---|---|---|\n| `internal:` | Inside a source (one column in `include {}`) | The column **isn't part of your model's public API**. Common reasons: data is messy (empty/garbage, raw JSON, duplicates), or a documented derived dimension already supersedes it, or the raw column exists only to be joined on / referenced internally and shouldn't appear as a dimension callers can pick. The data may be perfectly fine, it's just not what you want exposed. |\n| `private:` | Inside a source (one column in `include {}`) | The **data is sensitive**: SSN, raw credit card, password. Governance / security concern; a harder block than `internal:`. |\n\nIn one sentence: **`internal:` and `private:` shape what's inside a source's public API; `#(doc)` describes the fields you do expose.**\n\n### Example\n\nA base source pulled from a messy raw table often uses `internal:` to drop raw fields from the public API, while documenting the curated columns with `#(doc)`.\n\n```malloy\n// orders_base.malloy\n#(doc) Raw orders. Use orders.malloy as the entry point for analysis.\nsource: orders_base is conn.table('orders_raw')\n include {\n public: id, customer_id, order_date, total\n internal: raw_json_payload, deprecated_status_code, _temp_dedup_marker\n }\n extend {\n primary_key: id\n }\n```\n\n```malloy\n// orders.malloy\nimport \"orders_base.malloy\"\n\n#(doc) Order analysis. Use for revenue, fulfillment, and customer-order joins.\nsource: orders is orders_base extend {\n // joins, measures, curated dimensions\n}\n```\n\nThe base source stays fully queryable (`run: orders_base -> { ... }` still works); `internal:` only governs which columns appear as public dimensions callers can pick.\n\n## Annotating Columns in Include (Experimental)\n\nWith `##! experimental.access_modifiers`, you can add `#(doc)` tags to raw table columns inside `include` blocks. This documents columns without redefining them as dimensions.\n\n```malloy\n##! experimental.access_modifiers\n\nsource: orders is conn.table('orders') include {\n public:\n #(doc) Order line item identifier\n id\n\n #(doc) Customer email address\n email\n\n #(doc) Order status: pending, shipped, delivered\n status\n\n // internal: only for verified noise (empty cols, raw JSON blobs, duplicates)\n}\nextend {\n // ... dimensions and measures\n}\n```\n\n**When to use:**\n- Documenting raw columns without creating explicit dimensions\n- Curating which columns are public vs internal\n\n## Source-Level Documentation\n\nDocument **when to use** a source, not what it contains. Dimensions and measures can already be searched directly, so the source-level `#(doc)` should describe what questions/analyses this source answers.\n\n**Base source files:** Document what the table represents.\n```malloy\n#(doc) Customer records with demographics and segmentation. One row per customer.\nsource: customers is conn.table('sales.customers') extend { ... }\n```\n\n**Source files:** Document what analytical questions the source answers.\n```malloy\n#(doc) Customer health analysis. Use for retention, segmentation, churn risk, and lifetime value. For order-level analysis, use order_analysis instead.\nsource: customer_health is customers extend { ... }\n```\n\n**Best practices:**\n- Add `#(doc)` to all base source and joined source definitions\n- Base source docs: describe what the table is (one row per what)\n- Source docs: describe what questions/analyses the source answers\n- Documentation happens per-source-file, not in one monolithic file\n\n## Flag Ambiguous Descriptions\n\nAfter writing `#(doc)` tags, present any that required judgment to the user for confirmation:\n\n| Field | Proposed doc | Confidence | Uncertainty |\n|-------|-------------|------------|-------------|\n| `total` | \"Total order amount in USD\" | Medium | Could be gross or net, verified with sample query |\n| `status` | \"Order status: pending, shipped, delivered\" | High | Values confirmed via a query of distinct values |\n\nOnly flag fields where the description required assumptions about business meaning, units, or valid values. When in doubt about valid values, run a quick query against the data to confirm them before writing the description. Use `malloy_getContext` to ground yourself in the package's sources and fields and `malloy_executeQuery` to check distinct values, for example `run: source -> { group_by: status }`.\n\n## Done\n\nStep complete. Output: `#(doc)` tags added to all public fields and sources." }, { name: "malloy-getting-started", description: "First steps for using a Malloy Publisher deployment through its MCP tools. Use when connecting to Publisher for the first time, when you do not yet know the available environments, packages, or models, or when a user asks what data they can explore. Covers verifying the server, discovering data with malloy_getContext, and running a first grounded query.", body: '# Getting started with Malloy Publisher\n\nGoal: go from "connected" to a correct, grounded answer without guessing any names.\n\n## 0. Confirm the tools are reachable\n\nAt minimum you need `malloy_getContext`, `malloy_executeQuery`, and `malloy_searchDocs`. Authoring a model also needs `malloy_compile` and `malloy_reloadPackage` (see section 4); an older Publisher may not serve those two.\n\nIf none of the tools are there, either the server is not running or your client connected before it was. Start the server (`npx @malloy-publisher/server --port 4000`, or `bun run build && bun run start` from a clone) and wait until `curl -s http://localhost:4000/api/v0/status` reports `operationalState: serving`.\n\nIf there is no Publisher workspace here at all, and the user wants to work with data of their own rather than the bundled examples, `npm create @malloy-publisher/malloy-package@latest <name>` scaffolds one: the package and a starter model, registered so the server actually serves it, plus the start script, the MCP config and these skills. Keep the `@latest` when you type it: `npm create` resolves through npm\'s npx cache and an unversioned name is satisfied by any copy already there, so on a machine that has scaffolded before npm never asks the registry and you get an old scaffolder pinning an old server, with nothing to say so. Run bare, it comes with a small sample dataset, so there is something to query straight away. In a fresh directory `npm start` then runs the pinned server against the package in watch mode; if the directory already had a `package.json` the scaffolder leaves it alone and adds no script, printing the equivalent `npx` command to use instead. Where you run it matters: only the package lands in `<name>/`, and the workspace files, the agent instructions and the MCP config among them, are written to the current directory. Run it here if this directory is empty or is meant to become the workspace. If it already holds other work, scaffold into a new directory instead (`mkdir my-data && cd my-data`), because agent config is discovered by walking up, so writing those files here changes what every session beneath this directory inherits. Seed the starter model from a local file with `npm create @malloy-publisher/malloy-package@latest <name> -- --data <path/to/their-file.csv>` (CSV, Parquet, or Excel `.xlsx`), keeping the `--`, which is how `npm create` passes options through. That path is relative to wherever you run the command, so if you scaffolded into a new directory it has to reach back out to their file; the scaffolder copies it into the package and leaves the original alone. A seeded package starts smaller than the sample one, since the scaffolder does not read their columns: expect a row count and an overview, and build the model from there. A package is just Malloy, so it can instead query a database connection the config defines. Because it writes a `.mcp.json` that did not exist when the client connected, the user has to restart or reconnect once before these tools appear, and their client will ask them to approve the new project-scoped server the first time. That only works when the workspace is at the session\'s own root, so if you scaffolded into a new directory below that root, the user has to open a session there instead: a `.mcp.json` further down is never discovered.\n\nIf you started the server yourself in this session, the tools still will not appear: your tool list was fixed when you connected, and you cannot reconnect yourself. Tell the user the tools are missing for that reason and ask them to run `/mcp`, select `malloy`, and choose Reconnect. The panel offers `Authenticate` first and reports `Auth: not authenticated`; that is a red herring, the endpoint has no auth. Restarting Claude Code also works. Continue once the tools are there.\n\nWhen a user is present, do not route around it by calling the REST API with curl. It appears to work, so the user never learns their session is missing the tools, and you lose what they are for: grounded discovery instead of guessed names, `malloy_compile` instead of throwaway queries, and `malloy_reloadPackage` instead of a restart. Say the tools are missing and let the user fix it in five seconds. Running unattended, with nobody who can reconnect you, is different: there the REST API is the supported interface, not a workaround. Discovery, query, compile, and reload all have REST equivalents (`malloy_searchDocs` and `malloy_getContext`\'s plain-English ranking do not; read the bundled skills for syntax and ground from model metadata instead); the running server serves the full spec at `http://localhost:4000/api-doc.yaml`, and AGENTS.md carries the endpoint map.\n\n## 1. Discover what exists (never guess names)\n\n`malloy_getContext` is progressive. Call it with as much as you know:\n\n- No arguments: the available environments, each with its package names.\n- `environmentName` only: the packages in that environment.\n- `environmentName` + `packageName`: that package\'s sources.\n- `environmentName` + `packageName` + `query` (plain English): the sources, views, named queries, and dimension/measure fields most relevant to the question.\n\nUse the names it returns exactly. Do not invent environments, packages, sources, or fields.\n\n## 2. Run the query\n\nCall `malloy_executeQuery` with the `environmentName`, `packageName`, and `modelPath` from the context results, plus either:\n\n- a named view or query: pass its `name` as `queryName` (with `sourceName` for a view), or\n- an ad-hoc query: pass Malloy code as `query`.\n\nThe result is JSON. Charts and dashboards defined in the model render in the Publisher UI at http://localhost:4000.\n\n## 3. When you need Malloy syntax\n\nUse `malloy_searchDocs` for language questions (filters, aggregates, joins, nesting, renderers).\n\n## 4. What else you can do here\n\nAnswering questions is the start, not the whole surface. When the user asks what is possible, say so rather than offering queries alone. Switch skills for the deeper work:\n\n- `malloy-modeling`: build or change a model. Validate the edit with `malloy_compile`, save it, then `malloy_reloadPackage` so the new sources and views run by name without restarting the server.\n- `malloy-analysis`: explore a package and answer data questions.\n- `malloy-html-data-apps`: build a data app, a hand-authored HTML page in the package\'s `public/` directory that Publisher serves, backed by the package\'s models and needing no build step.\n- `malloy-review`: check Malloy for correctness.\n\n## Contract\n\n- Ground every query in `malloy_getContext` results. If a name is not in the results, do not use it.\n- Start broad and narrow down: environments, then packages, then sources, then query.\n- Confirm the environment and package before running a query.' }, { name: "malloy-gotchas-modeling", description: "Common Malloy modeling mistakes and how to avoid them. Read BEFORE writing source definitions, dimensions, measures, or joins. Covers reserved words, NULL checks, date functions, type casts, field management (extend except/accept/rename vs include public/internal/private), and query-based source gotchas.", body: "# Modeling Gotchas\n\n> **Read this before writing Malloy code.** These patterns cause most modeling errors.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n## Reserved Words: Backtick Them\n\n**When in doubt, backtick it.** Unquoted reserved words cause cascading errors on unrelated lines.\n\n```malloy\n// WRONG // RIGHT\ndimension: d is Date::date dimension: d is `Date`::date\n```\n\nWords most likely to appear as column names:\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n\n## NULL Checks: `is not null`, NOT `!= null`\n\n```malloy\n// WRONG // RIGHT\ndimension: is_sold is sold_at != null dimension: is_sold is sold_at is not null\n```\n\n## Date Functions vs Properties\n\n```malloy\n// WRONG: day_of_week is a function // RIGHT\ndimension: dow is created_at.day_of_week dimension: dow is day_of_week(created_at)\n```\n\n**Property access:** `.month`, `.year`, `.quarter`, `.day`, `::date`\n**Function call required:** `day_of_week()`, `week()`, `hour()`, `minute()`, `second()`\n\n## `.date` Is a Cast, Not a Truncation\n\nCalendar truncations are `.day`, `.week`, `.month`, `.quarter`, `.year` (plus `.hour`, `.minute`, `.second` for timestamps). `.date` is **not** among them: it's a **cast** (`::date`), not a truncation, so `created_at.date` does not compile. This bites twice: once at compile time, and again as a latent bad `#(doc)` comment that only a review pass catches (\"truncated to date\" is a doc smell; it should say \"to day\").\n\n```malloy\n// WRONG // RIGHT\ncreated_at.date created_at.day // truncate to day\n created_at::date // cast to a date\n```\n\n## Interval Functions: Only `seconds` / `minutes` / `hours` / `days`\n\n`weeks()`, `months()`, `quarters()`, `years()` are **documented but don't work** in this build; only `seconds`, `minutes`, `hours`, `days` actually function. Compute in days and derive the larger unit: a *units conversion*, not a calendar-floored duration:\n\n```malloy\n// WRONG: weeks()/months() don't compile\ndimension: weeks_open is weeks(opened_at to closed_at)\n\n// RIGHT: measure in days, convert (documents that it's approximate)\ndimension: days_open is days(opened_at to closed_at)\ndimension: weeks_open is days(opened_at to closed_at) / 7 // ≈ weeks\ndimension: months_open is days(opened_at to closed_at) / 30.44 // ≈ months\n```\n\n(Contrast: `search_malloy_docs` gets this right when asked narrowly; trust the docs on the supported units, not on the missing ones.)\n\n## Safe Division: Always `nullif`\n\n```malloy\n// WRONG // RIGHT\na / b a / nullif(b, 0)\n```\n\n## String Columns Need Casts for Aggregates\n\n```malloy\n// WRONG: \"Can't use type string\" // RIGHT\nmeasure: avg_score is avg(score) measure: avg_score is avg(score::number)\n```\n\n**Dirty columns: null the sentinel before casting.** `::number` is a strict cast, so a column that carries non-numeric sentinels (`'NA'`, `'N/A'`, `''`, `'-'`, `'null'`) compiles fine but fails at query time with `Could not convert string 'NA' to DOUBLE`. Strip the sentinel with `nullif` first, then cast (aggregates skip nulls):\n\n```malloy\n// WRONG: throws on 'NA' at query time // RIGHT: nulls 'NA', then casts\nmeasure: s is avg(score::number) measure: s is avg(nullif(score, 'NA')::number)\n```\n\nChain `nullif` for multiple sentinels: `nullif(nullif(score, 'NA'), '')::number`. Sample the column's values first (`run: source -> { group_by: score; limit: 20 }`) to see which sentinels it uses.\n\n## Boolean Columns: No Quotes\n\n```malloy\n// WRONG // RIGHT\ncount() { where: complaint = 'true' } count() { where: complaint = true }\n```\n\nCheck schema: if `BOOL`, use `true`/`false`. If `STRING`, use `'true'`/`'false'`.\n\n## `greatest()` / `least()` Are Null-Poisoning\n\nMalloy's `greatest()` / `least()` return **NULL if *any* argument is null**, unlike Postgres `GREATEST`/`LEAST`, which ignore nulls. Porting a LookML/SQL expression verbatim is a silent parity bug: the number just goes null for any row with a missing input. Coalesce the result back to a non-null argument:\n\n```malloy\n// WRONG: one null input nulls the whole thing\ndimension: last_touch is greatest(email_at, call_at)\n\n// RIGHT: fall back so a null arg can't poison the result\ndimension: last_touch is greatest(email_at, call_at) ?? email_at ?? call_at\n```\n\n## No Scalar Median; Raw-SQL Aggregates Don't Compile\n\n**There is no scalar `median`, and `PERCENTILE_CONT` cannot be expressed as a measure in this build.** Every documented form for a custom SQL aggregate - `percentile_cont!(x, 0.5)`, `sql_number(...)`, `sql_number(...) { is_aggregate: true }`, and the `# is_aggregate` annotation - resolves as a **scalar** and fails with *\"Cannot use a scalar field in a measure declaration.\"* The docs' own `avg_dist` example fails the same way. This is a deployed-runtime limitation, not a syntax error you can fix: **do not** burn cycles trying `!`, `sql_number`, or `is_aggregate` variations to get a median.\n\n```malloy\n// DOES NOT COMPILE in this build (all forms resolve as scalar):\nmeasure: median_x is percentile_cont!(x, 0.5)\nmeasure: median_x is sql_number(\"PERCENTILE_CONT(...) ...\") { is_aggregate: true }\n```\n\n**Ship `avg` instead, or defer median with a documented gap** (\"median deferred: no scalar median / runtime rejects raw-SQL aggregates\"). Tell the user; don't silently substitute `avg` for a metric that was specified as median.\n\n**`stddev` does work**, so reach for it when the question is about spread. It is a native Malloy aggregate rather than a raw-SQL escape, so unlike everything above it compiles both inline and as a `measure:`, and it is the sample standard deviation. `variance`, `stddev_samp`, and `stddev_pop` are not Malloy functions, and pushing them through `!` fails as a scalar exactly like `percentile_cont!`.\n\n```malloy\n// WORKS: inline, or as a measure on a source\nrun: order_items -> { aggregate: sd is stddev(sale_price) }\nsource: items is order_items extend { measure: price_stddev is stddev(sale_price) }\n```\n\n## Field Management: `extend {}` vs `include {}` Don't Compose\n\nMalloy has two field-management mechanisms for base sources. **`include {}` is the curated default; `extend { except / accept / rename }` is the fallback when a `rename:` is unavoidable.** They have different capabilities and **do not combine**.\n\n| Mechanism | Where it lives | Keywords | Compatible with `rename:`? | Experimental flag? |\n|---|---|---|---|---|\n| Access modifiers (default) | `include {}` | `public:` / `internal:` / `private:` | **No** | Yes (`##! experimental.access_modifiers`) |\n| Field management (fallback) | `extend {}` | `accept:` / `except:` / `rename:` | Yes (same block) | No |\n\n### Default: `include {}` for documented, curated base sources\n\nUse `include {}` whenever the source doesn't need a `rename:`. It's the only way to attach `#(doc)` tags to raw columns, and it's the canonical way to hide empty/garbage/duplicate columns (`internal:`) and sensitive ones (`private:`). See `skill:malloy-model` § Access Modifiers.\n\n```malloy\n##! experimental.access_modifiers\nsource: orders is conn.table('orders') include {\n public:\n #(doc) Order identifier\n order_id\n\n #(doc) Customer who placed the order\n user_id\n\n internal:\n raw_payload_json // empty after JSON extraction\n legacy_status_code // superseded by status_code\n}\n```\n\n### When `rename:` is unavoidable: fall back to `extend {}`\n\n`include {}` does not compose with `rename:`. The combination errors with `Can't find field 'X' to set access modifier` because `rename:` runs first and leaves no `X` for `include` to attach a modifier to. There's also a collision inside `include {}` itself: a measure cannot share a name with a raw column, even one tagged `internal:` (`Cannot redefine 'X'`), and the natural fix for that is `rename:`, which then triggers the first error.\n\nWhen a rename is genuinely required (most often during `conn.sql()` to `conn.table()` migration where a SQL alias matches a measure name that's already in heavy use downstream), drop `include {}` and curate the source with `extend { except: ... }` + `rename:` instead. You forfeit `#(doc)` on raw columns and the `public/internal/private` tiers, but keep column gating and the rename.\n\n```malloy\n// RIGHT: rename is required to free `revenue` for the measure\nextend {\n except: legacy_status_code // hide garbage column without include {}\n rename: raw_revenue is revenue\n measure: revenue is raw_revenue.sum()\n}\n```\n\nIf you can rename the measure or split the source instead, prefer that: it preserves `include {}` and the curated surface.\n\n### `extend {}` clauses (reference)\n\n- **`accept:`**: allow-list, keep only the named columns\n- **`except:`**: deny-list, drop the named columns; keep everything else (mutually exclusive with `accept:`)\n- **`rename:`**: alias a raw column to free up its original name for a measure or dimension\n\n### Migrating `conn.sql()` to `conn.table()` + Malloy clauses\n\nThe biggest reason teams reach for `conn.sql()` is column gating, aliasing, and per-row derivation in one place. All three have native equivalents:\n\n1. **Verify the schema**: `run: <source> -> { select: *; limit: 1 }` to discover all columns. Anything in the table but not in the SQL's `SELECT` was being intentionally hidden, so preserve that gating.\n2. Switch to `conn.table('…')`.\n3. Hidden columns: preferably `include { internal: ... }` (lets you also `#(doc)` the public columns). If a `rename:` is also needed in the same source, fall back to `extend { except: ... }`.\n4. SQL aliases: `extend { rename: ... }` (forces the fallback path, since `rename:` and `include {}` don't compose). If the alias was to free up a name for a measure, use `rename: raw_X is X`, then `measure: X is raw_X.sum()`.\n5. SQL derivations: `dimension:` definitions in `extend {}`.\n6. SQL `WHERE`: source-level `where:`.\n\n## Cannot Redefine Query-Based Source Columns\n\nColumns from `table -> { group_by, aggregate }` or `conn.sql()` already exist. You cannot re-declare them.\n\n```malloy\n// WRONG: \"Cannot redefine 'user_id'\"\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: user_id is user_id }\n// RIGHT: add only NEW derived dimensions\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: is_high_value is total > 1000 }\n```\n\nTo add `#(doc)` tags to existing query columns, use `include {}` between the query and extend.\n\n## Extending a Source Cannot Reuse a Name It Already Defines\n\n```malloy\n// WRONG: \"Cannot redefine 'overview'\" when sales already declares view: overview\nsource: wines is sales extend { view: overview is { aggregate: record_count } }\n// RIGHT: give the extension its own name\nsource: wines is sales extend { view: summary is { aggregate: record_count } }\n```\n\nAn extension adds to the parent's namespace, it does not override it. This bites when you extend a source to \"replace\" one of its views: rename the new definition, or edit the view on the parent source instead of extending it. Malloy reports the same `Cannot redefine 'X'` for dimensions and measures that collide with an inherited name, per the sections above and below.\n\n## Never Use `conn.sql()` When Malloy Has a Native Pattern\n\n```malloy\n// WRONG: raw SQL for pre-aggregation\nsource: facts is conn.sql(\"\"\"SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id\"\"\")\n// RIGHT: Malloy query-based source\nsource: facts is conn.table('orders') -> { group_by: user_id, aggregate: total is sum(amount) }\n```\n\n**Mandatory: call `search_malloy_docs` before reaching for `conn.sql()`.** Don't argue from intuition. Most patterns that look SQL-only have a Malloy equivalent, including the ones reviewers historically said couldn't be expressed.\n\n| Looks like it needs SQL | Malloy equivalent |\n|---|---|\n| Multi-CTE pipeline | Stacked query-based sources: `source: a is t -> {...}`; `source: b is a -> {...}`; `source: c is b -> {...}` |\n| UNNEST / array column access | `array_column.each.field`: arrays auto-join as nested tables ([data types docs](https://docs.malloydata.dev/documentation/language/datatypes#array-access)) |\n| PIVOT (conditional aggregation) | Filtered aggregates: `aggregate: a is x.sum() { where: cat = 'a' }, b is x.sum() { where: cat = 'b' }` |\n| Window functions (any frame, including custom) | `calculate:` with `sum_cumulative`, `lag`, `lead`, `rank`, `row_number`, `avg_moving`, `first_value`, `last_value`: supports `partition_by:` and `order_by:` ([window functions docs](https://docs.malloydata.dev/documentation/language/functions#window-functions)) |\n| `ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING` | `sum_cumulative(x) - x` (cumulative-including-current minus current = cumulative-excluding-current) |\n| `WHERE date = (SELECT max(date) FROM …)` (latest snapshot) | `join_cross` to a one-row aggregate source, then filter on the joined `max_date` field |\n| Multi-key joins | `join_one: x is target on a = x.a and b = x.b and c = x.c` |\n| `greatest()` / `least()` / `CASE` chains | All native: `greatest(a, b, c)`, `least(a, b)`, `pick 'x' when cond else 'y'` |\n| Dialect-specific scalar functions | `function_name!return_type(args)`: Malloy's raw-SQL function escape (no `conn.sql()` block needed) |\n\n**Genuinely valid `conn.sql()` candidates (rare):**\n\n- SQL features Malloy explicitly doesn't model (e.g., DML/DDL, specific `MERGE` patterns)\n- Multi-stage transformations where every CTE has 3+ joins to different tables AND the result is consumed by multiple downstream sources, but in this case an intermediate table in the data warehouse is usually still better than `conn.sql()`\n\n**Never use `conn.sql()` for:** simple column selection or renaming, `WHERE` filters, two-table joins, column type casts, latest-snapshot patterns, conditional aggregation, or window functions of any kind.\n\nIf a project's standards file specifies a stricter policy (e.g., a `search_malloy_docs` rationale comment requirement above every `conn.sql()` block), defer to that.\n\n## JSON Files: Read Them In Place Like CSV\n\n```malloy\n// RIGHT: .json works like .csv/.parquet\nsource: reviews is duckdb.table('data/reviews.json')\n// RIGHT: newline-delimited JSON is read the same way\nsource: events is duckdb.table('data/events.ndjson')\n// RIGHT: read options need read_json_auto in a SQL source\nsource: nested is duckdb.sql(\"\"\"SELECT * FROM read_json_auto('data/reviews.json')\"\"\")\n// WRONG: shelling out to python, or converting to CSV first\n```\n\nDuckDB reads JSON directly, so never preprocess a `.json` file before modeling it and never reach for a scripting language to inspect one. Both a top-level array of objects and newline-delimited JSON work through `duckdb.table()`.\n\nQuirk: JSON carries no schema, so a value written as `\"90\"` arrives as a string where the same data in CSV would be inferred as a number. Cast it in the source, under a new name (reusing the column's own name is a redefinition error):\n\n```malloy\nsource: reviews is duckdb.table('data/reviews.json') extend {\n dimension: points_num is points::number\n}\n```\n\n## Excel Files: Read `.xlsx` In Place, Never Convert\n\n```malloy\n// RIGHT when the sheet is a plain table (header in row 1, data under it, no blank row inside\n// it): read it where it sits, like .csv/.parquet (in a Publisher package the sandbox\n// connection is `duckdb`)\nsource: budget is duckdb.table('data/budget.xlsx')\n// RIGHT for anything messier. Profile the top rows first to find the real header row and the\n// last real column, because nothing else will tell you where they are. Put the probe in the\n// model file as its own source: Publisher refuses raw SQL in an ad-hoc query.\n// SELECT * FROM read_xlsx('data/sales.xlsx', sheet = 'Sales Data',\n// range = 'A1:Z15', header = false, all_varchar = true)\nsource: sales is duckdb.sql(\"\"\"\n SELECT * FROM read_xlsx('data/sales.xlsx',\n sheet = 'Sales Data', -- EDIT: only the first sheet is read by default\n header = true,\n range = 'A5:J100000' -- EDIT: A5 is the real header row. Keep the column bound at the\n ) -- last real column; the row bound just has to clear the end.\n WHERE \"Order ID\" LIKE 'SO-%' -- EDIT, REQUIRED: a data-row predicate. This is what ends the\n\"\"\") -- read; drop it and every empty row in the range comes back.\n// WRONG: converting the spreadsheet to Parquet or CSV first (an unnecessary extra step)\n```\n\nDo not convert spreadsheets before modeling. DuckDB's excel extension reads `.xlsx` directly and loads automatically on first use, so a sheet that is a plain table needs nothing more than `duckdb.table()`. Converting does not avoid any of the problems below, it just moves them into a copy that goes stale the next time someone updates the workbook.\n\n**Plenty of real exports are not plain tables, and nothing tells you.** A report title, a \"generated on\" banner, a merged group header, a blank line above the header, or a blank spacer row inside the data are all ordinary, and none of them is visible from Malloy. There is no error either: the package loads, the server reports serving, the query returns 200, and the number is just wrong. So make two checks before building on the read: compare `aggregate: record_count is count()` against what you know is in the file, and `select: *; limit: 1` to see what the columns really are. If either disagrees with the file, the read is wrong and so is every measure over it.\n\n`table()` takes a plain file path only, so anything needing `read_xlsx` options (`sheet`, `range`, `header`, `ignore_errors`, `normalize_names`, `all_varchar`, `empty_as_varchar`, `stop_at_empty`) goes through the SQL-source form.\n\nQuirks:\n\n- Only the FIRST sheet is read by default. Select another with `sheet = 'Name'`. There is no function that lists a workbook's sheet names, but passing one that does not exist reports a suggestion (`Sheet \"x\" not found ... Did you mean: \"Notes\"`), which is one way to find a name you were not given.\n- A title or banner row above the header collapses the read. DuckDB takes the first row it finds as the column names, so a lone title cell in A1 becomes the only column. How many rows you then get is the next quirk's business: whatever sits between the title and the first blank row, often none or one, otherwise a plausible-looking partial count. Pass a `range` that starts at the real header row.\n- With no `range`, `stop_at_empty` defaults to true and the read stops at the first blank row, which on a real sheet is usually a spacer between blocks rather than the end of the data: a 30-row sheet with one spacer after row 10 reads as 10 rows. `stop_at_empty = false` lifts that, but it only helps when the header really is in row 1; with a title above the header you need the `range` anyway, and a `range` flips the default for you. It also hands the blank rows back as all-null rows, so the count comes out one high per spacer until you filter them.\n- A `range` reads every cell inside it, so an overshot bound manufactures padding: past the last real column you get all-null fields (`A5:Z100000` on a ten-column sheet yields 26, the extras named `C10` and `_1` through `_15`), and past the last real row all-null rows (`A5:J100000` on a 1,500-row sheet reads 99,995). Spacers, subtotals, and footnotes come through as rows too. So the row filter is not tidying-up, it is the thing that ends the read: filter to what a data row looks like (`WHERE \"Order ID\" LIKE 'SO-%'`) rather than to `IS NOT NULL`, which keeps any footnote carrying text in the first column. A bound that falls SHORT of the data is the dangerous direction: the rows and columns past it are dropped with no error at all, so overshoot the row bound and let the filter end the read.\n- Every number in an xlsx is stored as a double, so there are no integer columns. Typing is per column and decided by the FIRST data row, and `$1,234`, `12%` and `N/A` are all text: a text cell in that first row makes the whole column a string (on one real export, all ten of them), while a text cell further down leaves the column numeric and makes the read throw instead (`Could not convert string ... to DOUBLE`). `ignore_errors = true` fixes that second case, nulling the bad cells and keeping the column a number. It does nothing for the first.\n- Sample the column's SHAPES before writing any conversion, not its values: `run: source -> { group_by: shape is replace(raw_col, r'[0-9]', '9'); aggregate: n is count(); order_by: n desc }` collapses every value to its format and counts it, so on one real price column the 16 euro-denominated rows surface beside the 1,484 in dollars. A plain `group_by raw_col; limit: 20` sorts lexicographically, which hides exactly the shapes that matter.\n- Convert in the SQL source, not in Malloy, where `::number` throws on the first bad cell. `try_cast(regexp_replace(\"Total Revenue\", '[^0-9.-]', '', 'g') AS double)` nulls what it cannot read instead of failing and is right for a plain `$1,234.56`, but it is not a general parser. It concatenates every digit in the cell, so `1,234 (see tab 2)` becomes 12342. It understands only a leading ASCII `-`, so an accounting `(1,234)`, a Unicode minus and a `CR` suffix all come back positive, while a trailing `-` (`1,234-`) comes back null and drops the row from the sum. And it assumes `.` is the decimal point, so a European `1.234,56` comes back a thousandfold small. Handle the shapes your sample actually found, and divide a percent by 100. Failure is quiet either way: a cast that fails on every row sums to 0 rather than erroring, and a text date strips to a number rather than a null (`'01/02/2023'` becomes 1022023).\n- Check the answer against the sheet's own total row, read as raw text. Lift the data-row filter and select the footer by its label, which usually sits in a different column from the one your data-row predicate uses: on one export `WHERE \"Customer Name\" = 'TOTAL'` finds it and `WHERE \"Order ID\" = 'TOTAL'` returns nothing, and an empty result reads as a pass. Do not run the total through the same expression, because a wrong sign survives a row count, survives `select: *`, and cancels out when both sides are parsed the same broken way.\n- A sheet with no header row whose first row is all text silently loses that row to header detection. Pass `header = false`.\n- Headers with spaces are kept verbatim: backtick them in Malloy, or pass `normalize_names = true` for snake_case names.\n- `all_varchar = true` hands back each cell's stored value as text, so a date arrives as its raw Excel serial number rather than a date: `'44929'` from a sheet Excel wrote, `'44927.0'` from one DuckDB's own xlsx writer wrote, and `'44929.5'` where the cell carries a time of day. Which form you get depends on the tool that wrote the file, so do not detect serials by matching for an integer; `try_cast(... AS double)` accepts all three and returns null for a cell that was stored as text (`'01/02/2023'`), which is the test you want. Convert with `date '1899-12-30' + floor(try_cast(d AS double))::int`, not from 1900-01-01. Both wrappers earn their place: adding a double to a date does not compile, and a bare `::int` rounds, so an afternoon timestamp would land on the next day.\n- A date column that mixes both, which is what an export edited by hand gives you, needs both branches or you silently lose every row of one kind: `CASE WHEN try_cast(d AS double) IS NOT NULL THEN date '1899-12-30' + floor(try_cast(d AS double))::int ELSE try_strptime(d, '%m/%d/%Y')::date END`. Without `all_varchar`, a uniformly date-formatted column arrives as real `date` and `timestamp` values, and a stray text cell behaves exactly as the typing rule above says. Note what `ignore_errors = true` does here: it nulls that cell rather than parsing it, so the hand-typed date is lost silently.\n\n## Duplicate Rows: Check Before Building Measures\n\n```malloy\nrun: source -> { group_by: pk_field, aggregate: n is count(), having: n > 1, limit: 10 }\n```\n\nSymptoms: `sum()` returns astronomical values. Causes: event tables, batch retries, merged sources.\n\n## `except:` Removes Fields From Namespace Entirely\n\n`except:` in `include {}` completely removes fields: dimensions and measures cannot reference excluded fields. Use `internal:` instead when derived dimensions need the raw column.\n\n```malloy\n// WRONG: dimension references excluded field\nsource: x is conn.table('t')\ninclude { except: raw_date }\nextend { dimension: order_date is raw_date::date } // ERROR! raw_date is gone\n\n// RIGHT: internal fields are still available in extend\nsource: x is conn.table('t')\ninclude { internal: raw_date }\nextend { dimension: order_date is raw_date::date } // Works\n```\n\n## Source Order: Define Joined Tables First\n\nMalloy compiles top-to-bottom. Define lookup/dimension tables before the source that joins them, or use `import` statements in multi-file projects.\n\n## MUST Search Docs Before Using Unfamiliar Patterns\n\nCall `search_malloy_docs` BEFORE first use of any of these. Don't guess the syntax:\n- `pick` expressions\n- Window functions (`calculate`)\n- `percentile` or statistical functions: but see the hard limit above, raw-SQL aggregates (`sql_number` / `is_aggregate` / `percentile_cont!`) do **not** compile as measures in this build; there is no scalar median (`stddev` is the exception and does work as a measure)\n- Time interval functions (`days()`, `seconds()`): only `seconds`/`minutes`/`hours`/`days` exist (see above)\n- Query-based sources (`from()`)\n- `!` operator / `sql_number()`" }, { name: "malloy-gotchas-queries", description: "Common Malloy query and view mistakes. Read BEFORE writing views, queries, or notebooks. Covers chart constraints, aggregate filters, joined field aliasing, method syntax, and time truncation vs extraction.", body: "# Query & View Gotchas\n\n> **Read this before writing views or queries.** These patterns cause most query errors.\n\n## Charts: ONE Aggregate Per View\n\nCharts render only the **first** aggregate. Use exactly one aggregate per `# bar_chart` / `# line_chart` view.\n\n```malloy\n// WRONG: revenue is ignored\n# bar_chart\nview: x is { group_by: status, aggregate: order_count, revenue }\n// RIGHT: single aggregate\n# bar_chart\nview: x is { group_by: status, aggregate: revenue }\n```\n\nFor multiple metrics: nest separate chart views in a `# dashboard`, or use `y=['revenue','cost']` for multi-measure series.\n\n## Joined Fields in `order_by`: Must Alias First\n\n```malloy\n// WRONG: compile error\nview: x is { group_by: races.season_year, aggregate: pts, order_by: races.season_year }\n// RIGHT: alias then reference\nview: x is { group_by: yr is races.season_year, aggregate: pts, order_by: yr }\n```\n\nAny time you `group_by` a joined field, create an alias and use it in `order_by`.\n\n## `having:` vs `where:`: Aggregate Filters\n\n```malloy\n// WRONG: \"Aggregate expressions not allowed in where\"\nview: x is { group_by: cat, aggregate: n is count(), where: n > 10 }\n// RIGHT\nview: x is { group_by: cat, aggregate: n is count(), having: n > 10 }\n```\n\n- `where:` filters rows BEFORE aggregation (dimensions/raw columns)\n- `having:` filters AFTER aggregation (measures)\n\n## Aggregating Joined Fields: Method Syntax\n\n```malloy\n// WRONG: compile error: \"Join path is required for this calculation; use 'inventory_items.item_cost.sum()'\"\nmeasure: cogs is sum(inventory_items.item_cost)\n// RIGHT: method syntax\nmeasure: cogs is inventory_items.item_cost.sum()\n```\n\n`sum`, `avg`, `min`, and `max` over a dotted joined path all produce that compile error; the diagnostic message even tells you the exact fix. Don't worry about catching this in code review; the compiler does it for you.\n\n**Method syntax is for aggregates over a path. Scalar functions never take it.**\n\n```malloy\n// WRONG: \"something is missing before 'round'\"\naggregate: avg_price_r is avg(price).round(2)\naggregate: avg_price_r is price.avg().round(2)\n// WRONG: \"Cannot call function round(number, number) with source\"\naggregate: avg_price_r is avg_price.round(2)\ndimension: rounded is price.round(2)\n// RIGHT: scalar functions are always call form\naggregate: avg_price_r is round(avg(price), 2)\ndimension: rounded is round(price, 2)\n```\n\nTwo separate rules produce those errors:\n\n- **No method call chains onto the result of a function call.** `avg(price).round(2)` and `price.avg().round(2)` are both parse errors. The message names `round` without saying it is unsupported in that position, so it reads like a typo somewhere else. `.floor()` and `.ceil()` fail identically.\n- **Scalar functions have no method form.** `round`, `floor`, and `ceil` are always `round(x, 2)`, never `x.round(2)`, whether `x` is a named measure or a plain column.\n\n`price.avg()` and `inventory_items.item_cost.sum()` are correct because `avg` and `sum` are aggregate functions over a field path, which is exactly what method syntax is for.\n\n**Exception: `count(joined.field)` is correct, not a bug.** `count(joined.field)` is the **canonical Malloy idiom** for distinct-count through a join. Keep it as-is even when nearby `sum`/`avg`/`min`/`max` calls have to use method syntax. The closest method-syntax form `joined.count()` counts *rows* in the joined source (different semantics, differs from the distinct count when the joined field has duplicates within the joined table). The Malloy docs example `joined.count(field)` does NOT compile against current Malloy (error: `Expression illegal inside path.count()`); it only works for double-nested paths like `aircraft.count(aircraft_models.code)`.\n\n## `sum`/`avg` Need a Numeric Field\n\n```malloy\n// WRONG: \"Can't use type string\" - status is a string column\naggregate: avg_status is avg(status)\n// RIGHT: aggregate a numeric field; count a string one\naggregate: avg_price is avg(price), statuses is count(status)\n```\n\nCheck the field's type in the `get_context` result before aggregating it. A name that reads numeric (`order_number`, `zip`, `account_id`) is very often typed string.\n\n## Dotted Paths Must Name a Declared Join\n\n```malloy\n// WRONG: the source declares the join as `carrier`, so this fails with\n// \"'carriers.name' is not a source or join\"\nrun: flights -> { group_by: carriers.name }\n// RIGHT: use the join name the source actually declares\nrun: flights -> { group_by: carrier.nickname }\n```\n\nA dotted path resolves only against a join declared on the source you are running. Confirm both the join name and the field under it in a `get_context` result; do not infer either from a table name or a plural/singular guess.\n\n## `order_by:` Can Only Name an Output Column\n\n```malloy\n// WRONG: \"Unknown field total in output space\" - total is never emitted\nrun: orders -> { group_by: state, aggregate: revenue is sum(total), order_by: total }\n// RIGHT: order by a column the query actually outputs\nrun: orders -> { group_by: state, aggregate: revenue is sum(total), order_by: revenue }\n```\n\n`order_by:` resolves against the query's *output* columns, not the source's fields. To order by something, `group_by` or `aggregate` it first - and if it comes through a join, alias it (see above).\n\n## Chart Annotation Placement\n\nPlace `# bar_chart` / `# line_chart` on the **nested view definition**, not on `nest:` itself. Putting it on `nest:` causes \"not a repeated record\" errors.\n\n## DRY: Define in Source, Reference in View\n\n```malloy\n// WRONG: inline in view\nview: summary is { aggregate: revenue is sum(total) }\n// RIGHT: reference existing measure\nview: summary is { aggregate: revenue }\n```\n\n## Time Truncation vs Extraction\n\n| Syntax | What it does | Returns |\n|--------|--------------|---------|\n| `ts.month` | Truncates to start of month | Timestamp (`@2024-03-01`) |\n| `month(ts)` | Extracts month number | Integer (1-12) |\n| `ts.year` | Truncates to start of year | Timestamp (`@2024-01-01`) |\n| `year(ts)` | Extracts year number | Integer (2024) |\n\nUse `.month` for time series charts (proper date ordering). Use `month()` for cross-year comparison.\n\n**Year integers render with commas.** `year(ts)` displays as `2,018`. Tag with `# number=id` to suppress commas. Same for zip codes, IDs.\n\n## `?` Alternation: Use Commas to Combine Filters\n\nThe `?` operator is Malloy's **alternation operator**: a shorthand for \"match any of these values.\" `party ? 'Democrat' | 'Republican'` means `party = 'Democrat' OR party = 'Republican'`. The `|` separates the alternatives.\n\nWhen combining an alternation filter with other filters, **use a comma**:\n\n```malloy\n// CANONICAL: commas separate independent filter conditions\nwhere: is_us = true, party ? 'Democrat' | 'Republican'\n```\n\n`and` works in some arrangements (when the alternation is the second operand) but produces a confusing `'logical operator' Can't use type string` compile error when the alternation comes first. The comma form is unambiguous in every position, so just use it.\n\n## Query Clauses Are Newline-Separated\n\nDo not use trailing commas between query clauses. Each clause goes on its own line.\n\n```malloy\n// WRONG: trailing comma before limit\nrun: source -> { group_by: status, aggregate: n is count(), limit: 10 }\n// RIGHT: newline-separated\nrun: source -> {\n group_by: status\n aggregate: n is count()\n limit: 10\n}\n```\n\nClauses: `group_by:`, `aggregate:`, `nest:`, `order_by:`, `limit:`, `where:`, `having:`, `select:`, `calculate:`\n\n## Fields Within a Clause: Commas or Newlines, Never Semicolons\n\nSemicolons are not a separator anywhere in Malloy. Multiple fields under one `aggregate:` / `group_by:` are separated by commas (inline) or newlines (one per line); a `;` fails with `no viable alternative at input '<next-field>'` pointing at the field right after it.\n\n```malloy\n// WRONG: semicolons between fields\nrun: schools -> { aggregate: total is count(); charters is count() { where: is_charter } }\n// RIGHT: commas inline...\nrun: schools -> { aggregate: total is count(), charters is count() { where: is_charter } }\n// ...or newlines\nrun: schools -> {\n aggregate:\n total is count()\n charters is count() { where: is_charter }\n}\n```" }, { name: "malloy-gotchas-rendering", description: "Common Malloy renderer annotation mistakes. Read BEFORE adding chart annotations, formatting tags, or building dashboards. Covers tag syntax, scale rules, sparkline setup, and big_value patterns.", body: `# Rendering Gotchas
|
|
288400
|
+
A confirmed source architecture and a confirmed set of field definitions (renames, dimensions, measures, business decisions), held in the conversation and ready to write into the \`.malloy\` model via your modeling workflow.` }, { name: "malloy-discover", description: "Silent data discovery for Malloy modeling. Used at Step 1 of the modeling workflow. Scans tables, columns, distributions, and relationships without user interaction. The agent builds an internal picture before presenting anything.", body: "# Data Discovery (Step 1, Silent)\n\n> **CRITICAL**: Read the model before writing ANY Malloy code. The model defines the sources, connection names, and fields. Never guess connection names.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n> **PREREQUISITE:** Make sure the Malloy MCP tools (`get_context`, `execute_query`, `search_malloy_docs`) are configured and reachable. If they are not, stop and resolve the MCP connection before continuing.\n\n**This step is silent.** The agent does not present findings to the user yet. That happens in the next step (PROPOSE SCOPE).\n\n## Tools\n\n- **`get_context`**: Ground yourself in the package's sources, views, and fields (with their docs). Call FIRST. The sources and their join paths are the schema you build on.\n- **`execute_query`**: Run ad-hoc queries to preview data, verify values, check NULLs, validate assumptions.\n- **`search_malloy_docs`**: Get Malloy syntax help when needed.\n\n## Workflow\n\n```\n1. Check for prior art signals → If found, ask user: \"I found [LookML/dbt] files, use as prior art?\"\n2. If user confirms: read adapter reference → Follow skill:malloy-lookml-review, keep prior-art notes in-conversation\n3. get_context → Ground yourself: sources, views, fields\n4. Inspect source definitions → See ALL fields and join paths for key sources\n5. Derive candidate joins/dimensions/measures → Read them off the model and the data, not a suggestion tool\n6. Define a minimal source if one is missing → Just enough to run execute_query for previews\n7. execute_query(query) → Preview data, verify values, check NULLs, check duplicates\n8. search_malloy_docs(query) → Get syntax help when needed\n9. Proceed to Step 2 (PROPOSE SCOPE)\n```\n\n**If the model has no sources defined** and no LookML files are present, do NOT silently retry or proceed without data. Tell the user: \"No model sources were found. Please check that the package points at a connected data source, then try again.\"\n\n**If the model has no sources defined** but LookML files ARE present (LookML-only mode), skip steps 3-7. Use connection name and table paths from the LookML review. Flag all proposals as unvalidated.\n\n**Key principle:** Query data to verify assumptions. Don't ask the user to confirm values you can check yourself.\n\n**Search docs proactively.** If you discover patterns that need derived/pre-aggregated sources, window functions, or unfamiliar features, call `search_malloy_docs` BEFORE writing code, not just when you hit errors.\n\n## Query File for Discovery\n\n**In the schema-first workflow:** Run ad-hoc queries with `execute_query`. If the source you want to preview is not yet defined in the model, define a minimal one against the connection and table so you can run previews. The real model fields are built in later steps.\n\n```malloy\n// minimal source for previewing data during discovery\nsource: explore is my_conn.table('schema.table') extend {}\n```\n\n**In analysis-first mode:** There is no temp file. The analysis `.malloy` file IS your working file. It grows throughout the session and becomes the input for formalizing into a model. See `skill:malloy-analyze` for that workflow.\n\n## What to Capture\n\nWhen reviewing tables and columns, capture:\n\n### Table-Level\n- All tables with row counts\n- Connection name and schema (CRITICAL, never guess)\n- Table roles: fact, dimension, bridge, lookup, staging, operational\n- Join relationships (FK → PK mappings)\n\n### Column-Level\n- Primary key and foreign key columns\n- Data types (watch for string dates, arrays, JSON)\n- Reserved word columns that need backticking (`Date`, `Type`, `number`, `source`, etc.)\n- Column cardinality and NULL rates (via `execute_query`)\n- Data distributions for key numeric and categorical columns\n\n### Data Quality\n- **Check for duplicate rows** on primary keys. Run `group_by: pk, aggregate: count(), having: count() > 1` on each key table. Duplicates cause `sum()` to return nonsensical values.\n- **Denormalized count columns**: beware pre-aggregated fields (e.g., `order_count` in a customer table) that may conflict with joined counts.\n- **Delimited list columns**: flag string columns containing comma-separated values.\n\n### Data-Driven Validation\n\n**Every recommendation must be grounded in queried data, not schema inference.** During discovery, run `execute_query` to validate assumptions before proposing anything in later steps.\n\n| What to validate | Query to run |\n|-----------------|-------------|\n| **Denormalized vs joined values** | Compare pre-computed columns (e.g., `customers.order_count`) against the actual joined aggregate (`count()` from `orders`). Report discrepancy rate. If >0%, flag for user decision. |\n| **Candidate date fields** | When multiple date/timestamp columns exist, query both. What % of rows differ? By how much? This informs which is canonical. |\n| **Numeric column distributions** | Query min, max, avg, percentiles (p25, p50, p75, p95). These inform tier boundaries and detect outliers. |\n| **Categorical column cardinality** | Query distinct values. A `status` column with 5 values behaves differently from one with 500. |\n| **Column usefulness** | Query NULL rates. Columns that are >95% NULL are candidates for `internal`. |\n| **Join cardinality** | Query FK uniqueness: `group_by: fk_col, aggregate: row_count is count(), having: row_count > 1`. Determines `join_one` vs `join_many`. |\n| **Revenue/amount columns** | When multiple money columns exist (`total`, `subtotal`, `amount`, `price`), query a sample to understand how they relate (does `total = subtotal + tax`?). |\n| **Join key value compatibility** | For every proposed join, sample 5-10 actual values from each side. Check for format mismatches: abbreviations (\"4th Av\" vs \"4 Avenue\"), ordinals (\"23 St\" vs \"23rd St\"), casing, prefixes. Mismatched values mean the join won't work even if column names match. |\n| **Mixed-grain rows** | For each key table, run top-N and bottom-N by primary metric. Look for summary/aggregate rows mixed with detail data (e.g., \"System Total\" rows in a station-level table). These corrupt measures if not filtered out. |\n\n**Never assume from column names.** Always query the data to confirm. A column named `total` could include or exclude tax. A `status` column could have unexpected values. A FK could have orphaned references.\n\n### Example Queries\n\n**Tier boundaries**: query distribution, propose breaks from percentiles:\n```malloy\nrun: orders -> {\n aggregate:\n min_val is min(sale_price), p25 is sale_price.percentile(25)\n median_val is sale_price.percentile(50), p75 is sale_price.percentile(75)\n p95 is sale_price.percentile(95), max_val is max(sale_price)\n}\n```\n\n**Denormalized vs joined**: compare pre-computed column against real aggregate, report match rate:\n```malloy\nrun: customers -> {\n join_many: orders on customer_id = orders.customer_id\n aggregate:\n total is count()\n match is count() { where: order_count = count(orders.order_id) }\n}\n```\n\n**Canonical date**: when multiple date columns exist, check how often they differ:\n```malloy\nrun: orders -> {\n aggregate:\n total is count()\n same_date is count() { where: created_at::date = submitted_at::date }\n max_gap_days is max(days(submitted_at - created_at))\n}\n```\n\n**Revenue columns**: when multiple money columns exist, verify their relationship:\n```malloy\nrun: orders -> {\n aggregate:\n total_eq_parts is count() { where: abs(sale_price - (subtotal + tax)) < 0.01 }\n total is count()\n}\n```\n\n### Schema Shape\n- Is this a star/snowflake schema (use base + joined source layers) or normalized/ER-style (may need 3-stage pattern)?\n- Combined vs split tables: prefer filtered/split tables over combined when both exist.\n\n## Computed Source Detection\n\nFlag potential computed sources when:\n\n1. **Grain mismatch**: the analytical scope requires a grain that no physical table provides (e.g., customer-level metrics from an order-grain table)\n2. **Repeated aggregation patterns**: the same GROUP BY + aggregate pattern would be needed in multiple analyses\n3. **Cross-entity aggregations**: the model or the data implies cross-entity aggregations that require a pre-aggregated entity\n\n## Prior Art Detection\n\nCheck for prior art signals at the start of discovery. If a signal is found and the user confirms, **you MUST read** the corresponding reference skill and follow its instructions.\n\n| Signal | Source Type | Reference to Read |\n|--------|------------|-------------------|\n| `.lkml` files in project or subdirectories | lookml | `skill:malloy-lookml-review` |\n| `dbt_project.yml` in project or parent dirs | dbt | dbt review (future) |\n\nThe reference handles inventory, classification, and produces prior-art notes. Keep those notes in-conversation, then continue with normal discovery below.\n\n**If DB connection available (LookML + DB mode):**\n- Read the model and run `execute_query` as normal\n- Use prior art as additional context, not a replacement for data validation\n- **The LookML connection name is NOT the Malloy connection name.** Always use the connection name from the model.\n\n**If no DB connection (LookML-only mode):**\n- Skip the model-read and `execute_query` steps\n- Use connection name and table paths extracted from prior art source files\n- Flag all proposals in Steps 2-4 as **unvalidated**\n- Proceed directly to Step 2 (PROPOSE SCOPE)\n\n**Prior art findings enhance discovery, they don't replace it.** When a DB connection is available, always validate assumptions against the actual data.\n\n## After Discovery\n\nDo NOT present findings to the user yet.\n\n## Done\n\nStep complete. Output: discovery findings (internal: tables, columns, relationships, data quality, prior art). Continue to the next modeling step (see your modeling workflow).\n\n## Verify Source Joins\n\nWhen reading joins off the model or the data, watch for `join_many` where the actual relationship is many-to-one. Always verify cardinality. Prefer `join_one` when each row in the primary table matches at most one row in the joined table." }, { name: "malloy-document", description: 'Add documentation with #(doc) tags to Malloy models so fields and sources are described in plain language. Use when user asks to "add documentation", "add doc tags", "document the model", or wants fields and sources described for natural-language search and discovery. For declaring parameterizable filters with #(filter), see the malloy-model skill. Filters are a runtime/modeling construct (governance, latency, correctness), not a documentation tag.', body: "# Documenting a Malloy Model\n\nAdd `#(doc)` tags to describe sources and fields in plain language so they are easy to find and understand:\n\n| Tag | Purpose | Goes on |\n|-----|---------|---------|\n| `#(doc)` | Plain-language description for natural-language search | source, dimension, measure, view, join |\n| `#(filter)` | Declare a parameterizable filter (runtime/modeling concern, see `malloy-model`) | source |\n\n`#(doc)` is a standard Malloy annotation. It documents a field or source with a human-readable description that downstream tools can surface and search against.\n\n## #(doc) Tag\n\nAdd before any source, dimension, measure, view, or join. When multiple fields share a keyword, use it once as a block header. Tags and field names are indented under the keyword; tags go on the line(s) directly above the field they annotate.\n\n**Tag ordering** (when a field has multiple tags): `#(doc)` → render tags (`# currency`, `# label`, etc.) → field name. Separate each field group with a blank line:\n\n```malloy\n#(doc) Customer who placed the order\njoin_one: users with user_id\n\ndimension:\n #(doc) Date the order was placed (UTC)\n order_date is created_at::date\n\nmeasure:\n #(doc) Total revenue from all orders in USD\n # currency\n revenue is sum(total)\n```\n\n### Writing Doc Strings for Retrieval\n\nDoc strings power natural-language search: users type plain-English questions and the system matches against your `#(doc)` strings. Write descriptions that match how analysts would search:\n\n- **Include business meaning**, not code mechanics: what it represents, not how it's implemented\n- **Include units** (USD, count, percentage) and valid values for categorical fields\n- **Avoid Malloy jargon**: never use \"filterable\", \"groupable\", \"dimension\", \"measure\", \"aggregation\"\n\n**Good examples:**\n- `#(doc) Total revenue from completed orders in USD` matches \"what was our revenue?\"\n- `#(doc) Customer signup date (UTC)` matches \"when did the customer join?\"\n- `#(doc) Order status: pending, processing, shipped, delivered, cancelled` matches \"what are the order statuses?\"\n\n**Bad examples:**\n- `#(doc) Filterable dimension for order status`: no analyst searches for \"filterable\"\n- `#(doc) Groupable by region`: \"groupable\" is a system concept\n- `#(doc) Aggregation of total sales`: \"aggregation\" doesn't match natural queries\n\n## #(filter): see `malloy-model`\n\n`#(filter)` is also a `#(...)`-shaped annotation, but unlike `#(doc)` it's a **runtime/modeling construct**: it shapes governance, query latency, and correctness, not discoverability. The full reference (syntax, filter types, `required` / `implicit` flags, and when each applies) lives in `malloy-model` § Parameterizable Filters with `#(filter)` alongside the other source-authoring constructs.\n\nOne rule worth knowing here: filters live on the source, never on the consumer. Ad-hoc reports and notebooks that import a source inherit its filters automatically; they do not (and cannot) declare new ones.\n\n## `internal:` and `private:`: column-level access in a source\n\n`#(doc)` describes what's exposed. Two access modifiers control what's exposed in the first place, and both live **inside** a source's `include {}` block. They are about the source's public API and data sensitivity, not about documentation, so reach for them when curating which columns callers can pick.\n\n| Mechanism | Layer | Why you reach for it |\n|---|---|---|\n| `internal:` | Inside a source (one column in `include {}`) | The column **isn't part of your model's public API**. Common reasons: data is messy (empty/garbage, raw JSON, duplicates), or a documented derived dimension already supersedes it, or the raw column exists only to be joined on / referenced internally and shouldn't appear as a dimension callers can pick. The data may be perfectly fine, it's just not what you want exposed. |\n| `private:` | Inside a source (one column in `include {}`) | The **data is sensitive**: SSN, raw credit card, password. Governance / security concern; a harder block than `internal:`. |\n\nIn one sentence: **`internal:` and `private:` shape what's inside a source's public API; `#(doc)` describes the fields you do expose.**\n\n### Example\n\nA base source pulled from a messy raw table often uses `internal:` to drop raw fields from the public API, while documenting the curated columns with `#(doc)`.\n\n```malloy\n// orders_base.malloy\n#(doc) Raw orders. Use orders.malloy as the entry point for analysis.\nsource: orders_base is conn.table('orders_raw')\n include {\n public: id, customer_id, order_date, total\n internal: raw_json_payload, deprecated_status_code, _temp_dedup_marker\n }\n extend {\n primary_key: id\n }\n```\n\n```malloy\n// orders.malloy\nimport \"orders_base.malloy\"\n\n#(doc) Order analysis. Use for revenue, fulfillment, and customer-order joins.\nsource: orders is orders_base extend {\n // joins, measures, curated dimensions\n}\n```\n\nThe base source stays fully queryable (`run: orders_base -> { ... }` still works); `internal:` only governs which columns appear as public dimensions callers can pick.\n\n## Annotating Columns in Include (Experimental)\n\nWith `##! experimental.access_modifiers`, you can add `#(doc)` tags to raw table columns inside `include` blocks. This documents columns without redefining them as dimensions.\n\n```malloy\n##! experimental.access_modifiers\n\nsource: orders is conn.table('orders') include {\n public:\n #(doc) Order line item identifier\n id\n\n #(doc) Customer email address\n email\n\n #(doc) Order status: pending, shipped, delivered\n status\n\n // internal: only for verified noise (empty cols, raw JSON blobs, duplicates)\n}\nextend {\n // ... dimensions and measures\n}\n```\n\n**When to use:**\n- Documenting raw columns without creating explicit dimensions\n- Curating which columns are public vs internal\n\n## Source-Level Documentation\n\nDocument **when to use** a source, not what it contains. Dimensions and measures can already be searched directly, so the source-level `#(doc)` should describe what questions/analyses this source answers.\n\n**Base source files:** Document what the table represents.\n```malloy\n#(doc) Customer records with demographics and segmentation. One row per customer.\nsource: customers is conn.table('sales.customers') extend { ... }\n```\n\n**Source files:** Document what analytical questions the source answers.\n```malloy\n#(doc) Customer health analysis. Use for retention, segmentation, churn risk, and lifetime value. For order-level analysis, use order_analysis instead.\nsource: customer_health is customers extend { ... }\n```\n\n**Best practices:**\n- Add `#(doc)` to all base source and joined source definitions\n- Base source docs: describe what the table is (one row per what)\n- Source docs: describe what questions/analyses the source answers\n- Documentation happens per-source-file, not in one monolithic file\n\n## Flag Ambiguous Descriptions\n\nAfter writing `#(doc)` tags, present any that required judgment to the user for confirmation:\n\n| Field | Proposed doc | Confidence | Uncertainty |\n|-------|-------------|------------|-------------|\n| `total` | \"Total order amount in USD\" | Medium | Could be gross or net, verified with sample query |\n| `status` | \"Order status: pending, shipped, delivered\" | High | Values confirmed via a query of distinct values |\n\nOnly flag fields where the description required assumptions about business meaning, units, or valid values. When in doubt about valid values, run a quick query against the data to confirm them before writing the description. Use `malloy_getContext` to ground yourself in the package's sources and fields and `malloy_executeQuery` to check distinct values, for example `run: source -> { group_by: status }`.\n\n## Done\n\nStep complete. Output: `#(doc)` tags added to all public fields and sources." }, { name: "malloy-getting-started", description: "First steps for using a Malloy Publisher deployment through its MCP tools. Use when connecting to Publisher for the first time, when you do not yet know the available environments, packages, or models, or when a user asks what data they can explore. Covers verifying the server, discovering data with malloy_getContext, and running a first grounded query.", body: '# Getting started with Malloy Publisher\n\nGoal: go from "connected" to a correct, grounded answer without guessing any names.\n\n## 0. Confirm the tools are reachable\n\nAt minimum you need `malloy_getContext`, `malloy_executeQuery`, and `malloy_searchDocs`. Authoring a model also needs `malloy_compile` and `malloy_reloadPackage` (see section 4); an older Publisher may not serve those two.\n\nIf none of the tools are there, either the server is not running or your client connected before it was. Start the server (`npx @malloy-publisher/server --port 4000`, or `bun run build && bun run start` from a clone) and wait until `curl -s http://localhost:4000/api/v0/status` reports `operationalState: serving`.\n\nIf there is no Publisher workspace here at all, and the user wants to work with data of their own rather than the bundled examples, `npm create @malloy-publisher/malloy-package@latest <name>` scaffolds one: the package and a starter model, registered so the server actually serves it, plus the start script, the MCP config and these skills. Keep the `@latest` when you type it: `npm create` resolves through npm\'s npx cache and an unversioned name is satisfied by any copy already there, so on a machine that has scaffolded before npm never asks the registry and you get an old scaffolder pinning an old server, with nothing to say so. Run bare, it comes with a small sample dataset, so there is something to query straight away. In a fresh directory `npm start` then runs the pinned server against the package in watch mode; if the directory already had a `package.json` the scaffolder leaves it alone and adds no script, printing the equivalent `npx` command to use instead. Where you run it matters: only the package lands in `<name>/`, and the workspace files, the agent instructions and the MCP config among them, are written to the current directory. Run it here if this directory is empty or is meant to become the workspace. If it already holds other work, scaffold into a new directory instead (`mkdir my-data && cd my-data`), because agent config is discovered by walking up, so writing those files here changes what every session beneath this directory inherits. Seed the starter model from a local file with `npm create @malloy-publisher/malloy-package@latest <name> -- --data <path/to/their-file.csv>` (CSV, Parquet, or Excel `.xlsx`), keeping the `--`, which is how `npm create` passes options through. That path is relative to wherever you run the command, so if you scaffolded into a new directory it has to reach back out to their file; the scaffolder copies it into the package and leaves the original alone. A seeded package starts smaller than the sample one, since the scaffolder does not read their columns: expect a row count and an overview, and build the model from there. A package is just Malloy, so it can instead query a database connection the config defines. Because it writes a `.mcp.json` that did not exist when the client connected, the user has to restart or reconnect once before these tools appear, and their client will ask them to approve the new project-scoped server the first time. That only works when the workspace is at the session\'s own root, so if you scaffolded into a new directory below that root, the user has to open a session there instead: a `.mcp.json` further down is never discovered.\n\nIf you started the server yourself in this session, the tools still will not appear: your tool list was fixed when you connected, and you cannot reconnect yourself. Tell the user the tools are missing for that reason and ask them to run `/mcp`, select `malloy`, and choose Reconnect. The panel offers `Authenticate` first and reports `Auth: not authenticated`; that is a red herring, the endpoint has no auth. Restarting Claude Code also works. Continue once the tools are there.\n\nWhen a user is present, do not route around it by calling the REST API with curl. It appears to work, so the user never learns their session is missing the tools, and you lose what they are for: grounded discovery instead of guessed names, `malloy_compile` instead of throwaway queries, and `malloy_reloadPackage` instead of a restart. Say the tools are missing and let the user fix it in five seconds. Running unattended, with nobody who can reconnect you, is different: there the REST API is the supported interface, not a workaround. Discovery, query, compile, and reload all have REST equivalents (`malloy_searchDocs` and `malloy_getContext`\'s plain-English ranking do not; read the bundled skills for syntax and ground from model metadata instead); the running server serves the full spec at `http://localhost:4000/api-doc.yaml`, and AGENTS.md carries the endpoint map.\n\n## 1. Discover what exists (never guess names)\n\n`malloy_getContext` is progressive. Call it with as much as you know:\n\n- No arguments: the available environments, each with its package names.\n- `environmentName` only: the packages in that environment.\n- `environmentName` + `packageName`: that package\'s sources.\n- `environmentName` + `packageName` + `query` (plain English): the sources, views, named queries, and dimension/measure fields most relevant to the question.\n\nUse the names it returns exactly. Do not invent environments, packages, sources, or fields.\n\n## 2. Run the query\n\nCall `malloy_executeQuery` with the `environmentName`, `packageName`, and `modelPath` from the context results, plus either:\n\n- a named view or query: pass its `name` as `queryName` (with `sourceName` for a view), or\n- an ad-hoc query: pass Malloy code as `query`.\n\nThe result is JSON. Charts and dashboards defined in the model render in the Publisher UI at http://localhost:4000.\n\n## 3. When you need Malloy syntax\n\nUse `malloy_searchDocs` for language questions (filters, aggregates, joins, nesting, renderers).\n\nIf the data you want is in a connected database but not yet in any package, use `malloy_searchDatabaseSchema` instead of `malloy_getContext`: it walks a connection\'s schemas and tables and ranks them against a plain-English description, and hands back the `source:` line to start a model from. It returns names and types only, so to see what a column actually contains run `malloy_executeQuery` against a model in a package that uses the same connection, with an ad-hoc query like `run: my_conn.table(\'sales.orders\') -> { group_by: order_status }`. That tool needs an existing model to run against, so a table you have not modelled yet has none of its own.\n\n## 4. What else you can do here\n\nAnswering questions is the start, not the whole surface. When the user asks what is possible, say so rather than offering queries alone. Switch skills for the deeper work:\n\n- `malloy-modeling`: build or change a model. Validate the edit with `malloy_compile`, save it, then `malloy_reloadPackage` so the new sources and views run by name without restarting the server.\n- `malloy-analysis`: explore a package and answer data questions.\n- `malloy-html-data-apps`: build a data app, a hand-authored HTML page in the package\'s `public/` directory that Publisher serves, backed by the package\'s models and needing no build step.\n- `malloy-review`: check Malloy for correctness.\n\n## Contract\n\n- Ground every query in `malloy_getContext` results. If a name is not in the results, do not use it.\n- Start broad and narrow down: environments, then packages, then sources, then query.\n- Confirm the environment and package before running a query.' }, { name: "malloy-gotchas-modeling", description: "Common Malloy modeling mistakes and how to avoid them. Read BEFORE writing source definitions, dimensions, measures, or joins. Covers reserved words, NULL checks, date functions, type casts, field management (extend except/accept/rename vs include public/internal/private), and query-based source gotchas.", body: "# Modeling Gotchas\n\n> **Read this before writing Malloy code.** These patterns cause most modeling errors.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n## Reserved Words: Backtick Them\n\n**When in doubt, backtick it.** Unquoted reserved words cause cascading errors on unrelated lines.\n\n```malloy\n// WRONG // RIGHT\ndimension: d is Date::date dimension: d is `Date`::date\n```\n\nWords most likely to appear as column names:\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n\n## NULL Checks: `is not null`, NOT `!= null`\n\n```malloy\n// WRONG // RIGHT\ndimension: is_sold is sold_at != null dimension: is_sold is sold_at is not null\n```\n\n## Date Functions vs Properties\n\n```malloy\n// WRONG: day_of_week is a function // RIGHT\ndimension: dow is created_at.day_of_week dimension: dow is day_of_week(created_at)\n```\n\n**Property access:** `.month`, `.year`, `.quarter`, `.day`, `::date`\n**Function call required:** `day_of_week()`, `week()`, `hour()`, `minute()`, `second()`\n\n## `.date` Is a Cast, Not a Truncation\n\nCalendar truncations are `.day`, `.week`, `.month`, `.quarter`, `.year` (plus `.hour`, `.minute`, `.second` for timestamps). `.date` is **not** among them: it's a **cast** (`::date`), not a truncation, so `created_at.date` does not compile. This bites twice: once at compile time, and again as a latent bad `#(doc)` comment that only a review pass catches (\"truncated to date\" is a doc smell; it should say \"to day\").\n\n```malloy\n// WRONG // RIGHT\ncreated_at.date created_at.day // truncate to day\n created_at::date // cast to a date\n```\n\n## Interval Functions: Only `seconds` / `minutes` / `hours` / `days`\n\n`weeks()`, `months()`, `quarters()`, `years()` are **documented but don't work** in this build; only `seconds`, `minutes`, `hours`, `days` actually function. Compute in days and derive the larger unit: a *units conversion*, not a calendar-floored duration:\n\n```malloy\n// WRONG: weeks()/months() don't compile\ndimension: weeks_open is weeks(opened_at to closed_at)\n\n// RIGHT: measure in days, convert (documents that it's approximate)\ndimension: days_open is days(opened_at to closed_at)\ndimension: weeks_open is days(opened_at to closed_at) / 7 // ≈ weeks\ndimension: months_open is days(opened_at to closed_at) / 30.44 // ≈ months\n```\n\n(Contrast: `search_malloy_docs` gets this right when asked narrowly; trust the docs on the supported units, not on the missing ones.)\n\n## Safe Division: Always `nullif`\n\n```malloy\n// WRONG // RIGHT\na / b a / nullif(b, 0)\n```\n\n## String Columns Need Casts for Aggregates\n\n```malloy\n// WRONG: \"Can't use type string\" // RIGHT\nmeasure: avg_score is avg(score) measure: avg_score is avg(score::number)\n```\n\n**Dirty columns: null the sentinel before casting.** `::number` is a strict cast, so a column that carries non-numeric sentinels (`'NA'`, `'N/A'`, `''`, `'-'`, `'null'`) compiles fine but fails at query time with `Could not convert string 'NA' to DOUBLE`. Strip the sentinel with `nullif` first, then cast (aggregates skip nulls):\n\n```malloy\n// WRONG: throws on 'NA' at query time // RIGHT: nulls 'NA', then casts\nmeasure: s is avg(score::number) measure: s is avg(nullif(score, 'NA')::number)\n```\n\nChain `nullif` for multiple sentinels: `nullif(nullif(score, 'NA'), '')::number`. Sample the column's values first (`run: source -> { group_by: score; limit: 20 }`) to see which sentinels it uses.\n\n## Boolean Columns: No Quotes\n\n```malloy\n// WRONG // RIGHT\ncount() { where: complaint = 'true' } count() { where: complaint = true }\n```\n\nCheck schema: if `BOOL`, use `true`/`false`. If `STRING`, use `'true'`/`'false'`.\n\n## `greatest()` / `least()` Are Null-Poisoning\n\nMalloy's `greatest()` / `least()` return **NULL if *any* argument is null**, unlike Postgres `GREATEST`/`LEAST`, which ignore nulls. Porting a LookML/SQL expression verbatim is a silent parity bug: the number just goes null for any row with a missing input. Coalesce the result back to a non-null argument:\n\n```malloy\n// WRONG: one null input nulls the whole thing\ndimension: last_touch is greatest(email_at, call_at)\n\n// RIGHT: fall back so a null arg can't poison the result\ndimension: last_touch is greatest(email_at, call_at) ?? email_at ?? call_at\n```\n\n## No Scalar Median; Raw-SQL Aggregates Don't Compile\n\n**There is no scalar `median`, and `PERCENTILE_CONT` cannot be expressed as a measure in this build.** Every documented form for a custom SQL aggregate - `percentile_cont!(x, 0.5)`, `sql_number(...)`, `sql_number(...) { is_aggregate: true }`, and the `# is_aggregate` annotation - resolves as a **scalar** and fails with *\"Cannot use a scalar field in a measure declaration.\"* The docs' own `avg_dist` example fails the same way. This is a deployed-runtime limitation, not a syntax error you can fix: **do not** burn cycles trying `!`, `sql_number`, or `is_aggregate` variations to get a median.\n\n```malloy\n// DOES NOT COMPILE in this build (all forms resolve as scalar):\nmeasure: median_x is percentile_cont!(x, 0.5)\nmeasure: median_x is sql_number(\"PERCENTILE_CONT(...) ...\") { is_aggregate: true }\n```\n\n**Ship `avg` instead, or defer median with a documented gap** (\"median deferred: no scalar median / runtime rejects raw-SQL aggregates\"). Tell the user; don't silently substitute `avg` for a metric that was specified as median.\n\n**`stddev` does work**, so reach for it when the question is about spread. It is a native Malloy aggregate rather than a raw-SQL escape, so unlike everything above it compiles both inline and as a `measure:`, and it is the sample standard deviation. `variance`, `stddev_samp`, and `stddev_pop` are not Malloy functions, and pushing them through `!` fails as a scalar exactly like `percentile_cont!`.\n\n```malloy\n// WORKS: inline, or as a measure on a source\nrun: order_items -> { aggregate: sd is stddev(sale_price) }\nsource: items is order_items extend { measure: price_stddev is stddev(sale_price) }\n```\n\n## Field Management: `extend {}` vs `include {}` Don't Compose\n\nMalloy has two field-management mechanisms for base sources. **`include {}` is the curated default; `extend { except / accept / rename }` is the fallback when a `rename:` is unavoidable.** They have different capabilities and **do not combine**.\n\n| Mechanism | Where it lives | Keywords | Compatible with `rename:`? | Experimental flag? |\n|---|---|---|---|---|\n| Access modifiers (default) | `include {}` | `public:` / `internal:` / `private:` | **No** | Yes (`##! experimental.access_modifiers`) |\n| Field management (fallback) | `extend {}` | `accept:` / `except:` / `rename:` | Yes (same block) | No |\n\n### Default: `include {}` for documented, curated base sources\n\nUse `include {}` whenever the source doesn't need a `rename:`. It's the only way to attach `#(doc)` tags to raw columns, and it's the canonical way to hide empty/garbage/duplicate columns (`internal:`) and sensitive ones (`private:`). See `skill:malloy-model` § Access Modifiers.\n\n```malloy\n##! experimental.access_modifiers\nsource: orders is conn.table('orders') include {\n public:\n #(doc) Order identifier\n order_id\n\n #(doc) Customer who placed the order\n user_id\n\n internal:\n raw_payload_json // empty after JSON extraction\n legacy_status_code // superseded by status_code\n}\n```\n\n### When `rename:` is unavoidable: fall back to `extend {}`\n\n`include {}` does not compose with `rename:`. The combination errors with `Can't find field 'X' to set access modifier` because `rename:` runs first and leaves no `X` for `include` to attach a modifier to. There's also a collision inside `include {}` itself: a measure cannot share a name with a raw column, even one tagged `internal:` (`Cannot redefine 'X'`), and the natural fix for that is `rename:`, which then triggers the first error.\n\nWhen a rename is genuinely required (most often during `conn.sql()` to `conn.table()` migration where a SQL alias matches a measure name that's already in heavy use downstream), drop `include {}` and curate the source with `extend { except: ... }` + `rename:` instead. You forfeit `#(doc)` on raw columns and the `public/internal/private` tiers, but keep column gating and the rename.\n\n```malloy\n// RIGHT: rename is required to free `revenue` for the measure\nextend {\n except: legacy_status_code // hide garbage column without include {}\n rename: raw_revenue is revenue\n measure: revenue is raw_revenue.sum()\n}\n```\n\nIf you can rename the measure or split the source instead, prefer that: it preserves `include {}` and the curated surface.\n\n### `extend {}` clauses (reference)\n\n- **`accept:`**: allow-list, keep only the named columns\n- **`except:`**: deny-list, drop the named columns; keep everything else (mutually exclusive with `accept:`)\n- **`rename:`**: alias a raw column to free up its original name for a measure or dimension\n\n### Migrating `conn.sql()` to `conn.table()` + Malloy clauses\n\nThe biggest reason teams reach for `conn.sql()` is column gating, aliasing, and per-row derivation in one place. All three have native equivalents:\n\n1. **Verify the schema**: `run: <source> -> { select: *; limit: 1 }` to discover all columns. Anything in the table but not in the SQL's `SELECT` was being intentionally hidden, so preserve that gating.\n2. Switch to `conn.table('…')`.\n3. Hidden columns: preferably `include { internal: ... }` (lets you also `#(doc)` the public columns). If a `rename:` is also needed in the same source, fall back to `extend { except: ... }`.\n4. SQL aliases: `extend { rename: ... }` (forces the fallback path, since `rename:` and `include {}` don't compose). If the alias was to free up a name for a measure, use `rename: raw_X is X`, then `measure: X is raw_X.sum()`.\n5. SQL derivations: `dimension:` definitions in `extend {}`.\n6. SQL `WHERE`: source-level `where:`.\n\n## Cannot Redefine Query-Based Source Columns\n\nColumns from `table -> { group_by, aggregate }` or `conn.sql()` already exist. You cannot re-declare them.\n\n```malloy\n// WRONG: \"Cannot redefine 'user_id'\"\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: user_id is user_id }\n// RIGHT: add only NEW derived dimensions\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: is_high_value is total > 1000 }\n```\n\nTo add `#(doc)` tags to existing query columns, use `include {}` between the query and extend.\n\n## Extending a Source Cannot Reuse a Name It Already Defines\n\n```malloy\n// WRONG: \"Cannot redefine 'overview'\" when sales already declares view: overview\nsource: wines is sales extend { view: overview is { aggregate: record_count } }\n// RIGHT: give the extension its own name\nsource: wines is sales extend { view: summary is { aggregate: record_count } }\n```\n\nAn extension adds to the parent's namespace, it does not override it. This bites when you extend a source to \"replace\" one of its views: rename the new definition, or edit the view on the parent source instead of extending it. Malloy reports the same `Cannot redefine 'X'` for dimensions and measures that collide with an inherited name, per the sections above and below.\n\n## Never Use `conn.sql()` When Malloy Has a Native Pattern\n\n```malloy\n// WRONG: raw SQL for pre-aggregation\nsource: facts is conn.sql(\"\"\"SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id\"\"\")\n// RIGHT: Malloy query-based source\nsource: facts is conn.table('orders') -> { group_by: user_id, aggregate: total is sum(amount) }\n```\n\n**Mandatory: call `search_malloy_docs` before reaching for `conn.sql()`.** Don't argue from intuition. Most patterns that look SQL-only have a Malloy equivalent, including the ones reviewers historically said couldn't be expressed.\n\n| Looks like it needs SQL | Malloy equivalent |\n|---|---|\n| Multi-CTE pipeline | Stacked query-based sources: `source: a is t -> {...}`; `source: b is a -> {...}`; `source: c is b -> {...}` |\n| UNNEST / array column access | `array_column.each.field`: arrays auto-join as nested tables ([data types docs](https://docs.malloydata.dev/documentation/language/datatypes#array-access)) |\n| PIVOT (conditional aggregation) | Filtered aggregates: `aggregate: a is x.sum() { where: cat = 'a' }, b is x.sum() { where: cat = 'b' }` |\n| Window functions (any frame, including custom) | `calculate:` with `sum_cumulative`, `lag`, `lead`, `rank`, `row_number`, `avg_moving`, `first_value`, `last_value`: supports `partition_by:` and `order_by:` ([window functions docs](https://docs.malloydata.dev/documentation/language/functions#window-functions)) |\n| `ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING` | `sum_cumulative(x) - x` (cumulative-including-current minus current = cumulative-excluding-current) |\n| `WHERE date = (SELECT max(date) FROM …)` (latest snapshot) | `join_cross` to a one-row aggregate source, then filter on the joined `max_date` field |\n| Multi-key joins | `join_one: x is target on a = x.a and b = x.b and c = x.c` |\n| `greatest()` / `least()` / `CASE` chains | All native: `greatest(a, b, c)`, `least(a, b)`, `pick 'x' when cond else 'y'` |\n| Dialect-specific scalar functions | `function_name!return_type(args)`: Malloy's raw-SQL function escape (no `conn.sql()` block needed) |\n\n**Genuinely valid `conn.sql()` candidates (rare):**\n\n- SQL features Malloy explicitly doesn't model (e.g., DML/DDL, specific `MERGE` patterns)\n- Multi-stage transformations where every CTE has 3+ joins to different tables AND the result is consumed by multiple downstream sources, but in this case an intermediate table in the data warehouse is usually still better than `conn.sql()`\n\n**Never use `conn.sql()` for:** simple column selection or renaming, `WHERE` filters, two-table joins, column type casts, latest-snapshot patterns, conditional aggregation, or window functions of any kind.\n\nIf a project's standards file specifies a stricter policy (e.g., a `search_malloy_docs` rationale comment requirement above every `conn.sql()` block), defer to that.\n\n## JSON Files: Read Them In Place Like CSV\n\n```malloy\n// RIGHT: .json works like .csv/.parquet\nsource: reviews is duckdb.table('data/reviews.json')\n// RIGHT: newline-delimited JSON is read the same way\nsource: events is duckdb.table('data/events.ndjson')\n// RIGHT: read options need read_json_auto in a SQL source\nsource: nested is duckdb.sql(\"\"\"SELECT * FROM read_json_auto('data/reviews.json')\"\"\")\n// WRONG: shelling out to python, or converting to CSV first\n```\n\nDuckDB reads JSON directly, so never preprocess a `.json` file before modeling it and never reach for a scripting language to inspect one. Both a top-level array of objects and newline-delimited JSON work through `duckdb.table()`.\n\nQuirk: JSON carries no schema, so a value written as `\"90\"` arrives as a string where the same data in CSV would be inferred as a number. Cast it in the source, under a new name (reusing the column's own name is a redefinition error):\n\n```malloy\nsource: reviews is duckdb.table('data/reviews.json') extend {\n dimension: points_num is points::number\n}\n```\n\n## Excel Files: Read `.xlsx` In Place, Never Convert\n\n```malloy\n// RIGHT when the sheet is a plain table (header in row 1, data under it, no blank row inside\n// it): read it where it sits, like .csv/.parquet (in a Publisher package the sandbox\n// connection is `duckdb`)\nsource: budget is duckdb.table('data/budget.xlsx')\n// RIGHT for anything messier. Profile the top rows first to find the real header row and the\n// last real column, because nothing else will tell you where they are. Put the probe in the\n// model file as its own source: Publisher refuses raw SQL in an ad-hoc query.\n// SELECT * FROM read_xlsx('data/sales.xlsx', sheet = 'Sales Data',\n// range = 'A1:Z15', header = false, all_varchar = true)\nsource: sales is duckdb.sql(\"\"\"\n SELECT * FROM read_xlsx('data/sales.xlsx',\n sheet = 'Sales Data', -- EDIT: only the first sheet is read by default\n header = true,\n range = 'A5:J100000' -- EDIT: A5 is the real header row. Keep the column bound at the\n ) -- last real column; the row bound just has to clear the end.\n WHERE \"Order ID\" LIKE 'SO-%' -- EDIT, REQUIRED: a data-row predicate. This is what ends the\n\"\"\") -- read; drop it and every empty row in the range comes back.\n// WRONG: converting the spreadsheet to Parquet or CSV first (an unnecessary extra step)\n```\n\nDo not convert spreadsheets before modeling. DuckDB's excel extension reads `.xlsx` directly and loads automatically on first use, so a sheet that is a plain table needs nothing more than `duckdb.table()`. Converting does not avoid any of the problems below, it just moves them into a copy that goes stale the next time someone updates the workbook.\n\n**Plenty of real exports are not plain tables, and nothing tells you.** A report title, a \"generated on\" banner, a merged group header, a blank line above the header, or a blank spacer row inside the data are all ordinary, and none of them is visible from Malloy. There is no error either: the package loads, the server reports serving, the query returns 200, and the number is just wrong. So make two checks before building on the read: compare `aggregate: record_count is count()` against what you know is in the file, and `select: *; limit: 1` to see what the columns really are. If either disagrees with the file, the read is wrong and so is every measure over it.\n\n`table()` takes a plain file path only, so anything needing `read_xlsx` options (`sheet`, `range`, `header`, `ignore_errors`, `normalize_names`, `all_varchar`, `empty_as_varchar`, `stop_at_empty`) goes through the SQL-source form.\n\nQuirks:\n\n- Only the FIRST sheet is read by default. Select another with `sheet = 'Name'`. There is no function that lists a workbook's sheet names, but passing one that does not exist reports a suggestion (`Sheet \"x\" not found ... Did you mean: \"Notes\"`), which is one way to find a name you were not given.\n- A title or banner row above the header collapses the read. DuckDB takes the first row it finds as the column names, so a lone title cell in A1 becomes the only column. How many rows you then get is the next quirk's business: whatever sits between the title and the first blank row, often none or one, otherwise a plausible-looking partial count. Pass a `range` that starts at the real header row.\n- With no `range`, `stop_at_empty` defaults to true and the read stops at the first blank row, which on a real sheet is usually a spacer between blocks rather than the end of the data: a 30-row sheet with one spacer after row 10 reads as 10 rows. `stop_at_empty = false` lifts that, but it only helps when the header really is in row 1; with a title above the header you need the `range` anyway, and a `range` flips the default for you. It also hands the blank rows back as all-null rows, so the count comes out one high per spacer until you filter them.\n- A `range` reads every cell inside it, so an overshot bound manufactures padding: past the last real column you get all-null fields (`A5:Z100000` on a ten-column sheet yields 26, the extras named `C10` and `_1` through `_15`), and past the last real row all-null rows (`A5:J100000` on a 1,500-row sheet reads 99,995). Spacers, subtotals, and footnotes come through as rows too. So the row filter is not tidying-up, it is the thing that ends the read: filter to what a data row looks like (`WHERE \"Order ID\" LIKE 'SO-%'`) rather than to `IS NOT NULL`, which keeps any footnote carrying text in the first column. A bound that falls SHORT of the data is the dangerous direction: the rows and columns past it are dropped with no error at all, so overshoot the row bound and let the filter end the read.\n- Every number in an xlsx is stored as a double, so there are no integer columns. Typing is per column and decided by the FIRST data row, and `$1,234`, `12%` and `N/A` are all text: a text cell in that first row makes the whole column a string (on one real export, all ten of them), while a text cell further down leaves the column numeric and makes the read throw instead (`Could not convert string ... to DOUBLE`). `ignore_errors = true` fixes that second case, nulling the bad cells and keeping the column a number. It does nothing for the first.\n- Sample the column's SHAPES before writing any conversion, not its values: `run: source -> { group_by: shape is replace(raw_col, r'[0-9]', '9'); aggregate: n is count(); order_by: n desc }` collapses every value to its format and counts it, so on one real price column the 16 euro-denominated rows surface beside the 1,484 in dollars. A plain `group_by raw_col; limit: 20` sorts lexicographically, which hides exactly the shapes that matter.\n- Convert in the SQL source, not in Malloy, where `::number` throws on the first bad cell. `try_cast(regexp_replace(\"Total Revenue\", '[^0-9.-]', '', 'g') AS double)` nulls what it cannot read instead of failing and is right for a plain `$1,234.56`, but it is not a general parser. It concatenates every digit in the cell, so `1,234 (see tab 2)` becomes 12342. It understands only a leading ASCII `-`, so an accounting `(1,234)`, a Unicode minus and a `CR` suffix all come back positive, while a trailing `-` (`1,234-`) comes back null and drops the row from the sum. And it assumes `.` is the decimal point, so a European `1.234,56` comes back a thousandfold small. Handle the shapes your sample actually found, and divide a percent by 100. Failure is quiet either way: a cast that fails on every row sums to 0 rather than erroring, and a text date strips to a number rather than a null (`'01/02/2023'` becomes 1022023).\n- Check the answer against the sheet's own total row, read as raw text. Lift the data-row filter and select the footer by its label, which usually sits in a different column from the one your data-row predicate uses: on one export `WHERE \"Customer Name\" = 'TOTAL'` finds it and `WHERE \"Order ID\" = 'TOTAL'` returns nothing, and an empty result reads as a pass. Do not run the total through the same expression, because a wrong sign survives a row count, survives `select: *`, and cancels out when both sides are parsed the same broken way.\n- A sheet with no header row whose first row is all text silently loses that row to header detection. Pass `header = false`.\n- Headers with spaces are kept verbatim: backtick them in Malloy, or pass `normalize_names = true` for snake_case names.\n- `all_varchar = true` hands back each cell's stored value as text, so a date arrives as its raw Excel serial number rather than a date: `'44929'` from a sheet Excel wrote, `'44927.0'` from one DuckDB's own xlsx writer wrote, and `'44929.5'` where the cell carries a time of day. Which form you get depends on the tool that wrote the file, so do not detect serials by matching for an integer; `try_cast(... AS double)` accepts all three and returns null for a cell that was stored as text (`'01/02/2023'`), which is the test you want. Convert with `date '1899-12-30' + floor(try_cast(d AS double))::int`, not from 1900-01-01. Both wrappers earn their place: adding a double to a date does not compile, and a bare `::int` rounds, so an afternoon timestamp would land on the next day.\n- A date column that mixes both, which is what an export edited by hand gives you, needs both branches or you silently lose every row of one kind: `CASE WHEN try_cast(d AS double) IS NOT NULL THEN date '1899-12-30' + floor(try_cast(d AS double))::int ELSE try_strptime(d, '%m/%d/%Y')::date END`. Without `all_varchar`, a uniformly date-formatted column arrives as real `date` and `timestamp` values, and a stray text cell behaves exactly as the typing rule above says. Note what `ignore_errors = true` does here: it nulls that cell rather than parsing it, so the hand-typed date is lost silently.\n\n## Duplicate Rows: Check Before Building Measures\n\n```malloy\nrun: source -> { group_by: pk_field, aggregate: n is count(), having: n > 1, limit: 10 }\n```\n\nSymptoms: `sum()` returns astronomical values. Causes: event tables, batch retries, merged sources.\n\n## `except:` Removes Fields From Namespace Entirely\n\n`except:` in `include {}` completely removes fields: dimensions and measures cannot reference excluded fields. Use `internal:` instead when derived dimensions need the raw column.\n\n```malloy\n// WRONG: dimension references excluded field\nsource: x is conn.table('t')\ninclude { except: raw_date }\nextend { dimension: order_date is raw_date::date } // ERROR! raw_date is gone\n\n// RIGHT: internal fields are still available in extend\nsource: x is conn.table('t')\ninclude { internal: raw_date }\nextend { dimension: order_date is raw_date::date } // Works\n```\n\n## Source Order: Define Joined Tables First\n\nMalloy compiles top-to-bottom. Define lookup/dimension tables before the source that joins them, or use `import` statements in multi-file projects.\n\n## MUST Search Docs Before Using Unfamiliar Patterns\n\nCall `search_malloy_docs` BEFORE first use of any of these. Don't guess the syntax:\n- `pick` expressions\n- Window functions (`calculate`)\n- `percentile` or statistical functions: but see the hard limit above, raw-SQL aggregates (`sql_number` / `is_aggregate` / `percentile_cont!`) do **not** compile as measures in this build; there is no scalar median (`stddev` is the exception and does work as a measure)\n- Time interval functions (`days()`, `seconds()`): only `seconds`/`minutes`/`hours`/`days` exist (see above)\n- Query-based sources (`from()`)\n- `!` operator / `sql_number()`" }, { name: "malloy-gotchas-queries", description: "Common Malloy query and view mistakes. Read BEFORE writing views, queries, or notebooks. Covers chart constraints, aggregate filters, joined field aliasing, method syntax, and time truncation vs extraction.", body: "# Query & View Gotchas\n\n> **Read this before writing views or queries.** These patterns cause most query errors.\n\n## Charts: ONE Aggregate Per View\n\nCharts render only the **first** aggregate. Use exactly one aggregate per `# bar_chart` / `# line_chart` view.\n\n```malloy\n// WRONG: revenue is ignored\n# bar_chart\nview: x is { group_by: status, aggregate: order_count, revenue }\n// RIGHT: single aggregate\n# bar_chart\nview: x is { group_by: status, aggregate: revenue }\n```\n\nFor multiple metrics: nest separate chart views in a `# dashboard`, or use `y=['revenue','cost']` for multi-measure series.\n\n## Joined Fields in `order_by`: Must Alias First\n\n```malloy\n// WRONG: compile error\nview: x is { group_by: races.season_year, aggregate: pts, order_by: races.season_year }\n// RIGHT: alias then reference\nview: x is { group_by: yr is races.season_year, aggregate: pts, order_by: yr }\n```\n\nAny time you `group_by` a joined field, create an alias and use it in `order_by`.\n\n## `having:` vs `where:`: Aggregate Filters\n\n```malloy\n// WRONG: \"Aggregate expressions not allowed in where\"\nview: x is { group_by: cat, aggregate: n is count(), where: n > 10 }\n// RIGHT\nview: x is { group_by: cat, aggregate: n is count(), having: n > 10 }\n```\n\n- `where:` filters rows BEFORE aggregation (dimensions/raw columns)\n- `having:` filters AFTER aggregation (measures)\n\n## Aggregating Joined Fields: Method Syntax\n\n```malloy\n// WRONG: compile error: \"Join path is required for this calculation; use 'inventory_items.item_cost.sum()'\"\nmeasure: cogs is sum(inventory_items.item_cost)\n// RIGHT: method syntax\nmeasure: cogs is inventory_items.item_cost.sum()\n```\n\n`sum`, `avg`, `min`, and `max` over a dotted joined path all produce that compile error; the diagnostic message even tells you the exact fix. Don't worry about catching this in code review; the compiler does it for you.\n\n**Method syntax is for aggregates over a path. Scalar functions never take it.**\n\n```malloy\n// WRONG: \"something is missing before 'round'\"\naggregate: avg_price_r is avg(price).round(2)\naggregate: avg_price_r is price.avg().round(2)\n// WRONG: \"Cannot call function round(number, number) with source\"\naggregate: avg_price_r is avg_price.round(2)\ndimension: rounded is price.round(2)\n// RIGHT: scalar functions are always call form\naggregate: avg_price_r is round(avg(price), 2)\ndimension: rounded is round(price, 2)\n```\n\nTwo separate rules produce those errors:\n\n- **No method call chains onto the result of a function call.** `avg(price).round(2)` and `price.avg().round(2)` are both parse errors. The message names `round` without saying it is unsupported in that position, so it reads like a typo somewhere else. `.floor()` and `.ceil()` fail identically.\n- **Scalar functions have no method form.** `round`, `floor`, and `ceil` are always `round(x, 2)`, never `x.round(2)`, whether `x` is a named measure or a plain column.\n\n`price.avg()` and `inventory_items.item_cost.sum()` are correct because `avg` and `sum` are aggregate functions over a field path, which is exactly what method syntax is for.\n\n**Exception: `count(joined.field)` is correct, not a bug.** `count(joined.field)` is the **canonical Malloy idiom** for distinct-count through a join. Keep it as-is even when nearby `sum`/`avg`/`min`/`max` calls have to use method syntax. The closest method-syntax form `joined.count()` counts *rows* in the joined source (different semantics, differs from the distinct count when the joined field has duplicates within the joined table). The Malloy docs example `joined.count(field)` does NOT compile against current Malloy (error: `Expression illegal inside path.count()`); it only works for double-nested paths like `aircraft.count(aircraft_models.code)`.\n\n## `sum`/`avg` Need a Numeric Field\n\n```malloy\n// WRONG: \"Can't use type string\" - status is a string column\naggregate: avg_status is avg(status)\n// RIGHT: aggregate a numeric field; count a string one\naggregate: avg_price is avg(price), statuses is count(status)\n```\n\nCheck the field's type in the `get_context` result before aggregating it. A name that reads numeric (`order_number`, `zip`, `account_id`) is very often typed string.\n\n## Dotted Paths Must Name a Declared Join\n\n```malloy\n// WRONG: the source declares the join as `carrier`, so this fails with\n// \"'carriers.name' is not a source or join\"\nrun: flights -> { group_by: carriers.name }\n// RIGHT: use the join name the source actually declares\nrun: flights -> { group_by: carrier.nickname }\n```\n\nA dotted path resolves only against a join declared on the source you are running. Confirm both the join name and the field under it in a `get_context` result; do not infer either from a table name or a plural/singular guess.\n\n## `order_by:` Can Only Name an Output Column\n\n```malloy\n// WRONG: \"Unknown field total in output space\" - total is never emitted\nrun: orders -> { group_by: state, aggregate: revenue is sum(total), order_by: total }\n// RIGHT: order by a column the query actually outputs\nrun: orders -> { group_by: state, aggregate: revenue is sum(total), order_by: revenue }\n```\n\n`order_by:` resolves against the query's *output* columns, not the source's fields. To order by something, `group_by` or `aggregate` it first - and if it comes through a join, alias it (see above).\n\n## Chart Annotation Placement\n\nPlace `# bar_chart` / `# line_chart` on the **nested view definition**, not on `nest:` itself. Putting it on `nest:` causes \"not a repeated record\" errors.\n\n## DRY: Define in Source, Reference in View\n\n```malloy\n// WRONG: inline in view\nview: summary is { aggregate: revenue is sum(total) }\n// RIGHT: reference existing measure\nview: summary is { aggregate: revenue }\n```\n\n## Time Truncation vs Extraction\n\n| Syntax | What it does | Returns |\n|--------|--------------|---------|\n| `ts.month` | Truncates to start of month | Timestamp (`@2024-03-01`) |\n| `month(ts)` | Extracts month number | Integer (1-12) |\n| `ts.year` | Truncates to start of year | Timestamp (`@2024-01-01`) |\n| `year(ts)` | Extracts year number | Integer (2024) |\n\nUse `.month` for time series charts (proper date ordering). Use `month()` for cross-year comparison.\n\n**Year integers render with commas.** `year(ts)` displays as `2,018`. Tag with `# number=id` to suppress commas. Same for zip codes, IDs.\n\n## `?` Alternation: Use Commas to Combine Filters\n\nThe `?` operator is Malloy's **alternation operator**: a shorthand for \"match any of these values.\" `party ? 'Democrat' | 'Republican'` means `party = 'Democrat' OR party = 'Republican'`. The `|` separates the alternatives.\n\nWhen combining an alternation filter with other filters, **use a comma**:\n\n```malloy\n// CANONICAL: commas separate independent filter conditions\nwhere: is_us = true, party ? 'Democrat' | 'Republican'\n```\n\n`and` works in some arrangements (when the alternation is the second operand) but produces a confusing `'logical operator' Can't use type string` compile error when the alternation comes first. The comma form is unambiguous in every position, so just use it.\n\n## Query Clauses Are Newline-Separated\n\nDo not use trailing commas between query clauses. Each clause goes on its own line.\n\n```malloy\n// WRONG: trailing comma before limit\nrun: source -> { group_by: status, aggregate: n is count(), limit: 10 }\n// RIGHT: newline-separated\nrun: source -> {\n group_by: status\n aggregate: n is count()\n limit: 10\n}\n```\n\nClauses: `group_by:`, `aggregate:`, `nest:`, `order_by:`, `limit:`, `where:`, `having:`, `select:`, `calculate:`\n\n## Fields Within a Clause: Commas or Newlines, Never Semicolons\n\nSemicolons are not a separator anywhere in Malloy. Multiple fields under one `aggregate:` / `group_by:` are separated by commas (inline) or newlines (one per line); a `;` fails with `no viable alternative at input '<next-field>'` pointing at the field right after it.\n\n```malloy\n// WRONG: semicolons between fields\nrun: schools -> { aggregate: total is count(); charters is count() { where: is_charter } }\n// RIGHT: commas inline...\nrun: schools -> { aggregate: total is count(), charters is count() { where: is_charter } }\n// ...or newlines\nrun: schools -> {\n aggregate:\n total is count()\n charters is count() { where: is_charter }\n}\n```" }, { name: "malloy-gotchas-rendering", description: "Common Malloy renderer annotation mistakes. Read BEFORE adding chart annotations, formatting tags, or building dashboards. Covers tag syntax, scale rules, sparkline setup, and big_value patterns.", body: `# Rendering Gotchas
|
|
287541
288401
|
|
|
287542
288402
|
> **Read this before adding renderer annotations.** These patterns cause most rendering issues.
|
|
287543
288403
|
|
|
@@ -288749,7 +289609,7 @@ source: orders is duckdb.table('orders.parquet') extend {
|
|
|
288749
289609
|
\`\`\`
|
|
288750
289610
|
|
|
288751
289611
|
- **Source-level** \`#(authorize) "<expr>"\` gates that one source. **File-level** \`##(authorize) "<expr>"\` applies to every source in the file. Multiple gates combine as an OR, access is granted if any one is true, so a permissive file-level gate is a **model-wide override**, not an added restriction.
|
|
288752
|
-
- **
|
|
289612
|
+
- **Entry point only: not joined, but inherited through \`extend\`.** The gate applies to the source a query enters through. A gate on a source reached only via \`join_*\` **never fires**, at any depth, so anything ungated that joins a locked base hands the base's rows to every caller. A source that \`extend\`s a locked base and declares no gate of its own **does** carry the base's gate; declaring its own replaces it. Pair a locked base (\`#(authorize) "false"\`) with curated extension sources, using access modifiers (\`include { public: …, private: * }\`), so an extension re-exposes only a curated column surface, and keep sensitive sources out of ungated joins.
|
|
288753
289613
|
- The expression may reference only givens and literals, never a column of the gated source; the check runs against a synthetic probe row, not your data.
|
|
288754
289614
|
|
|
288755
289615
|
> **Trust caveat.** Givens are **caller-asserted**, anyone who can reach the query API can claim a favorable given, e.g. \`{"ROLE":"admin"}\`. \`#(authorize)\` is only a real boundary when it sits behind a trusted tier that sets givens from its own verified context, never directly from an untrusted caller. It is not, on its own, end-user authentication.
|
|
@@ -289056,7 +289916,7 @@ source: first_touch is conn.table('events') -> {
|
|
|
289056
289916
|
- **Cannot redefine** columns from query-based sources, they already exist as fields. Add only NEW derived dimensions in \`extend {}\`.
|
|
289057
289917
|
- To add \`#(doc)\` tags to existing query columns, use \`include {}\` between the query and extend.
|
|
289058
289918
|
- **Use the RAW TABLE** in query-based sources, not a modeled source, when the modeled source would create a circular dependency.
|
|
289059
|
-
- **Never use \`conn.sql()\`** when Malloy has a native pattern. \`conn.sql()\` is a last resort for UNNEST, PIVOT, or dialect-specific functions only. Call \`search_malloy_docs\` first.` }, { name: "malloy-modeling", description: "Build semantic models with Malloy for the Malloy Publisher. Read this skill whenever the user asks about modeling data or specifically mentions Malloy.", body: "# STOP - READ BEFORE WRITING ANY MALLOY CODE\n\n> **AI AGENTS: You MUST review this file before writing Malloy code.** Cross-skill references below use logical `skill:` names; load the referenced skill before acting. Before writing code, also read the gotcha skills: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering`.\n\n## Pre-Flight Checklist\n\n1. **Discover first**: ground yourself with `malloy_getContext` before writing ANY code. It returns the package's sources, views, and fields (with their docs), so you build on what actually exists. Never guess field names.\n2. **Search docs proactively**: call `malloy_searchDocs` BEFORE writing unfamiliar patterns (window functions, query-based sources, pipelines). Don't guess. Malloy syntax is specific and SQL intuition is often wrong.\n3. **Use `skill:malloy-patterns`** to discover available doc topics (YoY, cohorts, rendering, window functions).\n4. **Check diagnostics** after writing: fix the FIRST error first, errors cascade.\n5. **Read the gotcha skills**: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering` prevent the most common mistakes.\n\n**Quick syntax reminders:**\n1. **Backtick reserved words:** `` `Date` ``, `` `Hour` ``, `` `Timestamp` ``, `` `Type` ``, `` `number` ``, `` `source` ``\n2. **Use `having:` for aggregate filters**: not `where:` on measures\n3. **Alias joined fields in `group_by`** if using them in `order_by`\n4. **Use `count(x)` not `count(distinct x)`**: Malloy's count() is always distinct\n5. **One tag per line**: `# label=\"Revenue\"` and `# currency` on separate lines\n6. **No fixed scale on measures**: use `# currency` not `# currency=usd0m`\n7. **Cast strings for aggregates:** `avg(score::number)` not `avg(score)`\n8. **Boolean columns:** use `= true` not `= 'true'` (no quotes!)\n9. **Read data files in place:** `.csv`, `.parquet`, `.json`, `.ndjson`, and `.xlsx` all work as-is through `duckdb.table('data/file.ext')`. Never convert a file to another format first, and never read one with python or jq to \"have a look\" first: query it. For `.xlsx`, check the row count before trusting it: a workbook with a title row or a blank spacer reads short and reports no error. (Per-format quirks: `skill:malloy-gotchas-modeling`)\n\n## Planning and `modeling-notes.md`\n\nIf the IDE has a native plan mode, use it for the high-level approach: do data exploration during planning, then present a concrete plan for user approval before writing any files. Once approved, you can write a `modeling-notes.md` during execution to record decisions (scope, sources, key choices, prior art, gaps). This file persists alongside the model. Otherwise, keep the proposal and decisions in the conversation; Publisher has no separate workspace document store to write them to.\n\n## 8-Step Modeling Workflow\n\nThe agent orchestrates all steps. Steps marked **(user)** pause for input. Each step has a dedicated skill with full instructions; load the relevant skill when needed.\n\n**A field is not complete until it has its definition, `#(doc)` tag, and rendering tags.** Documentation is part of defining a field, not a separate activity. Read `skill:malloy-document` for full documentation standards (doc string writing, tag ordering).\n\n```\nDISCOVER → SCOPE → SOURCES → DEFINITIONS → BUILD BASE → BUILD JOINED → REVIEW → CURATE\n (silent) (user) (user) (user) (agent) (agent) (user) (user)\n```\n\n| Step | Skill | What Happens |\n|------|-------|-------------|\n| 1. Discover | `skill:malloy-discover` | Read the model and data; scan sources, fields, distributions; detect prior art |\n| 2. Propose Scope | `skill:malloy-scope` | Present findings, user selects focus |\n| 3. Propose Sources | `skill:malloy-define` | Propose source plan, user confirms architecture |\n| 4. Propose Definitions | `skill:malloy-define` | Propose fields per base source, user confirms logic |\n| 5. Build Base Sources | `skill:malloy-model` | Write fully documented base source files (one per table), check diagnostics. Read `skill:malloy-document` for doc standards. |\n| 6. Build Joined Sources | `skill:malloy-model` | Write fully documented joined source files, validate. Read `skill:malloy-document` for doc standards. |\n| 7. Review | (none) | Present structure, assumptions, and doc coverage; user confirms |\n| 8. Curate | `skill:malloy-model` | Propose access controls, user approves: optional, ask user |\n\nPublishing is out of scope for open-source v1. Self-hosters move a finished model into a served package via git and the host's publish path; see `skill:malloy-publish` for the local-to-served handoff.\n\n**Two paths to a model: both produce the same fully documented result:**\n- **Schema-first:** \"Model my data\" → 8-step workflow above using the relevant skills\n- **Analysis-first:** \"Explore this data\" → `skill:malloy-analyze` → formalize via `skill:malloy-model` (`reference/analysis-to-model.md`)\n\nAfter analysis completes, **always recommend formalizing into a model.**\n\n## Agent Behavior\n\n**Research before asking.** Present proposals with evidence. Never ask open-ended questions: propose with data and let the user confirm.\n\n**Use business language.** Say \"I simplified the column name\" not \"reserved word replaced.\" Don't expose Malloy internals unless the user asks.\n\n**Describe what you're doing, not which step you're on.** The user doesn't have the skill files open. Say \"I'll propose which tables to include and how they relate\" not \"Steps 3 and 4.\" Say \"Now I'll write the source files\" not \"Moving to Step 5.\" Explain the purpose of each phase in plain language before doing it.\n\n**Present choices as A/B/C.** When asking the user to choose, use lettered options with one-line descriptions. Mark your recommendation.\n\n**Complete all workflow steps.** Once modeling begins, complete through review. A field without documentation is not finished. If you lose track, re-read the model and your notes. Suggest notebooks at the end.\n\n## Route by Intent\n\n| User says... | Route to |\n|-------------|----------|\n| \"Model my data\", \"create a model\" | 8-step workflow (`skill:malloy-discover`) |\n| \"Model from LookML\" | 8-step with prior art via `skill:malloy-lookml-review` |\n| \"Explore this data\", \"what's interesting?\", \"show me the top X\" | `skill:malloy-analyze` (EDA) |\n| \"Build a dashboard\", \"create views\" on existing model | `skill:malloy-analyze` (views), plus `skill:malloy-charts` or `skill:malloy-notebooks` as needed |\n| \"Build a model but not sure what metrics\" | `skill:malloy-analyze` first, then formalize via `skill:malloy-model` |\n\n**If the user's first message is a data question** (not \"build me a model\"), route to `skill:malloy-analyze`. After analysis completes, **always recommend formalizing via the analysis-to-model workflow** (`skill:malloy-model` → `reference/analysis-to-model.md`).\n\n## Additional Support Skills\n\nThese supplemental skills may also be loaded as needed:\n\n- **`skill:malloy`**: Index of Malloy skills and routing guide\n- **`skill:malloy-debug`**: Fix compile errors and interpret diagnostics\n\n## Publisher MCP Tools\n\nEnsure the Publisher MCP tools are configured before modeling.\n\n| Tool | Purpose |\n|------|---------|\n| `malloy_getContext` | Ground yourself in a package: its sources, views, and fields |\n| `malloy_executeQuery` | Run ad-hoc queries for validation |\n| `malloy_compile` | Compile-check a change and get diagnostics back without running a query |\n| `malloy_reloadPackage` | Recompile a package from disk so a saved edit becomes queryable by name |\n| `malloy_searchDocs` | Search Malloy docs (call BEFORE unfamiliar patterns) |\n\nNever guess field names. Ground yourself with `malloy_getContext` to see the sources and fields a package defines.\n\n### The edit-and-run loop\n\nPublisher 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 you reload the package. The loop is:\n\n1. **Validate** the change with `malloy_compile`, which reads the model fresh from disk and returns diagnostics without running anything.\n2. **Save** it to the package's model file.\n3. **Reload** with `malloy_reloadPackage`.\n4. **Run** the new view with `malloy_executeQuery`.\n\nA reload that fails to compile is safe: your files are left alone and the previously compiled model keeps serving, with the compile errors returned to you. Compile first anyway for faster feedback. Keep the source of truth outside `publisher_data/`, which is not version-controlled and is wiped by a `--init` restart. If these two tools are missing, the Publisher you are connected to predates them; fall back to validating with a throwaway `malloy_executeQuery`.\n\n## SQL-to-Malloy Quick Reference\n\n| SQL | Malloy |\n|-----|--------|\n| `COUNT(*)` | `count()` |\n| `COUNT(DISTINCT x)` | `count(x)` |\n| `NOW()` | `now` |\n| `CASE WHEN...END` | `pick...when...else` |\n| `col IN ('a','b')` | `col ? 'a' \\| 'b'` |\n| `COALESCE(a,b)` | `a ?? b` |\n| `CAST(x AS type)` | `x::type` |\n| `DATEDIFF(day, a, b)` | `days(a to b)` |\n| `CONCAT(a, b)` or `a \\|\\| b` | `concat(a, b)` |\n| `TIMESTAMP_DIFF(a, b, SECOND)` | `seconds(b to a)` |\n\n## Critical Rules\n\n1. **All keywords require colons**: `source:`, `dimension:`, `measure:`, `view:`\n2. **Use `is` not `as`**: `dimension: name is expression`\n3. **Arrow operator required**: `run: source -> { operations }`\n4. **Specify join type**: `join_one:`, `join_many:`, `join_cross:`\n5. **Safe division**: `revenue / nullif(count, 0)`\n6. **Group definitions under one keyword**: `measure:` then indent fields beneath\n\n## Common Anti-Patterns\n\n```\nWRONG: source flights is ... RIGHT: source: flights is ...\nWRONG: dimension: x as y RIGHT: dimension: y is x\nWRONG: count(*) RIGHT: count()\nWRONG: count(distinct x) RIGHT: count(x)\nWRONG: revenue / order_count RIGHT: revenue / nullif(order_count, 0)\nWRONG: run: src { ... } RIGHT: run: src -> { ... }\n```\n\n## Reserved Words: Scan Schema First\n\n**Malloy has many reserved words. When in doubt, backtick it.** Most likely to appear as column names:\n\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n- `string`, `boolean`, `true`, `false`: backtick any column with these exact names\n\n## Gotcha Skills: Read Before Writing Code\n\nThe following skills contain detailed WRONG/RIGHT patterns that prevent the most common Malloy errors. **Read them before writing code:**\n\n- **`skill:malloy-gotchas-modeling`**: Reserved words, NULL checks, date functions, type casts, rename pitfalls, query-based source gotchas, `conn.sql()` anti-pattern\n- **`skill:malloy-gotchas-queries`**: Chart constraints, aggregate filters, joined field aliasing, time truncation vs extraction\n- **`skill:malloy-gotchas-rendering`**: Tag syntax, scale rules, sparkline setup, big_value patterns" }, { name: "malloy-notebook-chat", description: "Steps to follow when the chat is bound to a notebook or saved report. The notebook's cells are the agent's primary context, answer from it, run its queries, and only reach for get_context when the user asks about something outside it.", body: `# Notebook/Report Chat Workflow
|
|
289919
|
+
- **Never use \`conn.sql()\`** when Malloy has a native pattern. \`conn.sql()\` is a last resort for UNNEST, PIVOT, or dialect-specific functions only. Call \`search_malloy_docs\` first.` }, { name: "malloy-modeling", description: "Build semantic models with Malloy for the Malloy Publisher. Read this skill whenever the user asks about modeling data or specifically mentions Malloy.", body: "# STOP - READ BEFORE WRITING ANY MALLOY CODE\n\n> **AI AGENTS: You MUST review this file before writing Malloy code.** Cross-skill references below use logical `skill:` names; load the referenced skill before acting. Before writing code, also read the gotcha skills: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering`.\n\n## Pre-Flight Checklist\n\n1. **Discover first**: ground yourself before writing ANY code, with the tool that matches what you are modelling.\n - Modelling data **already in a package**: `malloy_getContext` returns that package's sources, views, and fields (with their docs).\n - Modelling **a database with no package yet**: `malloy_getContext` has nothing to return, so use `malloy_searchDatabaseSchema` instead. It walks the connection's schemas and tables, ranks them against a plain-English description, and gives you each table's columns plus the `source:` line to start from. Take those names verbatim into step 5.\n Never guess field names either way.\n2. **Search docs proactively**: call `malloy_searchDocs` BEFORE writing unfamiliar patterns (window functions, query-based sources, pipelines). Don't guess. Malloy syntax is specific and SQL intuition is often wrong.\n3. **Use `skill:malloy-patterns`** to discover available doc topics (YoY, cohorts, rendering, window functions).\n4. **Check diagnostics** after writing: fix the FIRST error first, errors cascade.\n5. **Read the gotcha skills**: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering` prevent the most common mistakes.\n\n**Quick syntax reminders:**\n1. **Backtick reserved words:** `` `Date` ``, `` `Hour` ``, `` `Timestamp` ``, `` `Type` ``, `` `number` ``, `` `source` ``\n2. **Use `having:` for aggregate filters**: not `where:` on measures\n3. **Alias joined fields in `group_by`** if using them in `order_by`\n4. **Use `count(x)` not `count(distinct x)`**: Malloy's count() is always distinct\n5. **One tag per line**: `# label=\"Revenue\"` and `# currency` on separate lines\n6. **No fixed scale on measures**: use `# currency` not `# currency=usd0m`\n7. **Cast strings for aggregates:** `avg(score::number)` not `avg(score)`\n8. **Boolean columns:** use `= true` not `= 'true'` (no quotes!)\n9. **Read data files in place:** `.csv`, `.parquet`, `.json`, `.ndjson`, and `.xlsx` all work as-is through `duckdb.table('data/file.ext')`. Never convert a file to another format first, and never read one with python or jq to \"have a look\" first: query it. For `.xlsx`, check the row count before trusting it: a workbook with a title row or a blank spacer reads short and reports no error. (Per-format quirks: `skill:malloy-gotchas-modeling`)\n\n## Planning and `modeling-notes.md`\n\nIf the IDE has a native plan mode, use it for the high-level approach: do data exploration during planning, then present a concrete plan for user approval before writing any files. Once approved, you can write a `modeling-notes.md` during execution to record decisions (scope, sources, key choices, prior art, gaps). This file persists alongside the model. Otherwise, keep the proposal and decisions in the conversation; Publisher has no separate workspace document store to write them to.\n\n## 8-Step Modeling Workflow\n\nThe agent orchestrates all steps. Steps marked **(user)** pause for input. Each step has a dedicated skill with full instructions; load the relevant skill when needed.\n\n**A field is not complete until it has its definition, `#(doc)` tag, and rendering tags.** Documentation is part of defining a field, not a separate activity. Read `skill:malloy-document` for full documentation standards (doc string writing, tag ordering).\n\n```\nDISCOVER → SCOPE → SOURCES → DEFINITIONS → BUILD BASE → BUILD JOINED → REVIEW → CURATE\n (silent) (user) (user) (user) (agent) (agent) (user) (user)\n```\n\n| Step | Skill | What Happens |\n|------|-------|-------------|\n| 1. Discover | `skill:malloy-discover` | Read the model and data; scan sources, fields, distributions; detect prior art. With no package yet, start from `malloy_searchDatabaseSchema` to find the tables in the connection |\n| 2. Propose Scope | `skill:malloy-scope` | Present findings, user selects focus |\n| 3. Propose Sources | `skill:malloy-define` | Propose source plan, user confirms architecture |\n| 4. Propose Definitions | `skill:malloy-define` | Propose fields per base source, user confirms logic |\n| 5. Build Base Sources | `skill:malloy-model` | Write fully documented base source files (one per table), check diagnostics. Read `skill:malloy-document` for doc standards. |\n| 6. Build Joined Sources | `skill:malloy-model` | Write fully documented joined source files, validate. Read `skill:malloy-document` for doc standards. |\n| 7. Review | (none) | Present structure, assumptions, and doc coverage; user confirms |\n| 8. Curate | `skill:malloy-model` | Propose access controls, user approves: optional, ask user |\n\nPublishing is out of scope for open-source v1. Self-hosters move a finished model into a served package via git and the host's publish path; see `skill:malloy-publish` for the local-to-served handoff.\n\n**Two paths to a model: both produce the same fully documented result:**\n- **Schema-first:** \"Model my data\" → 8-step workflow above using the relevant skills\n- **Analysis-first:** \"Explore this data\" → `skill:malloy-analyze` → formalize via `skill:malloy-model` (`reference/analysis-to-model.md`)\n\nAfter analysis completes, **always recommend formalizing into a model.**\n\n## Agent Behavior\n\n**Research before asking.** Present proposals with evidence. Never ask open-ended questions: propose with data and let the user confirm.\n\n**Use business language.** Say \"I simplified the column name\" not \"reserved word replaced.\" Don't expose Malloy internals unless the user asks.\n\n**Describe what you're doing, not which step you're on.** The user doesn't have the skill files open. Say \"I'll propose which tables to include and how they relate\" not \"Steps 3 and 4.\" Say \"Now I'll write the source files\" not \"Moving to Step 5.\" Explain the purpose of each phase in plain language before doing it.\n\n**Present choices as A/B/C.** When asking the user to choose, use lettered options with one-line descriptions. Mark your recommendation.\n\n**Complete all workflow steps.** Once modeling begins, complete through review. A field without documentation is not finished. If you lose track, re-read the model and your notes. Suggest notebooks at the end.\n\n## Route by Intent\n\n| User says... | Route to |\n|-------------|----------|\n| \"Model my data\", \"create a model\" | 8-step workflow (`skill:malloy-discover`) |\n| \"Model from LookML\" | 8-step with prior art via `skill:malloy-lookml-review` |\n| \"Explore this data\", \"what's interesting?\", \"show me the top X\" | `skill:malloy-analyze` (EDA) |\n| \"Build a dashboard\", \"create views\" on existing model | `skill:malloy-analyze` (views), plus `skill:malloy-charts` or `skill:malloy-notebooks` as needed |\n| \"Build a model but not sure what metrics\" | `skill:malloy-analyze` first, then formalize via `skill:malloy-model` |\n\n**If the user's first message is a data question** (not \"build me a model\"), route to `skill:malloy-analyze`. After analysis completes, **always recommend formalizing via the analysis-to-model workflow** (`skill:malloy-model` → `reference/analysis-to-model.md`).\n\n## Additional Support Skills\n\nThese supplemental skills may also be loaded as needed:\n\n- **`skill:malloy`**: Index of Malloy skills and routing guide\n- **`skill:malloy-debug`**: Fix compile errors and interpret diagnostics\n\n## Publisher MCP Tools\n\nEnsure the Publisher MCP tools are configured before modeling.\n\n| Tool | Purpose |\n|------|---------|\n| `malloy_getContext` | Ground yourself in a package: its sources, views, and fields |\n| `malloy_executeQuery` | Run ad-hoc queries for validation |\n| `malloy_compile` | Compile-check a change and get diagnostics back without running a query |\n| `malloy_reloadPackage` | Recompile a package from disk so a saved edit becomes queryable by name |\n| `malloy_searchDocs` | Search Malloy docs (call BEFORE unfamiliar patterns) |\n| `malloy_searchDatabaseSchema` | Find the tables in a database connection by plain-English description, when modelling data that is not in a package yet. Returns each table's columns and the `source:` line to start from. Names and types only: no row value is returned |\n\nNever guess field names. Ground yourself with `malloy_getContext` to see the sources and fields a package defines.\n\n### The edit-and-run loop\n\nPublisher 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 you reload the package. The loop is:\n\n1. **Validate** the change with `malloy_compile`, which reads the model fresh from disk and returns diagnostics without running anything.\n2. **Save** it to the package's model file.\n3. **Reload** with `malloy_reloadPackage`.\n4. **Run** the new view with `malloy_executeQuery`.\n\nA reload that fails to compile is safe: your files are left alone and the previously compiled model keeps serving, with the compile errors returned to you. Compile first anyway for faster feedback. Keep the source of truth outside `publisher_data/`, which is not version-controlled and is wiped by a `--init` restart. If these two tools are missing, the Publisher you are connected to predates them; fall back to validating with a throwaway `malloy_executeQuery`.\n\n## SQL-to-Malloy Quick Reference\n\n| SQL | Malloy |\n|-----|--------|\n| `COUNT(*)` | `count()` |\n| `COUNT(DISTINCT x)` | `count(x)` |\n| `NOW()` | `now` |\n| `CASE WHEN...END` | `pick...when...else` |\n| `col IN ('a','b')` | `col ? 'a' \\| 'b'` |\n| `COALESCE(a,b)` | `a ?? b` |\n| `CAST(x AS type)` | `x::type` |\n| `DATEDIFF(day, a, b)` | `days(a to b)` |\n| `CONCAT(a, b)` or `a \\|\\| b` | `concat(a, b)` |\n| `TIMESTAMP_DIFF(a, b, SECOND)` | `seconds(b to a)` |\n\n## Critical Rules\n\n1. **All keywords require colons**: `source:`, `dimension:`, `measure:`, `view:`\n2. **Use `is` not `as`**: `dimension: name is expression`\n3. **Arrow operator required**: `run: source -> { operations }`\n4. **Specify join type**: `join_one:`, `join_many:`, `join_cross:`\n5. **Safe division**: `revenue / nullif(count, 0)`\n6. **Group definitions under one keyword**: `measure:` then indent fields beneath\n\n## Common Anti-Patterns\n\n```\nWRONG: source flights is ... RIGHT: source: flights is ...\nWRONG: dimension: x as y RIGHT: dimension: y is x\nWRONG: count(*) RIGHT: count()\nWRONG: count(distinct x) RIGHT: count(x)\nWRONG: revenue / order_count RIGHT: revenue / nullif(order_count, 0)\nWRONG: run: src { ... } RIGHT: run: src -> { ... }\n```\n\n## Reserved Words: Scan Schema First\n\n**Malloy has many reserved words. When in doubt, backtick it.** Most likely to appear as column names:\n\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n- `string`, `boolean`, `true`, `false`: backtick any column with these exact names\n\n## Gotcha Skills: Read Before Writing Code\n\nThe following skills contain detailed WRONG/RIGHT patterns that prevent the most common Malloy errors. **Read them before writing code:**\n\n- **`skill:malloy-gotchas-modeling`**: Reserved words, NULL checks, date functions, type casts, rename pitfalls, query-based source gotchas, `conn.sql()` anti-pattern\n- **`skill:malloy-gotchas-queries`**: Chart constraints, aggregate filters, joined field aliasing, time truncation vs extraction\n- **`skill:malloy-gotchas-rendering`**: Tag syntax, scale rules, sparkline setup, big_value patterns" }, { name: "malloy-notebook-chat", description: "Steps to follow when the chat is bound to a notebook or saved report. The notebook's cells are the agent's primary context, answer from it, run its queries, and only reach for get_context when the user asks about something outside it.", body: `# Notebook/Report Chat Workflow
|
|
289060
289920
|
|
|
289061
289921
|
Steps to follow when the user asks a question:
|
|
289062
289922
|
|
|
@@ -290073,6 +290933,8 @@ var MCP_INSTRUCTIONS = `Malloy Publisher serves one or more Malloy semantic-mode
|
|
|
290073
290933
|
|
|
290074
290934
|
Start with malloy_getContext. Call it with no arguments to list the environments (each with its packages), with an environment to list its packages, with a package to list its sources, and with a package plus a plain-English question to get the sources, views, and fields most relevant to it. Use the names it returns verbatim and do not guess. Then run a query with malloy_executeQuery. To change a model: validate the edit with malloy_compile, save it, then call malloy_reloadPackage so the new sources and views become queryable by name without restarting the server. ${RELOAD_FAILURE_IS_SAFE}
|
|
290075
290935
|
|
|
290936
|
+
To build a model from a database rather than from an existing package, start with malloy_searchDatabaseSchema: it lists the connections, their schemas, and their tables, and ranks those tables against a plain-English description of the data you want. Each table it returns carries the source line to start from. It returns names and types only: no row value is returned.
|
|
290937
|
+
|
|
290076
290938
|
Task-specific guidance is served as prompts you can fetch by name: malloy-getting-started to begin, malloy-modeling to build or change a model, malloy-analysis to explore and answer questions, and malloy-review to check correctness.
|
|
290077
290939
|
|
|
290078
290940
|
Results and any charts render in the Publisher web UI on the REST port (4000 by default).`;
|
|
@@ -290087,6 +290949,7 @@ function initializeMcpServer(environmentStore) {
|
|
|
290087
290949
|
registerDocsSearchTool(mcpServer, environmentStore);
|
|
290088
290950
|
registerCompileTool(mcpServer, environmentStore);
|
|
290089
290951
|
registerReloadPackageTool(mcpServer, environmentStore);
|
|
290952
|
+
registerSearchDatabaseSchemaTool(mcpServer, environmentStore);
|
|
290090
290953
|
for (const skill of AGENT_SKILLS) {
|
|
290091
290954
|
mcpServer.prompt(skill.name, skill.description, () => ({
|
|
290092
290955
|
messages: [
|
|
@@ -290105,7 +290968,7 @@ function initializeMcpServer(environmentStore) {
|
|
|
290105
290968
|
}
|
|
290106
290969
|
|
|
290107
290970
|
// src/mcp_config.ts
|
|
290108
|
-
import * as
|
|
290971
|
+
import * as fs10 from "fs";
|
|
290109
290972
|
import * as os3 from "os";
|
|
290110
290973
|
import * as path11 from "path";
|
|
290111
290974
|
init_logger();
|
|
@@ -290134,7 +290997,7 @@ function mcpEndpoint(host, port) {
|
|
|
290134
290997
|
function findGitWorkTreeRoot(dir) {
|
|
290135
290998
|
let current = path11.resolve(dir);
|
|
290136
290999
|
for (;; ) {
|
|
290137
|
-
if (
|
|
291000
|
+
if (fs10.existsSync(path11.join(current, ".git")))
|
|
290138
291001
|
return current;
|
|
290139
291002
|
const parent = path11.dirname(current);
|
|
290140
291003
|
if (parent === current)
|
|
@@ -290153,7 +291016,7 @@ function ensureMcpConfig(options) {
|
|
|
290153
291016
|
try {
|
|
290154
291017
|
return {
|
|
290155
291018
|
path: file,
|
|
290156
|
-
isSymlink:
|
|
291019
|
+
isSymlink: fs10.lstatSync(file).isSymbolicLink()
|
|
290157
291020
|
};
|
|
290158
291021
|
} catch {
|
|
290159
291022
|
return;
|
|
@@ -290172,7 +291035,7 @@ function ensureMcpConfig(options) {
|
|
|
290172
291035
|
}
|
|
290173
291036
|
const realish = (p) => {
|
|
290174
291037
|
try {
|
|
290175
|
-
return
|
|
291038
|
+
return fs10.realpathSync(p);
|
|
290176
291039
|
} catch {
|
|
290177
291040
|
return path11.resolve(p);
|
|
290178
291041
|
}
|
|
@@ -290190,7 +291053,7 @@ function ensureMcpConfig(options) {
|
|
|
290190
291053
|
action: "skipped-git",
|
|
290191
291054
|
dir,
|
|
290192
291055
|
gitRoot,
|
|
290193
|
-
rootConfig:
|
|
291056
|
+
rootConfig: fs10.existsSync(rootCandidate) ? rootCandidate : undefined,
|
|
290194
291057
|
endpoint,
|
|
290195
291058
|
staleConfig
|
|
290196
291059
|
};
|
|
@@ -290199,7 +291062,7 @@ function ensureMcpConfig(options) {
|
|
|
290199
291062
|
return { action: "exists", file, endpoint };
|
|
290200
291063
|
const body = JSON.stringify({ mcpServers: { malloy: malloyServer(endpoint) } }, null, 2) + `
|
|
290201
291064
|
`;
|
|
290202
|
-
|
|
291065
|
+
fs10.writeFileSync(file, body, { encoding: "utf8", flag: "wx" });
|
|
290203
291066
|
return { action: "created", file };
|
|
290204
291067
|
} catch (error) {
|
|
290205
291068
|
const code = error?.code;
|
|
@@ -292377,7 +293240,82 @@ function listSanitizerDrops(raw, sanitized) {
|
|
|
292377
293240
|
return drops;
|
|
292378
293241
|
}
|
|
292379
293242
|
|
|
293243
|
+
// src/spa_fallback.ts
|
|
293244
|
+
var ASSET_EXTENSIONS = new Set([
|
|
293245
|
+
"avif",
|
|
293246
|
+
"css",
|
|
293247
|
+
"csv",
|
|
293248
|
+
"gif",
|
|
293249
|
+
"htm",
|
|
293250
|
+
"html",
|
|
293251
|
+
"ico",
|
|
293252
|
+
"jpeg",
|
|
293253
|
+
"jpg",
|
|
293254
|
+
"js",
|
|
293255
|
+
"json",
|
|
293256
|
+
"map",
|
|
293257
|
+
"mjs",
|
|
293258
|
+
"parquet",
|
|
293259
|
+
"pdf",
|
|
293260
|
+
"png",
|
|
293261
|
+
"svg",
|
|
293262
|
+
"ttf",
|
|
293263
|
+
"txt",
|
|
293264
|
+
"webp",
|
|
293265
|
+
"woff",
|
|
293266
|
+
"woff2",
|
|
293267
|
+
"xlsx"
|
|
293268
|
+
]);
|
|
293269
|
+
var SPA_OWNED_SEGMENTS = new Set(["pages", "workbook"]);
|
|
293270
|
+
function extensionOf(segment) {
|
|
293271
|
+
const dot = segment.lastIndexOf(".");
|
|
293272
|
+
if (dot <= 0 || dot === segment.length - 1)
|
|
293273
|
+
return "";
|
|
293274
|
+
return segment.slice(dot + 1).toLowerCase();
|
|
293275
|
+
}
|
|
293276
|
+
function classifySpaFallback(requestPath, apiPrefix) {
|
|
293277
|
+
if (requestPath === apiPrefix || requestPath.startsWith(`${apiPrefix}/`)) {
|
|
293278
|
+
return { kind: "apiNotFound", path: requestPath };
|
|
293279
|
+
}
|
|
293280
|
+
const segments = requestPath.split("/").filter((s) => s.length > 0);
|
|
293281
|
+
const last2 = segments[segments.length - 1] ?? "";
|
|
293282
|
+
if (!ASSET_EXTENSIONS.has(extensionOf(last2)))
|
|
293283
|
+
return { kind: "spa" };
|
|
293284
|
+
if (segments.length >= 3 && SPA_OWNED_SEGMENTS.has(segments[2])) {
|
|
293285
|
+
return { kind: "spa" };
|
|
293286
|
+
}
|
|
293287
|
+
if (segments.length >= 3) {
|
|
293288
|
+
const alreadyStaticForm = segments[0] === "environments" && segments[2] === "packages";
|
|
293289
|
+
const traverses = segments.some((segment) => {
|
|
293290
|
+
let decoded;
|
|
293291
|
+
try {
|
|
293292
|
+
decoded = decodeURIComponent(segment);
|
|
293293
|
+
} catch {
|
|
293294
|
+
return true;
|
|
293295
|
+
}
|
|
293296
|
+
return decoded === "." || decoded === ".." || decoded.includes("\\");
|
|
293297
|
+
});
|
|
293298
|
+
if (!alreadyStaticForm && !traverses) {
|
|
293299
|
+
const [environmentName, packageName, ...rest] = segments;
|
|
293300
|
+
if (rest[0] === "public" && rest.length > 1)
|
|
293301
|
+
rest.shift();
|
|
293302
|
+
return {
|
|
293303
|
+
kind: "redirect",
|
|
293304
|
+
location: `/environments/${environmentName}/packages/${packageName}/${rest.join("/")}`,
|
|
293305
|
+
environmentName,
|
|
293306
|
+
packageName
|
|
293307
|
+
};
|
|
293308
|
+
}
|
|
293309
|
+
}
|
|
293310
|
+
return {
|
|
293311
|
+
kind: "assetNotFound",
|
|
293312
|
+
path: requestPath,
|
|
293313
|
+
appRouteCandidate: segments.length === 1 ? { environmentName: segments[0] } : segments.length === 2 ? { environmentName: segments[0], packageName: segments[1] } : null
|
|
293314
|
+
};
|
|
293315
|
+
}
|
|
293316
|
+
|
|
292380
293317
|
// src/server.ts
|
|
293318
|
+
assertSupportedNodeVersion();
|
|
292381
293319
|
function parseArgs() {
|
|
292382
293320
|
const args = process.argv.slice(2);
|
|
292383
293321
|
let sawServerRoot = false;
|
|
@@ -292567,6 +293505,17 @@ app.get("/sdk/publisher.js", (_req, res) => {
|
|
|
292567
293505
|
}
|
|
292568
293506
|
});
|
|
292569
293507
|
});
|
|
293508
|
+
var PACKAGE_FILE_NOT_FOUND_HTML = `<!doctype html><meta charset="utf-8">
|
|
293509
|
+
<title>Not found</title>
|
|
293510
|
+
<style>body{font:14px/1.4 -apple-system,system-ui,sans-serif;margin:40px;max-width:720px;color:#222}code{background:#f4f4f5;padding:1px 4px;border-radius:3px}</style>
|
|
293511
|
+
<h1>Not found</h1>
|
|
293512
|
+
<p>This package does not serve that file. Only files inside the package's
|
|
293513
|
+
<code>public/</code> directory are web-served, at
|
|
293514
|
+
<code>/environments/<env>/packages/<pkg>/<file></code>, where <code><file></code> is
|
|
293515
|
+
relative to <code>public/</code> and does not include it.</p>
|
|
293516
|
+
<p>Models and notebooks are not served here; they open in the web UI at
|
|
293517
|
+
<code>/<env>/<pkg>/<file>.malloy</code>. <a href="/">Publisher home</a> lists what
|
|
293518
|
+
this server has.</p>`;
|
|
292570
293519
|
async function serveFromPackage(req, res) {
|
|
292571
293520
|
const subPathRaw = req.params["0"] ?? "";
|
|
292572
293521
|
try {
|
|
@@ -292586,7 +293535,7 @@ async function serveFromPackage(req, res) {
|
|
|
292586
293535
|
realFullPath = await fsp.realpath(fullPath);
|
|
292587
293536
|
} catch {
|
|
292588
293537
|
if (!res.headersSent) {
|
|
292589
|
-
res.status(404).
|
|
293538
|
+
res.status(404).type("text/html").send(PACKAGE_FILE_NOT_FOUND_HTML);
|
|
292590
293539
|
}
|
|
292591
293540
|
return;
|
|
292592
293541
|
}
|
|
@@ -292605,7 +293554,7 @@ async function serveFromPackage(req, res) {
|
|
|
292605
293554
|
res.sendFile(realFullPath, (err) => {
|
|
292606
293555
|
if (err) {
|
|
292607
293556
|
if (!res.headersSent) {
|
|
292608
|
-
res.status(404).
|
|
293557
|
+
res.status(404).type("text/html").send(PACKAGE_FILE_NOT_FOUND_HTML);
|
|
292609
293558
|
}
|
|
292610
293559
|
}
|
|
292611
293560
|
});
|
|
@@ -292616,21 +293565,17 @@ async function serveFromPackage(req, res) {
|
|
|
292616
293565
|
}
|
|
292617
293566
|
}
|
|
292618
293567
|
}
|
|
293568
|
+
function withRequestQuery(req, target) {
|
|
293569
|
+
const hash = req.originalUrl.indexOf("#");
|
|
293570
|
+
const target_ = hash === -1 ? req.originalUrl : req.originalUrl.slice(0, hash);
|
|
293571
|
+
const marker = target_.indexOf("?");
|
|
293572
|
+
return marker === -1 ? target : target + target_.slice(marker);
|
|
293573
|
+
}
|
|
292619
293574
|
app.get("/environments/:environmentName/packages/:packageName", (req, res, next) => {
|
|
292620
293575
|
if (req.path.endsWith("/"))
|
|
292621
293576
|
return next();
|
|
292622
293577
|
const canonical = `/environments/${encodeURIComponent(req.params.environmentName)}/packages/${encodeURIComponent(req.params.packageName)}/`;
|
|
292623
|
-
|
|
292624
|
-
for (const [key, value] of Object.entries(req.query)) {
|
|
292625
|
-
if (Array.isArray(value)) {
|
|
292626
|
-
for (const v of value)
|
|
292627
|
-
query.append(key, String(v));
|
|
292628
|
-
} else if (value !== undefined) {
|
|
292629
|
-
query.append(key, String(value));
|
|
292630
|
-
}
|
|
292631
|
-
}
|
|
292632
|
-
const qs = query.toString();
|
|
292633
|
-
res.redirect(308, qs ? `${canonical}?${qs}` : canonical);
|
|
293578
|
+
res.redirect(308, withRequestQuery(req, canonical));
|
|
292634
293579
|
});
|
|
292635
293580
|
app.get("/environments/:environmentName/packages/:packageName/*", serveFromPackage);
|
|
292636
293581
|
var PAGES_DEPTH_CAP = 3;
|
|
@@ -292645,11 +293590,11 @@ function stripNonTagText(input) {
|
|
|
292645
293590
|
return current;
|
|
292646
293591
|
}
|
|
292647
293592
|
async function listPackagePages(environmentName, packageName, publicRoot) {
|
|
292648
|
-
const
|
|
293593
|
+
const fs11 = await import("fs/promises");
|
|
292649
293594
|
const out = [];
|
|
292650
293595
|
let realPublicRoot;
|
|
292651
293596
|
try {
|
|
292652
|
-
realPublicRoot = await
|
|
293597
|
+
realPublicRoot = await fs11.realpath(publicRoot);
|
|
292653
293598
|
} catch {
|
|
292654
293599
|
return out;
|
|
292655
293600
|
}
|
|
@@ -292658,7 +293603,7 @@ async function listPackagePages(environmentName, packageName, publicRoot) {
|
|
|
292658
293603
|
return;
|
|
292659
293604
|
let entries;
|
|
292660
293605
|
try {
|
|
292661
|
-
entries = await
|
|
293606
|
+
entries = await fs11.readdir(dir, { withFileTypes: true });
|
|
292662
293607
|
} catch {
|
|
292663
293608
|
return;
|
|
292664
293609
|
}
|
|
@@ -292668,7 +293613,7 @@ async function listPackagePages(environmentName, packageName, publicRoot) {
|
|
|
292668
293613
|
const full = path13.join(dir, entry.name);
|
|
292669
293614
|
let realFull;
|
|
292670
293615
|
try {
|
|
292671
|
-
realFull = await
|
|
293616
|
+
realFull = await fs11.realpath(full);
|
|
292672
293617
|
} catch {
|
|
292673
293618
|
continue;
|
|
292674
293619
|
}
|
|
@@ -292682,7 +293627,7 @@ async function listPackagePages(environmentName, packageName, publicRoot) {
|
|
|
292682
293627
|
let title = rel;
|
|
292683
293628
|
let fit;
|
|
292684
293629
|
try {
|
|
292685
|
-
const fh = await
|
|
293630
|
+
const fh = await fs11.open(full, "r");
|
|
292686
293631
|
try {
|
|
292687
293632
|
const buf = Buffer.alloc(4096);
|
|
292688
293633
|
const { bytesRead } = await fh.read(buf, 0, 4096, 0);
|
|
@@ -293371,7 +294316,62 @@ registerLegacyRoutes(app, {
|
|
|
293371
294316
|
});
|
|
293372
294317
|
if (!isDevelopment) {
|
|
293373
294318
|
const SPA_INDEX = path13.resolve(ROOT, "index.html");
|
|
294319
|
+
const escapeHtml = (value) => value.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c] ?? c);
|
|
294320
|
+
const decodeSegment = (segment) => {
|
|
294321
|
+
if (segment === undefined)
|
|
294322
|
+
return "";
|
|
294323
|
+
try {
|
|
294324
|
+
return decodeURIComponent(segment);
|
|
294325
|
+
} catch {
|
|
294326
|
+
return segment;
|
|
294327
|
+
}
|
|
294328
|
+
};
|
|
293374
294329
|
app.get("*", (req, res) => {
|
|
294330
|
+
let fallback = classifySpaFallback(req.path, API_PREFIX2);
|
|
294331
|
+
const loadedEnvironment = (name) => environmentStore.getLoadedEnvironments().find((environment) => environment.getEnvironmentName() === name);
|
|
294332
|
+
if (fallback.kind === "redirect") {
|
|
294333
|
+
const environment = loadedEnvironment(decodeSegment(fallback.environmentName));
|
|
294334
|
+
const packageName = decodeSegment(fallback.packageName);
|
|
294335
|
+
const known = environment?.getLoadedPackages().some((pkg) => pkg.getPackageName() === packageName);
|
|
294336
|
+
if (!known) {
|
|
294337
|
+
fallback = {
|
|
294338
|
+
kind: "assetNotFound",
|
|
294339
|
+
path: req.path,
|
|
294340
|
+
appRouteCandidate: null
|
|
294341
|
+
};
|
|
294342
|
+
}
|
|
294343
|
+
}
|
|
294344
|
+
if (fallback.kind === "assetNotFound" && fallback.appRouteCandidate) {
|
|
294345
|
+
const { environmentName, packageName } = fallback.appRouteCandidate;
|
|
294346
|
+
const environment = loadedEnvironment(decodeSegment(environmentName));
|
|
294347
|
+
const isAppRoute = environment !== undefined && (packageName === undefined || environment.getLoadedPackages().some((pkg) => pkg.getPackageName() === decodeSegment(packageName)));
|
|
294348
|
+
if (isAppRoute)
|
|
294349
|
+
fallback = { kind: "spa" };
|
|
294350
|
+
}
|
|
294351
|
+
if (fallback.kind === "redirect") {
|
|
294352
|
+
res.redirect(302, withRequestQuery(req, fallback.location));
|
|
294353
|
+
return;
|
|
294354
|
+
}
|
|
294355
|
+
if (fallback.kind === "apiNotFound") {
|
|
294356
|
+
res.status(404).json({
|
|
294357
|
+
code: 404,
|
|
294358
|
+
message: `Unknown API endpoint: ${fallback.path}. See /api-doc.yaml for the endpoints this server serves.`
|
|
294359
|
+
});
|
|
294360
|
+
return;
|
|
294361
|
+
}
|
|
294362
|
+
if (fallback.kind === "assetNotFound") {
|
|
294363
|
+
res.status(404).type("text/html").send(`<!doctype html><meta charset="utf-8">
|
|
294364
|
+
<title>Not found</title>
|
|
294365
|
+
<style>body{font:14px/1.4 -apple-system,system-ui,sans-serif;margin:40px;max-width:720px;color:#222}code{background:#f4f4f5;padding:1px 4px;border-radius:3px}</style>
|
|
294366
|
+
<h1>Not found</h1>
|
|
294367
|
+
<p>Nothing is served at <code>${escapeHtml(fallback.path)}</code>.</p>
|
|
294368
|
+
<p>A file inside a package is served from that package's <code>public/</code> directory at
|
|
294369
|
+
<code>/environments/<env>/packages/<pkg>/<file></code>, where <code><file></code> is relative to
|
|
294370
|
+
<code>public/</code> and does not include it. Models and notebooks open in the web UI at
|
|
294371
|
+
<code>/<env>/<pkg>/<file>.malloy</code> and <code>.malloynb</code>.</p>
|
|
294372
|
+
<p><a href="/">Publisher home</a> lists the environments and packages this server has.</p>`);
|
|
294373
|
+
return;
|
|
294374
|
+
}
|
|
293375
294375
|
res.sendFile(SPA_INDEX, (err) => {
|
|
293376
294376
|
if (!err)
|
|
293377
294377
|
return;
|