@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/server.mjs CHANGED
@@ -120280,8 +120280,8 @@ var require_node42 = __commonJS((exports, module) => {
120280
120280
  }
120281
120281
  break;
120282
120282
  case "FILE":
120283
- var fs = __require("fs");
120284
- stream2 = new fs.SyncWriteStream(fd2, { autoClose: false });
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 fs = __require("fs");
136351
- stream2 = new fs.SyncWriteStream(fd2, { autoClose: false });
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 fs = __require("fs");
137077
- stream2 = new fs.SyncWriteStream(fd2, { autoClose: false });
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 fs = __require("fs");
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 fs.statSync(path2);
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 fs = __require("fs");
138599
- stream2 = new fs.SyncWriteStream(fd2, { autoClose: false });
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 fs = __require("fs");
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 = fs.readFileSync(file, "ascii"), lines = content.split(/[\r\n]+/);
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 fs = __require("fs");
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
- fs.stat(path2, function onstat(err, stat) {
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
- fs.stat(p, function(err2, stat) {
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
- fs.stat(p, function(err2, stat) {
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 = fs.createReadStream(path2, options);
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 fs2 = __require("fs");
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
- fs2.stat(value.path, function(err, stat) {
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 fs2 = __require("fs");
183161
- var { constants } = fs2;
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, fs2, remotePath, localPath, opts, cb);
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(fs2, this, localPath, remotePath, opts, cb);
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 === fs2 || src.outgoing.state === "open"))
184785
+ if (srcHandle && (src === fs3 || src.outgoing.state === "open"))
184775
184786
  ++left;
184776
- if (dstHandle && (dst === fs2 || dst.outgoing.state === "open"))
184787
+ if (dstHandle && (dst === fs3 || dst.outgoing.state === "open"))
184777
184788
  ++left;
184778
- if (srcHandle && (src === fs2 || src.outgoing.state === "open"))
184789
+ if (srcHandle && (src === fs3 || src.outgoing.state === "open"))
184779
184790
  src.close(srcHandle, cbfinal);
184780
- if (dstHandle && (dst === fs2 || dst.outgoing.state === "open"))
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 !== fs2) {
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 crypto3 = __require("crypto");
190379
+ var crypto4 = __require("crypto");
190369
190380
 
190370
190381
  class NodeCrypto {
190371
190382
  async sha256DigestBase64(str) {
190372
- return crypto3.createHash("sha256").update(str).digest("base64");
190383
+ return crypto4.createHash("sha256").update(str).digest("base64");
190373
190384
  }
190374
190385
  randomBytesBase64(count) {
190375
- return crypto3.randomBytes(count).toString("base64");
190386
+ return crypto4.randomBytes(count).toString("base64");
190376
190387
  }
190377
190388
  async verify(pubkey, data, signature) {
190378
- const verifier = crypto3.createVerify("RSA-SHA256");
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 = crypto3.createSign("RSA-SHA256");
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 crypto3.createHash("sha256").update(str).digest("hex");
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(crypto3.createHmac("sha256", cryptoKey).update(msg).digest());
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 crypto3 = (0, crypto_1.createCrypto)();
191017
- const randomString = crypto3.randomBytesBase64(96);
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 crypto3.sha256DigestBase64(codeVerifier);
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 crypto3 = (0, crypto_1.createCrypto)();
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(crypto3.decodeBase64StringUtf8(segments[0]));
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(crypto3.decodeBase64StringUtf8(segments[1]));
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 crypto3.verify(cert, signed, signature);
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 crypto3 = __require("crypto");
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 crypto3.createPublicKey === "function";
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 = crypto3.createHmac("sha" + bits, secret);
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 crypto3 ? function timingSafeEqual2(a, b) {
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 crypto3.timingSafeEqual(a, b);
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 = crypto3.createSign("RSA-SHA" + bits);
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 = crypto3.createVerify("RSA-SHA" + bits);
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 = crypto3.createSign("RSA-SHA" + bits);
191942
+ var signer = crypto4.createSign("RSA-SHA" + bits);
191932
191943
  var sig = (signer.update(thing), signer.sign({
191933
191944
  key: privateKey,
191934
- padding: crypto3.constants.RSA_PKCS1_PSS_PADDING,
191935
- saltLength: crypto3.constants.RSA_PSS_SALTLEN_DIGEST
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 = crypto3.createVerify("RSA-SHA" + bits);
191956
+ var verifier = crypto4.createVerify("RSA-SHA" + bits);
191946
191957
  verifier.update(thing);
191947
191958
  return verifier.verify({
191948
191959
  key: publicKey,
191949
- padding: crypto3.constants.RSA_PKCS1_PSS_PADDING,
191950
- saltLength: crypto3.constants.RSA_PSS_SALTLEN_DIGEST
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 fs3 = __require("fs");
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 = fs3.readFile ? (0, util_1.promisify)(fs3.readFile) : async () => {
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 fs3 = __require("fs");
193473
- var readFile = (0, util_1.promisify)((_a = fs3.readFile) !== null && _a !== undefined ? _a : () => {});
193474
- var realpath = (0, util_1.promisify)((_b = fs3.realpath) !== null && _b !== undefined ? _b : () => {});
193475
- var lstat = (0, util_1.promisify)((_c = fs3.lstat) !== null && _c !== undefined ? _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(crypto3, key, msg) {
193677
- return await crypto3.signWithHmacSha256(key, msg);
193678
- }
193679
- async function getSigningKey(crypto3, key, dateStamp, region, serviceName) {
193680
- const kDate = await sign(crypto3, `AWS4${key}`, dateStamp);
193681
- const kRegion = await sign(crypto3, kDate, region);
193682
- const kService = await sign(crypto3, kRegion, serviceName);
193683
- const kSigning = await sign(crypto3, kService, "aws4_request");
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 fs3 = __require("fs");
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 fs3.promises.realpath(this.outputFile);
194106
+ filePath = await fs4.promises.realpath(this.outputFile);
194096
194107
  } catch (_a) {
194097
194108
  return;
194098
194109
  }
194099
- if (!(await fs3.promises.lstat(filePath)).isFile()) {
194110
+ if (!(await fs4.promises.lstat(filePath)).isFile()) {
194100
194111
  return;
194101
194112
  }
194102
- const responseString = await fs3.promises.readFile(filePath, {
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 fs3 = __require("fs");
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 (!fs3.existsSync(location)) {
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 = fs3.realpathSync(filePath);
194649
- if (!fs3.lstatSync(filePath).isFile()) {
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 = fs3.createReadStream(filePath);
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 crypto3 = (0, crypto_1.createCrypto)();
194921
+ const crypto4 = (0, crypto_1.createCrypto)();
194911
194922
  if (client instanceof jwtclient_1.JWT && client.key) {
194912
- const sign = await crypto3.sign(client.key, data);
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(crypto3, creds.client_email, data, endpoint);
194930
+ return this.signBlob(crypto4, creds.client_email, data, endpoint);
194920
194931
  }
194921
- async signBlob(crypto3, emailOrUniqueId, data, endpoint) {
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: crypto3.encodeBase64StringUtf8(data)
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 = fs3.createReadStream(filePath);
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 fs3 = __require("fs");
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 = fs3.createReadStream(source).pipe(this.createWriteStream_(metadata));
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 randomUUID2 = require_randomUUID();
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 (randomUUID2.randomUUID) {
208818
- return randomUUID2.randomUUID();
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 crypto3 = __require("crypto");
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 ? crypto3.createHmac(this.algorithmIdentifier, castSourceData(this.secret)) : crypto3.createHash(this.algorithmIdentifier);
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 fs3 = __require("fs");
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 } = fs3.promises;
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 fs3 = __require("fs");
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 = fs3.createReadStream(fileStream.path, {
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 fs3;
238499
+ var fs4;
238489
238500
  try {
238490
- fs3 = __require("fs");
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 (!fs3)
238511
+ if (!fs4)
238501
238512
  return false;
238502
- return (stream4 instanceof (fs3.ReadStream || noop2) || stream4 instanceof (fs3.WriteStream || noop2)) && isFn(stream4.close);
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 fs3 = __require("fs");
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
- fs3.read(self2.fd, buffer, offset, length, position, function(err, bytesRead, buffer2) {
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
- fs3.write(self2.fd, buffer, offset, length, position, function(err, written, buffer2) {
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
- fs3.close(self2.fd, onCloseDone);
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
- fs3.read(self2.context.fd, buffer, 0, toRead, self2.pos, function(err, bytesRead) {
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
- fs3.write(self2.context.fd, buffer, 0, buffer.length, self2.pos, function(err2, bytes) {
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 fs3 = __require("fs");
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
- fs3.open(path4, "r", function(err, fd) {
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
- fs3.close(fd, defaultCallback);
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
- fs3.fstat(fd, function(err, stats) {
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: fs3 } = __require("fs");
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 fs3.mkdir(destDir, { recursive: true });
240030
- const canonicalDestDir = await fs3.realpath(destDir);
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 fs3.mkdir(destDir, mkdirOptions);
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 fs3.symlink(link, dest);
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 fs3.mkdir(opts.dir, { recursive: true });
240116
- opts.dir = await fs3.realpath(opts.dir);
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: fs3 } = await import("fs");
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 fs3.readFile(filePath, "utf8");
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 fs5 = __require("fs");
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
- fs5.readdir(path6, function(err, files) {
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
- fs5.stat(filePath, function(_err, stats) {
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 InvalidArgumentError extends LuxonError {
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 InvalidArgumentError("Week settings must be an object");
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 InvalidArgumentError("Invalid week settings");
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 InvalidArgumentError(`Invalid unit value ${value}`);
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 InvalidArgumentError(`Duration.fromObject: argument expected to be an object, got ${obj === null ? "null" : typeof obj}`);
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 InvalidArgumentError(`Unknown duration argument ${durationLike} of type ${typeof durationLike}`);
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 InvalidArgumentError("need to specify a reason the Duration is invalid");
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 InvalidArgumentError("need to specify a reason the Interval is invalid");
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 InvalidArgumentError(`fromMillis requires a numerical input, but received a ${typeof milliseconds} with value ${milliseconds}`);
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 InvalidArgumentError("fromSeconds requires a numerical input");
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 InvalidArgumentError("fromFormat requires an input string and a format");
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 InvalidArgumentError("need to specify a reason the DateTime is invalid");
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 InvalidArgumentError("min requires all arguments be DateTimes");
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 InvalidArgumentError("max requires all arguments be DateTimes");
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 InvalidArgumentError("fromFormatParser requires an input string and a format parser");
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 InvalidArgumentError(`fromFormatParser called with a locale of ${localeToUse}, ` + `but the format parser was created for ${formatParser.locale}`);
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 InvalidArgumentError(`Unknown datetime argument: ${dateTimeish}, of type ${typeof dateTimeish}`);
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 fs from "fs";
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 (!fs.existsSync(explicitPath)) {
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 (fs.existsSync(serverRootPath)) {
261271
+ if (fs2.existsSync(serverRootPath)) {
261199
261272
  return { path: serverRootPath, isBundledDefault: false };
261200
261273
  }
261201
- if (process.env.PUBLISHER_USE_BUNDLED_DEFAULT === "true" && fs.existsSync(BUNDLED_DEFAULT_CONFIG_PATH)) {
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 (!fs.statSync(resolved.path).isFile()) {
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 = fs.readFileSync(publisherConfigPath, "utf8");
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: "Queries rejected with 413 because the row or byte cap was exceeded. Labels: cap_type ('rows'|'bytes'), source ('connection_sql'|'model_query'|'notebook_cell')."
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 fs2 from "fs/promises";
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 crypto.randomUUID();
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 fs2.access(ducklakePath);
266900
- await fs2.rm(ducklakePath);
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.runSQL(describeQuery);
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.replace(/'/g, "''")}'`);
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.runSQL("SELECT row_to_json(t) as row FROM (SELECT schema_name FROM information_schema.schemata ORDER BY schema_name) t");
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.runSQL(`SELECT CATALOG_NAME, SCHEMA_NAME, SCHEMA_OWNER FROM ${database ? `${database}.` : ""}INFORMATION_SCHEMA.SCHEMATA ${whereClause} ORDER BY SCHEMA_NAME`);
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.runSQL(`SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`);
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.runSQL(`SHOW CATALOGS`);
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.runSQL(`SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`);
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.runSQL(`SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`);
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.runSQL(`SHOW CATALOGS`);
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.runSQL(`SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`);
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.runSQL("SELECT DISTINCT schema_name,catalog_name FROM information_schema.schemata ORDER BY catalog_name,schema_name", { rowLimit: 1000 });
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.runSQL(`SELECT DISTINCT schema_name FROM information_schema.schemata ${whereClause} ORDER BY schema_name`);
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.runSQL(`SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${catalogName}' ORDER BY schema_name`, { rowLimit: 1000 });
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.runSQL(describeQuery);
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.runSQL(`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("TABLE_NAME", tableNames)} ORDER BY TABLE_NAME, ORDINAL_POSITION`);
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.runSQL(`SELECT row_to_json(t) as row FROM (SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position) t`);
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.runSQL(`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM ${databaseName}.INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '${schemaOnly}' ${sqlInFilter("TABLE_NAME", tableNames)} ORDER BY TABLE_NAME, ORDINAL_POSITION`);
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.runSQL(`SELECT table_name, column_name, data_type FROM ${catalogPrefix}information_schema.columns WHERE table_schema = '${schemaOnly}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`);
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.runSQL(`SELECT table_name, column_name, data_type FROM ${catalogPrefix}information_schema.columns WHERE table_schema = '${schemaOnly}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`);
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 Error(`DuckDB schema name must be qualified as "catalog.schema", got "${schemaName}"`);
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.runSQL(`SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${actualSchemaName}' AND table_catalog = '${catalogName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`);
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.runSQL(`SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`);
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.runSQL(`SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${actualSchemaName}' AND table_catalog = '${catalogName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`);
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. ` + `Use /environments/${environmentName}/packages/{packageName}/connections/duckdb/... to disambiguate.`);
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
- compactResult,
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: compactJson ? JSON.stringify(compactResult, bigIntReplacer) : JSON.stringify(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 crypto4 from "crypto";
271152
- import * as fs8 from "fs";
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 crypto3 from "crypto";
276696
- import * as fs7 from "fs";
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 fs3 from "fs";
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 fs3.promises.readFile(path6, "utf8");
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 fs4 from "fs/promises";
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 fs4.readFile(fileURLToPath4(uri), "utf8");
277179
+ return fs5.readFile(fileURLToPath4(uri), "utf8");
276839
277180
  }
276840
- return fs4.readFile(uri, "utf8");
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 fs6 from "fs/promises";
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 Annotations3 } from "@malloydata/malloy";
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 fs5 from "fs/promises";
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 assertWithinModelResponseLimits(rowCount, serializedBytes, { maxRows, maxBytes }, source) {
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
- if (maxBytes > 0 && serializedBytes > maxBytes) {
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+))\s*->/);
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?.blockNotes ?? []).map((note) => note.text);
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
- ...collectAuthorizeExprs(ownNotes)
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
- function isRecordOrArrayField(field) {
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 names;
278418
+ return byName;
278197
278419
  for (const entry of Object.values(modelDef.contents)) {
278198
278420
  if (!isSourceDef2(entry))
278199
278421
  continue;
278200
- for (const { exprs } of this.collectAllReachableGates(entry, modelDef, new Set)) {
278201
- addExprs(exprs);
278202
- }
278422
+ const name = entry.as ?? entry.name;
278423
+ byName.set(name, this.collectEntryPointGates(entry, modelDef, new Set, true));
278203
278424
  }
278204
- return names;
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, extendSources } = await this.resolveRunTargetStruct(runnable);
278471
+ const { struct, modelDef, compositeResolvedSourceDef } = await this.resolveRunTargetStruct(runnable);
278244
278472
  const seen = new Set;
278245
- const joinedGates = this.collectAllReachableGates(struct, modelDef, seen, true);
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
- joinedGates.push(...this.collectAllReachableGates(compositeResolvedSourceDef, modelDef, seen, true));
278475
+ entryPointGates.push(...this.collectEntryPointGates(compositeResolvedSourceDef, modelDef, seen, true));
278261
278476
  }
278262
- for (const { label, exprs, selfContained } of joinedGates) {
278263
- await this.assertAuthorizedExprs(label, exprs, givens, selfContained);
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?.blockNotes ?? []).map((note) => note.text);
278511
+ gateExprsForOwnAnnotations(struct, modelDef) {
278512
+ const ownNotes = ownLevelNoteTexts(struct.annotations);
278297
278513
  try {
278298
- return [
278299
- ...this.fileLevelAuthorize,
278300
- ...collectAuthorizeExprs(ownNotes)
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
- classifyJoinedField(field, parentLabel) {
278307
- if (!isJoined(field))
278308
- return {};
278309
- if (isRecordOrArrayField(field))
278310
- return {};
278311
- if (!isSourceDef2(field)) {
278312
- logger.error("authorize: joined field failed to resolve to a walkable SourceDef; denying rather than silently skipping its gate (possible Malloy struct-shape drift)", {
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
- return { resolved: field };
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
- collectAllReachableGates(struct, modelDef, seen = new Set, treatAsOwnGate = false) {
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 ownExprs = this.gateExprsForOwnAnnotations(struct);
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.collectAllReachableGates(base, modelDef, seen));
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.collectAllReachableGates(resolved, modelDef, seen));
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, sources ?? []);
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
- target: target.label,
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 earlySource = sourceName || (queryName ? this.queries?.find((q) => q.name === queryName)?.sourceName : undefined) || extractRunTargetSourceName(query);
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 + "->" : ""}${queryName}`;
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 serializedBytes = maxBytes > 0 ? Buffer.byteLength(JSON.stringify(wrappedResult), "utf8") : 0;
279021
- assertWithinModelResponseLimits(queryResults.totalRows, serializedBytes, { maxRows, maxBytes }, "model_query");
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 ? new Annotations2(modelAnnotations(this.modelDef)).texts() : [];
279101
- return {
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
- queryResult = result?._queryResult && this.modelInfo && JSON.stringify(API.util.wrapResult(result));
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
- assertWithinModelResponseLimits(result.totalRows, Buffer.byteLength(queryResult, "utf8"), { maxRows: cellMaxRows, maxBytes: cellMaxBytes }, "notebook_cell");
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 fs5.stat(fullModelPath)).isFile()) {
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 { sources, filterMap };
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 fs5.readFile(modelURL, "utf8");
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 fs5.readFile(fullPath, "utf8");
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 Annotations3(def.annotations).parseAsTag("@").tag.has("persist");
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, target) {
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
- ...target ? { target } : {}
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.target ?? ""}\x00${warning.message}`;
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 fs6.lstat(packagePath).catch(() => null);
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 fs6.rm(packagePath, { recursive: true, force: true });
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({ model: sm.modelPath, ...w });
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 fs6.readFile(path7.join(packagePath, sm.modelPath), "utf-8");
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
- target: d.name,
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
- target: source.name,
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({ model: sm.modelPath, ...w });
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 fs6.stat(packageConfigPath);
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 fs7.promises.writeFile(readmePath, readme, "utf-8");
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 fs7.promises.stat(environmentPath))?.isDirectory()) {
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 fs7.promises.readFile(safeJoinUnderRoot(this.environmentPath, README_NAME))).toString();
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 fs7.promises.readFile(modelPath, "utf8");
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
- fs7.mkdirSync(destinationRoot, { recursive: true });
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}-${crypto3.randomUUID()}`);
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}-${crypto3.randomUUID()}`);
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 fs7.promises.rm(dir, { recursive: true, force: true });
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 fs7.promises.access(packagePath).then(() => true).catch(() => false) || !(await fs7.promises.stat(packagePath))?.isDirectory()) {
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 fs7.promises.mkdir(path8.dirname(stagingPath), { recursive: true });
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 fs7.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
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 fs7.promises.access(canonicalPath).then(() => true).catch(() => false);
281479
+ const oldExistsOnDisk = await fs8.promises.access(canonicalPath).then(() => true).catch(() => false);
281211
281480
  if (oldExistsOnDisk) {
281212
281481
  retiredPath = this.allocateRetiredPath(packageName);
281213
- await fs7.promises.mkdir(path8.dirname(retiredPath), {
281482
+ await fs8.promises.mkdir(path8.dirname(retiredPath), {
281214
281483
  recursive: true
281215
281484
  });
281216
- await fs7.promises.rename(canonicalPath, retiredPath);
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 fs7.promises.rename(stagingPath, canonicalPath);
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 fs7.promises.rm(canonicalPath, { recursive: true, force: true }).catch(() => {});
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 fs7.promises.rename(retiredPath, canonicalPath);
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 fs7.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
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
- fs7.promises.rm(pathToClean, { recursive: true, force: true }).catch((err) => {
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 fs7.promises.readFile(manifestPath, "utf-8");
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 fs7.promises.writeFile(manifestPath, JSON.stringify(updatedManifest, null, 2), "utf-8");
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 fs7.promises.mkdir(path8.dirname(retiredPath), {
281832
+ await fs8.promises.mkdir(path8.dirname(retiredPath), {
281564
281833
  recursive: true
281565
281834
  });
281566
- await fs7.promises.rename(canonicalPath, retiredPath);
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
- fs7.promises.rm(retiredPath, { recursive: true, force: true }).catch((err) => {
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 fs7.promises.rm(duckdbPath, { force: true });
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 fs8.promises.lstat(targetPath);
281994
+ const stats = await fs9.promises.lstat(targetPath);
281726
281995
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
281727
- await fs8.promises.rm(targetPath, { recursive: true, force: true });
281996
+ await fs9.promises.rm(targetPath, { recursive: true, force: true });
281728
281997
  } else {
281729
- await fs8.promises.unlink(targetPath);
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 fs8.promises.access(dbEnvironment.path).then(() => true).catch(() => false);
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 fs8.promises.mkdir(this.serverRootPath, { recursive: true });
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 fs8.promises.rm(uploadDocsPath2, {
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 fs8.promises.mkdir(uploadDocsPath, { recursive: true });
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 fs8.promises.rm(unzippedEnvironmentPath, {
282788
+ await fs9.promises.rm(unzippedEnvironmentPath, {
282520
282789
  recursive: true,
282521
282790
  force: true
282522
282791
  });
282523
- await fs8.promises.mkdir(unzippedEnvironmentPath, { recursive: true });
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 fs8.promises.rm(environmentPath, {
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 fs8.promises.readdir(serverRootPath, {
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 fs8.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
282897
+ await fs9.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
282629
282898
  if (environment.readme) {
282630
- await fs8.promises.writeFile(safeJoinUnderRoot(absoluteEnvironmentPath, "README.md"), environment.readme);
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 fs8.promises.mkdir(absoluteTargetPath, { recursive: true });
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 = crypto4.createHash("sha256").update(groupedLocation).digest("hex").substring(0, 16);
282959
+ const locationHash = crypto5.createHash("sha256").update(groupedLocation).digest("hex").substring(0, 16);
282691
282960
  const tempDownloadPath = safeJoinUnderRoot(absoluteTargetPath, `.temp_${locationHash}`);
282692
- await fs8.promises.mkdir(tempDownloadPath, { recursive: true });
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 fs8.promises.access(sourcePath).then(() => true).catch(() => false);
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 fs8.promises.symlink(absoluteSourcePath, absolutePackagePath, linkType);
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 fs8.promises.mkdir(absolutePackagePath, {
283010
+ await fs9.promises.mkdir(absolutePackagePath, {
282742
283011
  recursive: true
282743
283012
  });
282744
- await fs8.promises.cp(sourcePath, absolutePackagePath, {
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 fs8.promises.mkdir(absolutePackagePath, {
283023
+ await fs9.promises.mkdir(absolutePackagePath, {
282755
283024
  recursive: true
282756
283025
  });
282757
- await fs8.promises.cp(sourcePath, absolutePackagePath, {
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 fs8.promises.mkdir(absolutePackagePath, {
283033
+ await fs9.promises.mkdir(absolutePackagePath, {
282765
283034
  recursive: true
282766
283035
  });
282767
- await fs8.promises.cp(tempDownloadPath, absolutePackagePath, {
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 fs8.promises.rm(tempDownloadPath, {
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 fs8.promises.stat(environmentPath))?.isDirectory() ?? false;
283120
+ const environmentDirExists = (await fs9.promises.stat(environmentPath))?.isDirectory() ?? false;
282852
283121
  if (environmentDirExists) {
282853
- await fs8.promises.rm(absoluteTargetPath, {
283122
+ await fs9.promises.rm(absoluteTargetPath, {
282854
283123
  recursive: true,
282855
283124
  force: true
282856
283125
  });
282857
- await fs8.promises.mkdir(absoluteTargetPath, { recursive: true });
282858
- await fs8.promises.cp(environmentPath, absoluteTargetPath, {
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 fs8.promises.rm(absoluteDirPath, {
283146
+ await fs9.promises.rm(absoluteDirPath, {
282878
283147
  recursive: true,
282879
283148
  force: true
282880
283149
  });
282881
- await fs8.promises.mkdir(absoluteDirPath, { recursive: true });
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 fs8.promises.mkdir(path9.dirname(absoluteFilePath), {
283160
+ await fs9.promises.mkdir(path9.dirname(absoluteFilePath), {
282892
283161
  recursive: true
282893
283162
  });
282894
- return fs8.promises.writeFile(absoluteFilePath, await file.download());
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 fs8.promises.mkdir(path9.dirname(zipFilePath), {
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 = fs8.createWriteStream(zipFilePath);
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 fs8.promises.rm(absoluteDirPath, { recursive: true, force: true });
282934
- await fs8.promises.mkdir(absoluteDirPath, { recursive: true });
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 fs8.promises.mkdir(path9.dirname(absoluteFilePath), {
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 = fs8.createWriteStream(absoluteFilePath);
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 fs8.promises.rm(absoluteDirPath, {
283245
+ await fs9.promises.rm(absoluteDirPath, {
282977
283246
  recursive: true,
282978
283247
  force: true
282979
283248
  });
282980
- await fs8.promises.mkdir(absoluteDirPath, { recursive: true });
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 fs8.promises.access(packageFullPath).then(() => true).catch(() => false);
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 fs8.promises.readdir(absoluteDirPath);
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 fs8.promises.rm(safeJoinUnderRoot(absoluteDirPath, entry), {
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 fs8.promises.readdir(packageFullPath);
283288
+ const packageContents = await fs9.promises.readdir(packageFullPath);
283020
283289
  for (const entry of packageContents) {
283021
- await fs8.promises.rename(safeJoinUnderRoot(packageFullPath, entry), safeJoinUnderRoot(absoluteDirPath, entry));
283290
+ await fs9.promises.rename(safeJoinUnderRoot(packageFullPath, entry), safeJoinUnderRoot(absoluteDirPath, entry));
283022
283291
  }
283023
- await fs8.promises.rm(packageFullPath, { recursive: true, force: true });
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 row cap cut the result off and more rows exist, so the numbers in front of you are a partial set, not the answer. A query that set its own limit: or top: does not set it, and returning exactly that many rows is a complete answer to what was asked.
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 returns, so a data app behaves the same authored locally and served in production:
286400
- - rows: flat objects keyed by column name, the shape an in-package data app receives.
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
- A query with no limit: of its own gets the server default, so a result landing exactly on _query_row_limit is almost never the whole table. Values above 2^53 are returned as JSON strings so their digits survive.`;
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
- - **Not inherited, not joined.** The gate applies only to the source a query directly runs against. A source that \`extend\`s a locked base does **not** inherit the base's gate, and a gate on a source reached only via \`join_*\` never fires. 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 instead of leaking the base's data through the join or extend.
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 fs9 from "fs";
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 (fs9.existsSync(path11.join(current, ".git")))
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: fs9.lstatSync(file).isSymbolicLink()
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 fs9.realpathSync(p);
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: fs9.existsSync(rootCandidate) ? rootCandidate : undefined,
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
- fs9.writeFileSync(file, body, { encoding: "utf8", flag: "wx" });
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/&lt;env&gt;/packages/&lt;pkg&gt;/&lt;file&gt;</code>, where <code>&lt;file&gt;</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>/&lt;env&gt;/&lt;pkg&gt;/&lt;file&gt;.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).end();
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).end();
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
- const query = new URLSearchParams;
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 fs10 = await import("fs/promises");
293593
+ const fs11 = await import("fs/promises");
292649
293594
  const out = [];
292650
293595
  let realPublicRoot;
292651
293596
  try {
292652
- realPublicRoot = await fs10.realpath(publicRoot);
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 fs10.readdir(dir, { withFileTypes: true });
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 fs10.realpath(full);
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 fs10.open(full, "r");
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) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[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/&lt;env&gt;/packages/&lt;pkg&gt;/&lt;file&gt;</code>, where <code>&lt;file&gt;</code> is relative to
294370
+ <code>public/</code> and does not include it. Models and notebooks open in the web UI at
294371
+ <code>/&lt;env&gt;/&lt;pkg&gt;/&lt;file&gt;.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;