@malloy-publisher/server 0.9.11 → 0.9.13

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.
Files changed (27) hide show
  1. package/dist/app/api-doc.yaml +259 -4
  2. package/dist/app/assets/EnvironmentPage-BxJoHBPQ.js +1 -0
  3. package/dist/app/assets/HomePage-CtB5QVol.js +1 -0
  4. package/dist/app/assets/{MainPage-BpE_5WuW.js → MainPage-1nhjUUzl.js} +1 -1
  5. package/dist/app/assets/{ModelPage-ftyDI1tw.js → ModelPage-D8aucAvE.js} +2 -2
  6. package/dist/app/assets/{PackagePage-QGnFwlHu.js → PackagePage-DBSA4TbT.js} +1 -1
  7. package/dist/app/assets/{RenderedResult-Cmx8sX8d.es-du-Qe1x0.js → RenderedResult-DnBtf-cI.es-DalkcU9E.js} +2 -2
  8. package/dist/app/assets/{RouteError-zM4SSZaU.js → RouteError-BVLp9Ogi.js} +1 -1
  9. package/dist/app/assets/{ThemeEditorPage-DhZJXGJm.js → ThemeEditorPage-D6bvkrka.js} +1 -1
  10. package/dist/app/assets/{ToggleButtonGroup-TKxhg0Mg.js → ToggleButtonGroup-CWY9-__R.js} +1 -1
  11. package/dist/app/assets/{documentRoutes-D4TbSIi5.js → documentRoutes-QL_YE88h.js} +1 -1
  12. package/dist/app/assets/{editPageMounted-0kzl2sgg.js → editPageMounted-BT7ByBX-.js} +2 -2
  13. package/dist/app/assets/{index-Bzi4YH8Y.js → index-BRzP9bJe.js} +1 -1
  14. package/dist/app/assets/{index-CS4mmuDD.js → index-CHfG8VAW.js} +1 -1
  15. package/dist/app/assets/{index-B-qJVEXn.js → index-DM7_2PYo.js} +1 -1
  16. package/dist/app/assets/index-Dgqan3mQ.js +481 -0
  17. package/dist/app/assets/index.es-BBmXDZRJ.js +143 -0
  18. package/dist/app/index.html +1 -1
  19. package/dist/package_load_worker.mjs +112 -8
  20. package/dist/server.mjs +1498 -767
  21. package/dist/sshcrypto-cqb52edz.node +0 -0
  22. package/package.json +2 -1
  23. package/dist/app/assets/EnvironmentPage-tIGzymgb.js +0 -1
  24. package/dist/app/assets/HomePage-7QkNvQ_3.js +0 -1
  25. package/dist/app/assets/index-gQEuz0Cg.js +0 -480
  26. package/dist/app/assets/index.es-B3P9tIm3.js +0 -143
  27. package/dist/cpufeatures-1yrn0vtw.node +0 -0
package/dist/server.mjs CHANGED
@@ -205154,15 +205154,36 @@ function filesystemAccessFailure(error2) {
205154
205154
  function isMysqlConfigFault(code) {
205155
205155
  return typeof code === "string" && (MYSQL_CONFIG_CODES.has(code) || code.startsWith("HANDSHAKE_"));
205156
205156
  }
205157
- function isConnectionFailure(error2) {
205158
- let current = error2;
205157
+ function isSqlserverConnectTimeout(error2, code) {
205158
+ return code === "ETIMEOUT" && error2.name === "ConnectionError";
205159
+ }
205160
+ function wrappedError(error2) {
205161
+ if (error2.cause instanceof Error)
205162
+ return error2.cause;
205163
+ return error2.originalError;
205164
+ }
205165
+ function causeChainHasTlsVerificationFailure(error2) {
205166
+ let current = wrappedError(error2);
205159
205167
  for (let depth = 0;depth < 8 && current instanceof Error; depth++) {
205168
+ const { code } = current;
205169
+ if (typeof code === "string" && TLS_VERIFICATION_CODES.has(code)) {
205170
+ return true;
205171
+ }
205172
+ current = wrappedError(current);
205173
+ }
205174
+ return false;
205175
+ }
205176
+ function isConnectionFailure(error2) {
205177
+ for (const current of causeChain(error2)) {
205160
205178
  const { code, fatal } = current;
205161
205179
  if (typeof code === "string") {
205162
205180
  if (NODE_CONNECTION_CODES.has(code))
205163
205181
  return true;
205164
205182
  if (POSTGRES_CONNECTION_SQLSTATE.test(code))
205165
205183
  return true;
205184
+ if (SQLSERVER_CONNECTION_CODES.has(code) || isSqlserverConnectTimeout(current, code)) {
205185
+ return !causeChainHasTlsVerificationFailure(current);
205186
+ }
205166
205187
  }
205167
205188
  if (fatal === true && !isMysqlConfigFault(code))
205168
205189
  return true;
@@ -205170,13 +205191,18 @@ function isConnectionFailure(error2) {
205170
205191
  if (CODELESS_CONNECTION_MESSAGES.some((re) => re.test(message))) {
205171
205192
  return true;
205172
205193
  }
205173
- current = current.cause;
205174
205194
  }
205175
205195
  return false;
205176
205196
  }
205177
- function isCredentialRejection(error2) {
205197
+ function* causeChain(error2) {
205178
205198
  let current = error2;
205179
205199
  for (let depth = 0;depth < 8 && current instanceof Error; depth++) {
205200
+ yield current;
205201
+ current = wrappedError(current);
205202
+ }
205203
+ }
205204
+ function isCredentialRejection(error2) {
205205
+ for (const current of causeChain(error2)) {
205180
205206
  const { code } = current;
205181
205207
  if (typeof code === "string" || typeof code === "number") {
205182
205208
  const value = String(code);
@@ -205184,12 +205210,13 @@ function isCredentialRejection(error2) {
205184
205210
  return true;
205185
205211
  if (MYSQL_AUTH_CODES.has(value))
205186
205212
  return true;
205213
+ if (SQLSERVER_AUTH_CODES.has(value))
205214
+ return true;
205187
205215
  if (SNOWFLAKE_AUTH_CODES.has(value))
205188
205216
  return true;
205189
205217
  }
205190
205218
  if (current.name === "BigQueryAuthenticationError")
205191
205219
  return true;
205192
- current = current.cause;
205193
205220
  }
205194
205221
  return false;
205195
205222
  }
@@ -205204,6 +205231,19 @@ function databaseAccessFailure(error2) {
205204
205231
  return new ConnectionFailedError(message);
205205
205232
  return;
205206
205233
  }
205234
+ function statementRejection(error2) {
205235
+ for (const current of causeChain(error2)) {
205236
+ const { code, severity } = current;
205237
+ if (severity === "FATAL" || severity === "PANIC")
205238
+ return;
205239
+ if (typeof code !== "string" || !POSTGRES_STATEMENT_SQLSTATES.has(code)) {
205240
+ continue;
205241
+ }
205242
+ const message = current.message;
205243
+ return code === POSTGRES_UNDEFINED_TABLE && POSTGRES_RELATION_MISSING.test(message) ? new TableNotFoundError(message) : new InvalidArgumentError(message);
205244
+ }
205245
+ return;
205246
+ }
205207
205247
  function describeFilesystemAccessFailure(errno) {
205208
205248
  const code = errno.code ?? "";
205209
205249
  const target = errno.path ? ` '${errno.path}'` : "";
@@ -205276,6 +205316,8 @@ function internalErrorToHttpError(error2, { log = true, onLogged } = {}) {
205276
205316
  return httpError(404, error2.message, "TABLE_NOT_FOUND");
205277
205317
  } else if (error2 instanceof ConnectionNotFoundError) {
205278
205318
  return httpError(404, error2.message);
205319
+ } else if (error2 instanceof StorageDestinationNotFoundError) {
205320
+ return httpError(404, error2.message);
205279
205321
  } else if (error2 instanceof DestinationNotFoundError) {
205280
205322
  return httpError(422, error2.message);
205281
205323
  } else if (error2 instanceof ConnectionAuthError) {
@@ -205343,7 +205385,7 @@ function toQueryTextProblem(problem) {
205343
205385
  ...problem.at ? { at: { range: problem.at.range } } : {}
205344
205386
  };
205345
205387
  }
205346
- var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", GENERIC_UNREACHABLE_MESSAGE = "The database connection is down: the database could not be reached, so the query did not run.", GENERIC_AUTH_MESSAGE = "The database rejected the connection's credentials. Check the connection's user, password, key or token.", MAX_LOGGED_DETAIL_CHARS = 2000, FILESYSTEM_ACCESS_DESCRIPTIONS, NODE_CONNECTION_CODES, POSTGRES_CONNECTION_SQLSTATE, CODELESS_CONNECTION_MESSAGES, MYSQL_CONFIG_CODES, POSTGRES_AUTH_SQLSTATE, MYSQL_AUTH_CODES, SNOWFLAKE_AUTH_CODES, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, InvalidModelPathError, RenderTagRefusedError, UnparseableTextError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, SkillNotFoundError, AgentNotFoundError, ConflictError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, ConnectionFailedError, UnconfiguredConnectionError, ConnectionPoolExhaustedError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, ChainedUpstreamMissingError, ChainedShapeNotCarriedError, PublisherConfigError, ServerConfigurationError, FrozenConfigError, AccessDeniedError, QueryCompileError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, WriteRollbackFailedError, WriteVerifyError, InvalidStateTransitionError, ServiceUnavailableError, PackageAdmissionRefusedError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
205388
+ var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", GENERIC_UNREACHABLE_MESSAGE = "The database connection is down: the database could not be reached, so the query did not run.", GENERIC_AUTH_MESSAGE = "The database rejected the connection's credentials. Check the connection's user, password, key or token.", MAX_LOGGED_DETAIL_CHARS = 2000, FILESYSTEM_ACCESS_DESCRIPTIONS, NODE_CONNECTION_CODES, POSTGRES_CONNECTION_SQLSTATE, CODELESS_CONNECTION_MESSAGES, MYSQL_CONFIG_CODES, SQLSERVER_CONNECTION_CODES, TLS_VERIFICATION_CODES, POSTGRES_AUTH_SQLSTATE, MYSQL_AUTH_CODES, SQLSERVER_AUTH_CODES, SNOWFLAKE_AUTH_CODES, POSTGRES_STATEMENT_SQLSTATES, POSTGRES_UNDEFINED_TABLE = "42P01", POSTGRES_RELATION_MISSING, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, InvalidModelPathError, RenderTagRefusedError, UnparseableTextError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, SkillNotFoundError, AgentNotFoundError, ConflictError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, ConnectionFailedError, UnconfiguredConnectionError, ConnectionPoolExhaustedError, DestinationNotFoundError, StorageDestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, ChainedUpstreamMissingError, ChainedShapeNotCarriedError, PublisherConfigError, ServerConfigurationError, FrozenConfigError, AccessDeniedError, QueryCompileError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, WriteRollbackFailedError, WriteVerifyError, InvalidStateTransitionError, ServiceUnavailableError, PackageAdmissionRefusedError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
205347
205389
  var init_errors = __esm(() => {
205348
205390
  init_constants();
205349
205391
  init_logger();
@@ -205369,7 +205411,8 @@ var init_errors = __esm(() => {
205369
205411
  /^(Error: )?Connection lost: The server closed the connection\.$/,
205370
205412
  new RegExp(`^(Error: )?(connect|read|write|getaddrinfo) (${[
205371
205413
  ...NODE_CONNECTION_CODES
205372
- ].join("|")})\\b`)
205414
+ ].join("|")})\\b`),
205415
+ /^Failed to connect to \S+( \(redirected from \S+\))?( in \d+ms| - (?!.*certificate).+)$/i
205373
205416
  ];
205374
205417
  MYSQL_CONFIG_CODES = new Set([
205375
205418
  "ER_ACCESS_DENIED_ERROR",
@@ -205378,9 +205421,46 @@ var init_errors = __esm(() => {
205378
205421
  "AUTH_SWITCH_PLUGIN_ERROR",
205379
205422
  "MYSQL_CLEAR_PASSWORD_NOT_ENABLED"
205380
205423
  ]);
205424
+ SQLSERVER_CONNECTION_CODES = new Set(["ESOCKET", "EINSTLOOKUP"]);
205425
+ TLS_VERIFICATION_CODES = new Set([
205426
+ "CERT_HAS_EXPIRED",
205427
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
205428
+ "ERR_TLS_CERT_ALTNAME_INVALID",
205429
+ "SELF_SIGNED_CERT_IN_CHAIN",
205430
+ "UNABLE_TO_GET_ISSUER_CERT",
205431
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
205432
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE"
205433
+ ]);
205381
205434
  POSTGRES_AUTH_SQLSTATE = /^28[0-9A-Z]{3}$/;
205382
205435
  MYSQL_AUTH_CODES = new Set(["ER_ACCESS_DENIED_ERROR"]);
205436
+ SQLSERVER_AUTH_CODES = new Set(["ELOGIN"]);
205383
205437
  SNOWFLAKE_AUTH_CODES = new Set(["390100", "390144", "390195", "390318"]);
205438
+ POSTGRES_STATEMENT_SQLSTATES = new Set([
205439
+ "42601",
205440
+ "42703",
205441
+ "42P01",
205442
+ "42883",
205443
+ "42704",
205444
+ "42P02",
205445
+ "42702",
205446
+ "42725",
205447
+ "42P09",
205448
+ "42P10",
205449
+ "42804",
205450
+ "42846",
205451
+ "42803",
205452
+ "42P20",
205453
+ "42P18",
205454
+ "42P19",
205455
+ "42712",
205456
+ "42701",
205457
+ "42602",
205458
+ "42622",
205459
+ "42809",
205460
+ "42P21",
205461
+ "42P22"
205462
+ ]);
205463
+ POSTGRES_RELATION_MISSING = /^relation ".*" does not exist$/;
205384
205464
  NotImplementedError = class NotImplementedError extends Error {
205385
205465
  constructor(message) {
205386
205466
  super(message);
@@ -205479,6 +205559,11 @@ var init_errors = __esm(() => {
205479
205559
  super(message);
205480
205560
  }
205481
205561
  };
205562
+ StorageDestinationNotFoundError = class StorageDestinationNotFoundError extends Error {
205563
+ constructor(message) {
205564
+ super(message);
205565
+ }
205566
+ };
205482
205567
  ConnectionAuthError = class ConnectionAuthError extends Error {
205483
205568
  constructor(message) {
205484
205569
  super(message);
@@ -261488,23 +261573,12 @@ var require_bcrypt_pbkdf = __commonJS((exports, module) => {
261488
261573
  };
261489
261574
  });
261490
261575
 
261491
- // ../../node_modules/cpu-features/build/Release/cpufeatures.node
261492
- var require_cpufeatures = __commonJS((exports, module) => {
261493
- module.exports = __require("./cpufeatures-1yrn0vtw.node");
261494
- });
261495
-
261496
- // ../../node_modules/cpu-features/lib/index.js
261497
- var require_lib5 = __commonJS((exports, module) => {
261498
- var binding = require_cpufeatures();
261499
- module.exports = binding.getCPUInfo;
261500
- });
261501
-
261502
261576
  // ../../node_modules/ssh2/lib/protocol/constants.js
261503
261577
  var require_constants12 = __commonJS((exports, module) => {
261504
261578
  var crypto3 = __require("crypto");
261505
261579
  var cpuInfo;
261506
261580
  try {
261507
- cpuInfo = require_lib5()();
261581
+ cpuInfo = (()=>{throw new Error("Cannot require module "+"cpu-features");})()();
261508
261582
  } catch {}
261509
261583
  var { bindingAvailable, CIPHER_INFO, MAC_INFO } = require_crypto();
261510
261584
  var eddsaSupported = (() => {
@@ -262101,6 +262175,11 @@ var require_utils93 = __commonJS((exports, module) => {
262101
262175
  };
262102
262176
  });
262103
262177
 
262178
+ // ../../node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
262179
+ var require_sshcrypto = __commonJS((exports, module) => {
262180
+ module.exports = __require("./sshcrypto-cqb52edz.node");
262181
+ });
262182
+
262104
262183
  // ../../node_modules/ssh2/lib/protocol/crypto/poly1305.js
262105
262184
  var require_poly1305 = __commonJS((exports, module) => {
262106
262185
  var __dirname = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto", __filename = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto/poly1305.js";
@@ -262587,7 +262666,7 @@ var require_crypto = __commonJS((exports, module) => {
262587
262666
  var ChaChaPolyDecipher;
262588
262667
  var GenericDecipher;
262589
262668
  try {
262590
- binding = (()=>{throw new Error("Cannot require module "+"./crypto/build/Release/sshcrypto.node");})();
262669
+ binding = require_sshcrypto();
262591
262670
  ({
262592
262671
  AESGCMCipher,
262593
262672
  ChaChaPolyCipher,
@@ -276142,7 +276221,7 @@ var require_keygen = __commonJS((exports, module) => {
276142
276221
  });
276143
276222
 
276144
276223
  // ../../node_modules/ssh2/lib/index.js
276145
- var require_lib6 = __commonJS((exports, module) => {
276224
+ var require_lib5 = __commonJS((exports, module) => {
276146
276225
  var {
276147
276226
  AgentProtocol,
276148
276227
  BaseAgent,
@@ -276187,7 +276266,7 @@ var require_lib6 = __commonJS((exports, module) => {
276187
276266
  });
276188
276267
 
276189
276268
  // ../../node_modules/webidl-conversions/lib/index.js
276190
- var require_lib7 = __commonJS((exports, module) => {
276269
+ var require_lib6 = __commonJS((exports, module) => {
276191
276270
  var conversions = {};
276192
276271
  module.exports = conversions;
276193
276272
  function sign(x2) {
@@ -277744,7 +277823,7 @@ var require_URL_impl = __commonJS((exports) => {
277744
277823
 
277745
277824
  // ../../node_modules/whatwg-url/lib/URL.js
277746
277825
  var require_URL = __commonJS((exports, module) => {
277747
- var conversions = require_lib7();
277826
+ var conversions = require_lib6();
277748
277827
  var utils = require_utils95();
277749
277828
  var Impl = require_URL_impl();
277750
277829
  var impl = utils.implSymbol;
@@ -277936,7 +278015,7 @@ var require_public_api2 = __commonJS((exports) => {
277936
278015
  });
277937
278016
 
277938
278017
  // ../../node_modules/node-fetch/lib/index.js
277939
- var require_lib8 = __commonJS((exports, module) => {
278018
+ var require_lib7 = __commonJS((exports, module) => {
277940
278019
  Object.defineProperty(exports, "__esModule", { value: true });
277941
278020
  function _interopDefault(ex) {
277942
278021
  return ex && typeof ex === "object" && "default" in ex ? ex["default"] : ex;
@@ -280409,7 +280488,7 @@ var require_gaxios2 = __commonJS((exports) => {
280409
280488
  exports.Gaxios = undefined;
280410
280489
  var extend_1 = __importDefault(require_extend());
280411
280490
  var https_1 = __require("https");
280412
- var node_fetch_1 = __importDefault(require_lib8());
280491
+ var node_fetch_1 = __importDefault(require_lib7());
280413
280492
  var querystring_1 = __importDefault(__require("querystring"));
280414
280493
  var is_stream_1 = __importDefault(require_is_stream());
280415
280494
  var url_1 = __require("url");
@@ -288025,7 +288104,7 @@ var require_stream_events = __commonJS((exports, module) => {
288025
288104
  var require_src140 = __commonJS((exports) => {
288026
288105
  Object.defineProperty(exports, "__esModule", { value: true });
288027
288106
  exports.teenyRequest = exports.RequestError = undefined;
288028
- var node_fetch_1 = require_lib8();
288107
+ var node_fetch_1 = require_lib7();
288029
288108
  var stream_1 = __require("stream");
288030
288109
  var uuid2 = require_dist10();
288031
288110
  var agents_1 = require_agents();
@@ -315019,7 +315098,7 @@ var require_code = __commonJS((exports) => {
315019
315098
  }
315020
315099
  exports._ = _3;
315021
315100
  var plus = new _Code("+");
315022
- function str(strs, ...args) {
315101
+ function str3(strs, ...args) {
315023
315102
  const expr = [safeStringify(strs[0])];
315024
315103
  let i2 = 0;
315025
315104
  while (i2 < args.length) {
@@ -315030,7 +315109,7 @@ var require_code = __commonJS((exports) => {
315030
315109
  optimize(expr);
315031
315110
  return new _Code(expr);
315032
315111
  }
315033
- exports.str = str;
315112
+ exports.str = str3;
315034
315113
  function addCodeArg(code, arg) {
315035
315114
  if (arg instanceof _Code)
315036
315115
  code.push(...arg._items);
@@ -315073,7 +315152,7 @@ var require_code = __commonJS((exports) => {
315073
315152
  return;
315074
315153
  }
315075
315154
  function strConcat(c1, c22) {
315076
- return c22.emptyStr() ? c1 : c1.emptyStr() ? c22 : str`${c1}${c22}`;
315155
+ return c22.emptyStr() ? c1 : c1.emptyStr() ? c22 : str3`${c1}${c22}`;
315077
315156
  }
315078
315157
  exports.strConcat = strConcat;
315079
315158
  function interpolate(x4) {
@@ -316023,22 +316102,22 @@ var require_util11 = __commonJS((exports) => {
316023
316102
  return (0, codegen_1._)`${topSchemaRef}${schemaPath}${(0, codegen_1.getProperty)(keyword)}`;
316024
316103
  }
316025
316104
  exports.schemaRefOrVal = schemaRefOrVal;
316026
- function unescapeFragment(str) {
316027
- return unescapeJsonPointer(decodeURIComponent(str));
316105
+ function unescapeFragment(str3) {
316106
+ return unescapeJsonPointer(decodeURIComponent(str3));
316028
316107
  }
316029
316108
  exports.unescapeFragment = unescapeFragment;
316030
- function escapeFragment(str) {
316031
- return encodeURIComponent(escapeJsonPointer(str));
316109
+ function escapeFragment(str3) {
316110
+ return encodeURIComponent(escapeJsonPointer(str3));
316032
316111
  }
316033
316112
  exports.escapeFragment = escapeFragment;
316034
- function escapeJsonPointer(str) {
316035
- if (typeof str == "number")
316036
- return `${str}`;
316037
- return str.replace(/~/g, "~0").replace(/\//g, "~1");
316113
+ function escapeJsonPointer(str3) {
316114
+ if (typeof str3 == "number")
316115
+ return `${str3}`;
316116
+ return str3.replace(/~/g, "~0").replace(/\//g, "~1");
316038
316117
  }
316039
316118
  exports.escapeJsonPointer = escapeJsonPointer;
316040
- function unescapeJsonPointer(str) {
316041
- return str.replace(/~1/g, "/").replace(/~0/g, "~");
316119
+ function unescapeJsonPointer(str3) {
316120
+ return str3.replace(/~1/g, "/").replace(/~0/g, "~");
316042
316121
  }
316043
316122
  exports.unescapeJsonPointer = unescapeJsonPointer;
316044
316123
  function eachItem(xs2, f5) {
@@ -317022,8 +317101,8 @@ var require_json_schema_traverse = __commonJS((exports, module) => {
317022
317101
  post(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex);
317023
317102
  }
317024
317103
  }
317025
- function escapeJsonPtr(str) {
317026
- return str.replace(/~/g, "~0").replace(/\//g, "~1");
317104
+ function escapeJsonPtr(str3) {
317105
+ return str3.replace(/~/g, "~0").replace(/\//g, "~1");
317027
317106
  }
317028
317107
  });
317029
317108
 
@@ -318119,16 +318198,16 @@ var require_utils96 = __commonJS((exports, module) => {
318119
318198
  isIPV6: true
318120
318199
  };
318121
318200
  }
318122
- function findToken(str, token) {
318201
+ function findToken(str3, token) {
318123
318202
  let ind = 0;
318124
- for (let i2 = 0;i2 < str.length; i2++) {
318125
- if (str[i2] === token)
318203
+ for (let i2 = 0;i2 < str3.length; i2++) {
318204
+ if (str3[i2] === token)
318126
318205
  ind++;
318127
318206
  }
318128
318207
  return ind;
318129
318208
  }
318130
- function removeDotSegments(path20) {
318131
- let input = path20;
318209
+ function removeDotSegments(path21) {
318210
+ let input = path21;
318132
318211
  const output = [];
318133
318212
  let nextSlash = -1;
318134
318213
  let len = 0;
@@ -318528,8 +318607,8 @@ var require_schemes = __commonJS((exports, module) => {
318528
318607
  }
318529
318608
  if (wsComponent.resourceName) {
318530
318609
  const queryIndex = wsComponent.resourceName.indexOf("?");
318531
- const path20 = queryIndex === -1 ? wsComponent.resourceName : wsComponent.resourceName.slice(0, queryIndex);
318532
- wsComponent.path = path20 && path20 !== "/" ? path20 : undefined;
318610
+ const path21 = queryIndex === -1 ? wsComponent.resourceName : wsComponent.resourceName.slice(0, queryIndex);
318611
+ wsComponent.path = path21 && path21 !== "/" ? path21 : undefined;
318533
318612
  wsComponent.query = queryIndex === -1 ? undefined : wsComponent.resourceName.slice(queryIndex + 1);
318534
318613
  wsComponent.resourceName = undefined;
318535
318614
  }
@@ -319092,7 +319171,7 @@ var require_core = __commonJS((exports) => {
319092
319171
  var util_1 = require_util11();
319093
319172
  var $dataRefSchema = require_data();
319094
319173
  var uri_1 = require_uri2();
319095
- var defaultRegExp = (str, flags) => new RegExp(str, flags);
319174
+ var defaultRegExp = (str3, flags) => new RegExp(str3, flags);
319096
319175
  defaultRegExp.code = "new RegExp";
319097
319176
  var META_IGNORE_OPTIONS = ["removeAdditional", "useDefaults", "coerceTypes"];
319098
319177
  var EXT_SCOPE_NAMES = new Set([
@@ -319854,16 +319933,16 @@ var require_multipleOf = __commonJS((exports) => {
319854
319933
  // ../../node_modules/@modelcontextprotocol/sdk/node_modules/ajv/dist/runtime/ucs2length.js
319855
319934
  var require_ucs2length = __commonJS((exports) => {
319856
319935
  Object.defineProperty(exports, "__esModule", { value: true });
319857
- function ucs2length(str) {
319858
- const len = str.length;
319936
+ function ucs2length(str3) {
319937
+ const len = str3.length;
319859
319938
  let length = 0;
319860
319939
  let pos = 0;
319861
319940
  let value;
319862
319941
  while (pos < len) {
319863
319942
  length++;
319864
- value = str.charCodeAt(pos++);
319943
+ value = str3.charCodeAt(pos++);
319865
319944
  if (value >= 55296 && value <= 56319 && pos < len) {
319866
- value = str.charCodeAt(pos);
319945
+ value = str3.charCodeAt(pos);
319867
319946
  if ((value & 64512) === 56320)
319868
319947
  pos++;
319869
319948
  }
@@ -321595,8 +321674,8 @@ var require_formats2 = __commonJS((exports) => {
321595
321674
  }
321596
321675
  var DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
321597
321676
  var DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
321598
- function date4(str) {
321599
- const matches = DATE.exec(str);
321677
+ function date4(str3) {
321678
+ const matches = DATE.exec(str3);
321600
321679
  if (!matches)
321601
321680
  return false;
321602
321681
  const year = +matches[1];
@@ -321615,8 +321694,8 @@ var require_formats2 = __commonJS((exports) => {
321615
321694
  }
321616
321695
  var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
321617
321696
  function getTime(strictTimeZone) {
321618
- return function time3(str) {
321619
- const matches = TIME.exec(str);
321697
+ return function time3(str3) {
321698
+ const matches = TIME.exec(str3);
321620
321699
  if (!matches)
321621
321700
  return false;
321622
321701
  const hr2 = +matches[1];
@@ -321662,8 +321741,8 @@ var require_formats2 = __commonJS((exports) => {
321662
321741
  var DATE_TIME_SEPARATOR = /t|\s/i;
321663
321742
  function getDateTime(strictTimeZone) {
321664
321743
  const time3 = getTime(strictTimeZone);
321665
- return function date_time(str) {
321666
- const dateTime = str.split(DATE_TIME_SEPARATOR);
321744
+ return function date_time(str3) {
321745
+ const dateTime = str3.split(DATE_TIME_SEPARATOR);
321667
321746
  return dateTime.length === 2 && date4(dateTime[0]) && time3(dateTime[1]);
321668
321747
  };
321669
321748
  }
@@ -321688,13 +321767,13 @@ var require_formats2 = __commonJS((exports) => {
321688
321767
  }
321689
321768
  var NOT_URI_FRAGMENT = /\/|:/;
321690
321769
  var URI = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
321691
- function uri(str) {
321692
- return NOT_URI_FRAGMENT.test(str) && URI.test(str);
321770
+ function uri(str3) {
321771
+ return NOT_URI_FRAGMENT.test(str3) && URI.test(str3);
321693
321772
  }
321694
321773
  var BYTE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gm;
321695
- function byte(str) {
321774
+ function byte(str3) {
321696
321775
  BYTE.lastIndex = 0;
321697
- return BYTE.test(str);
321776
+ return BYTE.test(str3);
321698
321777
  }
321699
321778
  var MIN_INT32 = -(2 ** 31);
321700
321779
  var MAX_INT32 = 2 ** 31 - 1;
@@ -321708,11 +321787,11 @@ var require_formats2 = __commonJS((exports) => {
321708
321787
  return true;
321709
321788
  }
321710
321789
  var Z_ANCHOR = /[^\\]\\Z/;
321711
- function regex(str) {
321712
- if (Z_ANCHOR.test(str))
321790
+ function regex(str3) {
321791
+ if (Z_ANCHOR.test(str3))
321713
321792
  return false;
321714
321793
  try {
321715
- new RegExp(str);
321794
+ new RegExp(str3);
321716
321795
  return true;
321717
321796
  } catch (e2) {
321718
321797
  return false;
@@ -321789,7 +321868,7 @@ var require_code3 = __commonJS((exports) => {
321789
321868
  }
321790
321869
  exports._ = _3;
321791
321870
  var plus = new _Code("+");
321792
- function str(strs, ...args) {
321871
+ function str3(strs, ...args) {
321793
321872
  const expr = [safeStringify(strs[0])];
321794
321873
  let i2 = 0;
321795
321874
  while (i2 < args.length) {
@@ -321800,7 +321879,7 @@ var require_code3 = __commonJS((exports) => {
321800
321879
  optimize(expr);
321801
321880
  return new _Code(expr);
321802
321881
  }
321803
- exports.str = str;
321882
+ exports.str = str3;
321804
321883
  function addCodeArg(code, arg) {
321805
321884
  if (arg instanceof _Code)
321806
321885
  code.push(...arg._items);
@@ -321843,7 +321922,7 @@ var require_code3 = __commonJS((exports) => {
321843
321922
  return;
321844
321923
  }
321845
321924
  function strConcat(c1, c22) {
321846
- return c22.emptyStr() ? c1 : c1.emptyStr() ? c22 : str`${c1}${c22}`;
321925
+ return c22.emptyStr() ? c1 : c1.emptyStr() ? c22 : str3`${c1}${c22}`;
321847
321926
  }
321848
321927
  exports.strConcat = strConcat;
321849
321928
  function interpolate(x4) {
@@ -322793,22 +322872,22 @@ var require_util12 = __commonJS((exports) => {
322793
322872
  return (0, codegen_1._)`${topSchemaRef}${schemaPath}${(0, codegen_1.getProperty)(keyword)}`;
322794
322873
  }
322795
322874
  exports.schemaRefOrVal = schemaRefOrVal;
322796
- function unescapeFragment(str) {
322797
- return unescapeJsonPointer(decodeURIComponent(str));
322875
+ function unescapeFragment(str3) {
322876
+ return unescapeJsonPointer(decodeURIComponent(str3));
322798
322877
  }
322799
322878
  exports.unescapeFragment = unescapeFragment;
322800
- function escapeFragment(str) {
322801
- return encodeURIComponent(escapeJsonPointer(str));
322879
+ function escapeFragment(str3) {
322880
+ return encodeURIComponent(escapeJsonPointer(str3));
322802
322881
  }
322803
322882
  exports.escapeFragment = escapeFragment;
322804
- function escapeJsonPointer(str) {
322805
- if (typeof str == "number")
322806
- return `${str}`;
322807
- return str.replace(/~/g, "~0").replace(/\//g, "~1");
322883
+ function escapeJsonPointer(str3) {
322884
+ if (typeof str3 == "number")
322885
+ return `${str3}`;
322886
+ return str3.replace(/~/g, "~0").replace(/\//g, "~1");
322808
322887
  }
322809
322888
  exports.escapeJsonPointer = escapeJsonPointer;
322810
- function unescapeJsonPointer(str) {
322811
- return str.replace(/~1/g, "/").replace(/~0/g, "~");
322889
+ function unescapeJsonPointer(str3) {
322890
+ return str3.replace(/~1/g, "/").replace(/~0/g, "~");
322812
322891
  }
322813
322892
  exports.unescapeJsonPointer = unescapeJsonPointer;
322814
322893
  function eachItem(xs2, f5) {
@@ -324642,7 +324721,7 @@ var require_core3 = __commonJS((exports) => {
324642
324721
  var util_1 = require_util12();
324643
324722
  var $dataRefSchema = require_data2();
324644
324723
  var uri_1 = require_uri3();
324645
- var defaultRegExp = (str, flags) => new RegExp(str, flags);
324724
+ var defaultRegExp = (str3, flags) => new RegExp(str3, flags);
324646
324725
  defaultRegExp.code = "new RegExp";
324647
324726
  var META_IGNORE_OPTIONS = ["removeAdditional", "useDefaults", "coerceTypes"];
324648
324727
  var EXT_SCOPE_NAMES = new Set([
@@ -325404,16 +325483,16 @@ var require_multipleOf2 = __commonJS((exports) => {
325404
325483
  // ../../node_modules/ajv-formats/node_modules/ajv/dist/runtime/ucs2length.js
325405
325484
  var require_ucs2length2 = __commonJS((exports) => {
325406
325485
  Object.defineProperty(exports, "__esModule", { value: true });
325407
- function ucs2length(str) {
325408
- const len = str.length;
325486
+ function ucs2length(str3) {
325487
+ const len = str3.length;
325409
325488
  let length = 0;
325410
325489
  let pos = 0;
325411
325490
  let value;
325412
325491
  while (pos < len) {
325413
325492
  length++;
325414
- value = str.charCodeAt(pos++);
325493
+ value = str3.charCodeAt(pos++);
325415
325494
  if (value >= 55296 && value <= 56319 && pos < len) {
325416
- value = str.charCodeAt(pos);
325495
+ value = str3.charCodeAt(pos);
325417
325496
  if ((value & 64512) === 56320)
325418
325497
  pos++;
325419
325498
  }
@@ -338983,6 +339062,7 @@ import {
338983
339062
  buildPoolOptions,
338984
339063
  SnowflakeConnection
338985
339064
  } from "@malloydata/db-snowflake";
339065
+ import"@malloydata/db-sqlserver";
338986
339066
  import"@malloydata/db-trino";
338987
339067
  import {
338988
339068
  contextOverlay,
@@ -344267,6 +344347,7 @@ var DOUBLED_ESCAPE_DIALECTS = new Set([
344267
344347
  "motherduck",
344268
344348
  "postgres",
344269
344349
  "publisher",
344350
+ "sqlserver",
344270
344351
  "trino"
344271
344352
  ]);
344272
344353
  function sqlLiteral(value, connectionType) {
@@ -344603,7 +344684,7 @@ async function listCloudDirectorySchemas(credentials) {
344603
344684
 
344604
344685
  // src/service/proxy.ts
344605
344686
  init_logger();
344606
- var import_ssh2 = __toESM(require_lib6(), 1);
344687
+ var import_ssh2 = __toESM(require_lib5(), 1);
344607
344688
  import net from "net";
344608
344689
  var ALLOW_UNVERIFIED_SSH_HOST_KEY_ENV = "PUBLISHER_ALLOW_UNVERIFIED_SSH_HOST_KEY";
344609
344690
  function allowUnverifiedHostKey() {
@@ -345119,6 +345200,13 @@ function getStaticConnectionAttributes(type) {
345119
345200
  canPersist: true,
345120
345201
  canStream: false
345121
345202
  };
345203
+ case "sqlserver":
345204
+ return {
345205
+ dialectName: "sqlserver",
345206
+ isPool: true,
345207
+ canPersist: false,
345208
+ canStream: true
345209
+ };
345122
345210
  case "duckdb":
345123
345211
  case "motherduck":
345124
345212
  case "ducklake":
@@ -345265,6 +345353,20 @@ function validateConnectionShape(connection) {
345265
345353
  case "postgres":
345266
345354
  case "mysql":
345267
345355
  break;
345356
+ case "sqlserver": {
345357
+ const sqlserver = connection.sqlserverConnection;
345358
+ if (!sqlserver) {
345359
+ throw new Error("SQL Server connection configuration is missing.");
345360
+ }
345361
+ const port = sqlserver.port;
345362
+ if (port !== undefined && port !== null && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) {
345363
+ throw new Error(`Connection '${connection.name}' has an invalid sqlserverConnection.port ` + `${JSON.stringify(port)} (expected an integer from 1 to 65535).`);
345364
+ }
345365
+ if (sqlserver.instanceName && sqlserver.port) {
345366
+ logger.warn(`Connection '${connection.name}' sets both sqlserverConnection.instanceName ` + `and port; the instance is located through SQL Browser and the port is not used.`);
345367
+ }
345368
+ break;
345369
+ }
345268
345370
  case "bigquery": {
345269
345371
  const bigquery = connection.bigqueryConnection;
345270
345372
  if (bigquery?.impersonateServiceAccount) {
@@ -345512,6 +345614,9 @@ function canonicalizeJsonValue(value) {
345512
345614
  }
345513
345615
  return sorted;
345514
345616
  }
345617
+ function blankAsUnset(value) {
345618
+ return value === "" ? undefined : value;
345619
+ }
345515
345620
  function assembleEnvironmentConnections(connections = [], environmentPath = "") {
345516
345621
  const pojo = { connections: {} };
345517
345622
  const metadata = new Map;
@@ -345574,6 +345679,22 @@ function assembleEnvironmentConnections(connections = [], environmentPath = "")
345574
345679
  };
345575
345680
  break;
345576
345681
  }
345682
+ case "sqlserver": {
345683
+ const sqlserver = connection.sqlserverConnection;
345684
+ pojo.connections[connection.name] = {
345685
+ is: "sqlserver",
345686
+ server: sqlserver?.server,
345687
+ port: sqlserver?.port,
345688
+ database: sqlserver?.database,
345689
+ user: sqlserver?.user,
345690
+ password: sqlserver?.password,
345691
+ encrypt: sqlserver?.encrypt,
345692
+ trustServerCertificate: sqlserver?.trustServerCertificate,
345693
+ hostNameInCertificate: blankAsUnset(sqlserver?.hostNameInCertificate),
345694
+ instanceName: blankAsUnset(sqlserver?.instanceName)
345695
+ };
345696
+ break;
345697
+ }
345577
345698
  case "bigquery": {
345578
345699
  const serviceAccountKey = parseServiceAccountKey(connection.bigqueryConnection?.serviceAccountKeyJson);
345579
345700
  const impersonateServiceAccount = connection.bigqueryConnection?.impersonateServiceAccount;
@@ -346369,6 +346490,9 @@ async function attachDuckLake(connection, dbName, ducklakeConfig) {
346369
346490
  readOnly: true
346370
346491
  });
346371
346492
  }
346493
+ async function attachDuckLakeReadOnly(connection, dbName, ducklakeConfig) {
346494
+ await attachDuckLake(connection, dbName, ducklakeConfig);
346495
+ }
346372
346496
  async function attachDuckLakeReadWrite(connection, dbName, ducklakeConfig) {
346373
346497
  await attachDuckLakeWithMode(connection, dbName, ducklakeConfig, {
346374
346498
  readOnly: false
@@ -347507,6 +347631,16 @@ var MYSQL = {
347507
347631
  database: "scalar",
347508
347632
  user: "scalar"
347509
347633
  };
347634
+ var SQLSERVER = {
347635
+ server: "scalar",
347636
+ port: "scalar",
347637
+ database: "scalar",
347638
+ user: "scalar",
347639
+ encrypt: "scalar",
347640
+ trustServerCertificate: "scalar",
347641
+ hostNameInCertificate: "scalar",
347642
+ instanceName: "scalar"
347643
+ };
347510
347644
  var MOTHERDUCK = { database: "scalar" };
347511
347645
  var PUBLISHER = { connectionUri: "scalar" };
347512
347646
  var S3 = {
@@ -347572,6 +347706,7 @@ var PUBLIC_CONNECTION = {
347572
347706
  trinoConnection: TRINO,
347573
347707
  databricksConnection: DATABRICKS,
347574
347708
  mysqlConnection: MYSQL,
347709
+ sqlserverConnection: SQLSERVER,
347575
347710
  duckdbConnection: DUCKDB,
347576
347711
  motherduckConnection: MOTHERDUCK,
347577
347712
  ducklakeConnection: DUCKLAKE,
@@ -347585,6 +347720,7 @@ var PUBLIC_FIELDS_BY_SCHEMA = {
347585
347720
  TrinoConnection: Object.keys(TRINO),
347586
347721
  DatabricksConnection: Object.keys(DATABRICKS),
347587
347722
  MysqlConnection: Object.keys(MYSQL),
347723
+ SqlserverConnection: Object.keys(SQLSERVER),
347588
347724
  DuckdbConnection: Object.keys(DUCKDB),
347589
347725
  MotherDuckConnection: Object.keys(MOTHERDUCK),
347590
347726
  DucklakeConnection: Object.keys(DUCKLAKE),
@@ -348077,6 +348213,27 @@ async function getSchemasForPostgres(connection, malloyConnection) {
348077
348213
  throw new Error(`Failed to get schemas for Postgres connection ${connection.name}: ${error2.message}`, { cause: error2 });
348078
348214
  }
348079
348215
  }
348216
+ async function getSchemasForSQLServer(connection, malloyConnection) {
348217
+ if (!connection.sqlserverConnection) {
348218
+ throw new Error("SQL Server connection is required");
348219
+ }
348220
+ try {
348221
+ const result = await runIntrospectionSQL(malloyConnection, "SELECT DISTINCT table_schema AS schema_name FROM information_schema.tables ORDER BY table_schema");
348222
+ const rows = standardizeRunSQLResult2(result);
348223
+ return rows.map((row) => {
348224
+ const typedRow = row;
348225
+ const schemaName = String(typedRow.schema_name ?? typedRow.SCHEMA_NAME ?? "");
348226
+ return {
348227
+ name: schemaName,
348228
+ isHidden: false,
348229
+ isDefault: schemaName === "dbo"
348230
+ };
348231
+ });
348232
+ } catch (error2) {
348233
+ logger.error(`Error getting schemas for SQL Server connection ${connection.name}`, { error: error2 });
348234
+ throw new Error(`Failed to get schemas for SQL Server connection ${connection.name}: ${error2.message}`, { cause: error2 });
348235
+ }
348236
+ }
348080
348237
  async function getSchemasForMySQL(connection) {
348081
348238
  if (!connection.mysqlConnection) {
348082
348239
  throw new Error("Mysql connection is required");
@@ -348426,6 +348583,8 @@ async function getSchemasForConnection(connection, malloyConnection) {
348426
348583
  return getSchemasForPostgres(connection, malloyConnection);
348427
348584
  case "mysql":
348428
348585
  return getSchemasForMySQL(connection);
348586
+ case "sqlserver":
348587
+ return getSchemasForSQLServer(connection, malloyConnection);
348429
348588
  case "snowflake":
348430
348589
  return getSchemasForSnowflake(connection, malloyConnection);
348431
348590
  case "trino":
@@ -348613,6 +348772,8 @@ async function listTablesForSchema(connection, schemaName, malloyConnection, tab
348613
348772
  return listTablesForMySQL(connection, schemaName, malloyConnection, tableNames);
348614
348773
  case "postgres":
348615
348774
  return listTablesForPostgres(connection, schemaName, malloyConnection, tableNames);
348775
+ case "sqlserver":
348776
+ return listTablesForSQLServer(connection, schemaName, malloyConnection, tableNames);
348616
348777
  case "snowflake":
348617
348778
  return listTablesForSnowflake(connection, schemaName, malloyConnection, tableNames);
348618
348779
  case "trino":
@@ -348681,6 +348842,27 @@ async function listTablesForMySQL(connection, schemaName, malloyConnection, tabl
348681
348842
  throw new Error(`Failed to get tables for MySQL schema ${schemaName} in connection ${connection.name}: ${error2.message}`, { cause: error2 });
348682
348843
  }
348683
348844
  }
348845
+ function sqlserverNLiteral(value) {
348846
+ return `N'${sqlLiteral(value, "sqlserver")}'`;
348847
+ }
348848
+ function sqlserverInFilter(columnName, values) {
348849
+ if (!values || values.length === 0)
348850
+ return "";
348851
+ return `AND ${columnName} IN (${values.map(sqlserverNLiteral).join(", ")})`;
348852
+ }
348853
+ async function listTablesForSQLServer(connection, schemaName, malloyConnection, tableNames) {
348854
+ if (!connection.sqlserverConnection) {
348855
+ throw new Error("SQL Server connection is required");
348856
+ }
348857
+ try {
348858
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = ${sqlserverNLiteral(schemaName)} ${sqlserverInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`);
348859
+ const rows = standardizeRunSQLResult2(result);
348860
+ return groupColumnRowsIntoTables(rows, (t2) => `${schemaName}.${t2}`);
348861
+ } catch (error2) {
348862
+ logger.error(`Error getting tables for SQL Server schema ${schemaName} in connection ${connection.name}`, { error: error2 });
348863
+ throw new Error(`Failed to get tables for SQL Server schema ${schemaName} in connection ${connection.name}: ${error2.message}`, { cause: error2 });
348864
+ }
348865
+ }
348684
348866
  async function listTablesForPostgres(connection, schemaName, malloyConnection, tableNames) {
348685
348867
  if (!connection.postgresConnection) {
348686
348868
  throw new Error("Postgres connection is required");
@@ -349001,12 +349183,12 @@ function classifyDriverFailure(error2) {
349001
349183
  return error2;
349002
349184
  }
349003
349185
  const message = error2 instanceof Error ? error2.message : typeof error2 === "string" ? error2 : JSON.stringify(error2);
349004
- return databaseAccessFailure(error2) ?? driverErrorToPublisherError(message);
349186
+ return databaseAccessFailure(error2) ?? statementRejection(error2) ?? driverErrorToPublisherError(message);
349005
349187
  }
349006
349188
  function sqlRunFailure(error2) {
349007
349189
  if (error2 instanceof ConnectionError)
349008
349190
  return error2;
349009
- return databaseAccessFailure(error2) ?? new ConnectionError(error2.message);
349191
+ return databaseAccessFailure(error2) ?? statementRejection(error2) ?? new ConnectionError(error2.message);
349010
349192
  }
349011
349193
  async function withDatabaseAccessClassified(list) {
349012
349194
  try {
@@ -349096,8 +349278,11 @@ class ConnectionController {
349096
349278
  throw new TableNotFoundError(`Table ${tablePath} not found`);
349097
349279
  }
349098
349280
  if (typeof source === "string") {
349099
- const prefix = `Error fetching schema for ${tablePath}: `;
349100
- const accessFailure = source.startsWith(prefix) ? databaseAccessFailure(new Error(source.slice(prefix.length))) : undefined;
349281
+ if (source === `Table ${tablePath} not found`) {
349282
+ throw new TableNotFoundError(source);
349283
+ }
349284
+ const driverMessage = lookupFailureDriverMessage(source, tablePath);
349285
+ const accessFailure = driverMessage === undefined ? undefined : databaseAccessFailure(new Error(driverMessage));
349101
349286
  throw accessFailure ?? driverErrorToPublisherError(source);
349102
349287
  }
349103
349288
  return {
@@ -349376,6 +349561,16 @@ class ConnectionController {
349376
349561
  };
349377
349562
  }
349378
349563
  }
349564
+ function lookupFailureDriverMessage(source, tablePath) {
349565
+ const prefix = `Error fetching schema for ${tablePath}: `;
349566
+ if (source.startsWith(prefix))
349567
+ return source.slice(prefix.length);
349568
+ const sqlserverPrefix = `Error fetching schema for table ${tablePath}: `;
349569
+ if (source.startsWith(sqlserverPrefix)) {
349570
+ return source.slice(sqlserverPrefix.length).replace(/^\d+: /, "");
349571
+ }
349572
+ return;
349573
+ }
349379
349574
 
349380
349575
  // src/controller/report.controller.ts
349381
349576
  init_errors();
@@ -351499,7 +351694,7 @@ with both queries. Otherwise it is still \`NOT-RETURNED\` / \`LOW-RANK\`.
351499
351694
  Use when the entity was found and used, and the definition or the data behind it
351500
351695
  is wrong (bad grain, wrong join key, inverted filter). Owner: model. A doc whose
351501
351696
  factual claim the data contradicts (a population statement, a grain claim) is
351502
- also model-definition: the SQL may be right while the stated contract is false,
351697
+ also model-definition: the field may compute correctly while its doc is false,
351503
351698
  and an agent that trusts the doc answers wrongly without ever failing a query.
351504
351699
  Probe the claim before writing the issue.
351505
351700
 
@@ -352213,7 +352408,7 @@ trusting a name that looks familiar, and write down the mapping you used in
352213
352408
  |---|---|---|
352214
352409
  | \`prompt\`, \`query\`, \`input\`, \`nl_question\` | the question | \`query\` is the trap: in half of these it is the SQL or Malloy they ran, not the question |
352215
352410
  | \`answer\`, \`expected\`, \`output\`, \`ground_truth\` | a golden value, prose, or both mixed in one string | a paragraph under \`answer\` is criteria, not a value. Classify it, do not store it as a number |
352216
- | \`sql\`, \`malloy\`, \`gold_query\` | their derivation | run it (\`SKILL.md\` step 3). This is the field that upgrades a typed number into something checkable |
352411
+ | \`sql\`, \`malloy\`, \`gold_query\` | their derivation | Malloy: run it (\`SKILL.md\` step 3). SQL: keep it as evidence of how they got the number, but do not run it; derive the number in Malloy over the truth package instead. Either way, this is the field that upgrades a typed number into something checkable |
352217
352412
  | \`criteria\`, \`rubric\`, \`assertions\`, \`must_include\` | prose criteria | apply the query test in \`SKILL.md\` step 4 to EACH one, not to the field |
352218
352413
  | \`id\`, \`case_id\`, \`uuid\` | their id | keep it, prefixed. Two sets from one customer collide otherwise |
352219
352414
  | \`difficulty\`, \`category\`, \`tags\` | their own labels | keep as-is on the case for slicing. Never let one become \`split\` |
@@ -352376,7 +352571,7 @@ data with no question in front of them?* Reject:
352376
352571
 
352377
352572
  - a field that hard-codes this question's filter and serves no other question
352378
352573
  - this question's text, qid, or expected numbers in a doc, comment, or name
352379
- - a join copied from gold SQL that you have not probed as a real relationship
352574
+ - a join copied from a golden's query that you have not probed as a real relationship
352380
352575
 
352381
352576
  The golden is a hypothesis source. The data is still the verifier.
352382
352577
 
@@ -352385,12 +352580,16 @@ The golden is a hypothesis source. The data is still the verifier.
352385
352580
  Every structural claim needs a query you ran: join key, primary key, filter
352386
352581
  value existence, snapshot assumption, value space, cardinality.
352387
352582
 
352388
- \`\`\`sql
352389
- SELECT COUNT(*), COUNT(DISTINCT col) FROM t;
352390
- SELECT a.k, COUNT(*) FROM a JOIN b ON … GROUP BY 1 ORDER BY 2 DESC;
352391
- SELECT col, COUNT(*) FROM t GROUP BY 1 ORDER BY 2 DESC LIMIT 5;
352583
+ \`\`\`malloy
352584
+ run: t -> { aggregate: row_count is count(), distinct_keys is count(col) }
352585
+ run: b -> { group_by: k; aggregate: n is count(); order_by: n desc; limit: 5 }
352586
+ run: t -> { group_by: col; aggregate: n is count(); order_by: n desc; limit: 5 }
352392
352587
  \`\`\`
352393
352588
 
352589
+ Run probes in Malloy, against the raw tables (the truth package, or a bare
352590
+ \`connection.table()\` source), never as SQL. The second line is the join probe:
352591
+ any \`n\` above 1 means \`k\` is not a key on \`b\`, so a \`join_one\` on it fans out.
352592
+
352394
352593
  A false \`primary_key\` compiles and silently corrupts every aggregate. Of one
352395
352594
  pilot's 11 accepted edits, 4 of 5 wrong ones died to a single
352396
352595
  \`COUNT(*)\` vs \`COUNT(DISTINCT …)\` probe that was never run.
@@ -352735,7 +352934,7 @@ Output, exactly this shape:
352735
352934
  plainly the answer explained itself, and however the rubric is worded. Say
352736
352935
  in \`why\` that you are overriding a rubric clause, so the clause gets fixed.
352737
352936
  This rule exists because a set shipped one: a question asked for sales over
352738
- the company's season, the answer gave the meteorological window 25% lower
352937
+ the company's season, the answer gave the meteorological window 20% lower
352739
352938
  and said which window it used, and a clause granting \`near_match\` for a
352740
352939
  stated window kept a materially wrong answer out of the pass rate
352741
352940
  entirely -- the arm reported 100%.
@@ -355550,7 +355749,7 @@ The agent orchestrates these. Each query and chart step hands off to a focused s
355550
355749
  2. READ THE MODEL. Get the model's real source and view names, through your environment's context tool if it has one, or by opening the \`.malloy\` file directly. Never guess field or view names.
355551
355750
  3. SCAFFOLD the package (template below).
355552
355751
  4. WRITE THE QUERIES. Read \`reference/runtime.md\`. Validate each before pasting it into the page, using whatever query tool your environment provides or a running Publisher (see \`reference/runtime.md\`). Malloy syntax questions go to \`skill:malloy-queries\`.
355553
- 5. CHOOSE CHARTS with \`skill:malloy-charts\` when rendering through \`<malloy-render>\`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into \`public/\` and load it locally, not from a CDN. Two reasons: embedded author JavaScript runs with the viewing user's data authority, and a blocked CDN (agent sandboxes and many corporate networks block them) is easy to miss, because the script never runs and the charts come up empty. The \`storefront\` example ships its chart library in \`public/vendor/\` and loads it from \`public/index.html\` as \`./vendor/chart.umd.js\`. Copy that, but resolve the path against the page's own directory: a page in a subdirectory (\`public/reports/index.html\`) needs \`../vendor/chart.umd.js\`. A wrong relative path 404s and leaves the charts blank, which is the failure you are trying to avoid.
355752
+ 5. CHOOSE CHARTS with \`skill:malloy-charts\` when rendering through \`<malloy-render>\`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into \`public/\` and load it locally, not from a CDN. Two reasons: embedded author JavaScript runs with the viewing user's data authority, and a blocked CDN (agent sandboxes and many corporate networks block them) is easy to miss, because the script never runs and the charts come up empty. The \`storefront\` example ships its chart library in \`public/vendor/\` and loads it from \`public/index.html\` as \`./vendor/chart.umd.js\`. Copy its vendoring, not its loading (it uses a plain \`<script>\`, which the next sentences rule out), and resolve the path against the page's own directory: a page in a subdirectory (\`public/reports/index.html\`) needs \`../vendor/chart.umd.js\`. A wrong relative path 404s and leaves the charts blank, which is the failure you are trying to avoid. Load it off the critical path (\`<link rel="preload">\`, then injected on the first chart), not as a plain \`<script>\` ahead of the app: it is usually most of the page's weight and would hold every query until it has parsed (\`reference/performance.md\`).
355554
355753
  6. EMBED (optional). Read \`reference/embedding.md\`.
355555
355754
  7. PREVIEW with the local authoring loop (below).
355556
355755
  8. VERIFY before you call it done (see "What 'done' means" below). This step is not optional.
@@ -355570,6 +355769,9 @@ A data app you can defend has all of these. Build to this list, not to the scaff
355570
355769
  - **Visible assumptions.** When you assume something (two sources joined by month, an in-month proxy that differs from a certified definition) or a metric is incomplete, say so *in the app*: a caption, a footnote, a placeholder card with the reason. The non-technical user cannot see your reasoning; bury a caveat and you have misled them. Don't silently drop a metric you couldn't model. Show a placeholder that names what's missing and why.
355571
355770
  - **Built to the design brief, not to the model's shape.** The app matches the archetype, forms, and depth plan from step 1 (\`reference/design.md\`), carries a real token contract (surfaces, text ramp, semantic colors, a categorical chart palette) that the chart code reads at runtime rather than hardcoding, and puts visible weight on the two or three numbers that lead. A uniform grid of same-size cards, or a tile per view, means the brief was skipped.
355572
355771
  - **Vendored libraries.** Chart and helper libraries live in \`public/\`, loaded locally (step 5).
355772
+ - **Light, and nothing waits on the chart library.** Pages that draw charts preload the library and load it on first use; a page that draws none never loads it. Trim it to the series types the app draws once it is big enough to matter, and verify a trimmed build by reading pixels, because a missing type draws an empty box without an error. One query text per resource, so any cache or prefetch actually hits. (\`reference/performance.md\`, which also has two opt-in recipes, answering from the last visit and starting a drawer's queries on intent, each with the conditions it needs.)
355773
+ - **Holds still while it loads.** Script-filled blocks reserve their final size in the HTML, skeletons are sized from what is coming, settled tile heights are remembered for the next visit, and a tile re-running for a new filter keeps its last answer dimmed instead of collapsing. Target: layout shift under 0.1 on a first visit and 0 on a repeat, embedded as well as standalone. (\`reference/performance.md\`.)
355774
+ - **Works embedded.** Inside an auto-sized frame there is no viewport: the frame never scrolls, nothing extends past the content, and anything that floats (a drawer, a tray) is placed over the slice of the page the reader can see, keeps its scroll to itself, and returns the reader exactly where they were. A drawer built the standalone way opens at the top of the document and drags the host page up to meet it. (\`reference/embedding.md\`, \`reference/overlays.md\`.)
355573
355775
  - **Lazy-load below the fold, once the page has more than about eight query-backed tiles.** ("Many" is not decidable; that is the threshold.) Tiles sitting below the fold is *why* the technique works, not a second trigger for it - almost every real data app scrolls, so reading it as one makes the rule "always" and contradicts the count. Under that threshold, firing every tile at once is cheaper than the machinery. Don't fire every tile's query on load. \`reference/lazy-load.md\` is the recipe: \`IntersectionObserver\` (rootMargin ~240px) + a small concurrency cap + reserve each tile's height so lazy tiles don't reflow. Includes the verification trap - on a short/tall-default viewport all tiles intersect at once and you get a false "everything deferred" pass, so test on a deliberately small viewport.
355574
355776
 
355575
355777
  ### Verify before you call it done
@@ -355578,6 +355780,7 @@ You are building for someone who cannot tell a correct dashboard from a broken o
355578
355780
 
355579
355781
  - **Validate every query against the model before wiring it in** (step 4): confirm it compiles and that the column names match what your render code reads.
355580
355782
  - **Load the finished page and confirm every tile shows real numbers**: not stuck on "Loading…", not an error, not an empty state you didn't intend. In a headless browser, wait on \`load\` plus a content selector, not network idle (\`publisher.js\` holds an SSE stream open; see \`reference/runtime.md\`). Don't hand-roll this each time - \`reference/verification-harness.md\` is a copy-adaptable recipe: a mock \`sdk/publisher.js\` returning canned rows keyed by \`(model, query)\`, a \`python3 -m http.server\` webroot, and Playwright assertions (KPIs non-null, no \`.is-error\`, no stuck \`.kit-skeleton\`, a chart/table present). It also documents the false-"stuck skeleton" trap (assert after the mock's async delay, never on \`networkidle\`).
355783
+ - **Check it embedded, for stability, and through its drawers.** Load each page inside the Publisher console (\`/<env>/<package>/data-apps/<path>\`) as well as on its own, measure layout shift on a first and a repeat visit, confirm the app never scrolls inside its frame, and open a drawer from well down the page after scrolling the host with real wheel events: the page behind must not move, the drawer must span exactly the visible area, and its scroll must stay in it. \`reference/verification-harness.md\` section 4 has the checks. A tile check alone passed on a build where every one of these was broken.
355581
355784
  - **Unit-test any non-trivial pure logic** (a month-join, a de-cumulation, a unit conversion). Keep that logic in DOM-free helpers so \`node --test\` can cover it, and run it.
355582
355785
 
355583
355786
  ## Minimal scaffold
@@ -355682,7 +355885,7 @@ Publishing an app is publishing its package: get the package into publishable sh
355682
355885
 
355683
355886
  This skill's \`reference/\` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read \`reference/<name>.md\`, get the prompt named \`malloy-html-data-apps/<name>\` instead.
355684
355887
 
355685
- Available: archetypes, depth-patterns, design, embedding, lazy-load, runtime, token-contract, verification-harness, visual-craft.`
355888
+ Available: archetypes, depth-patterns, design, embedding, lazy-load, overlays, performance, runtime, token-contract, verification-harness, visual-craft.`
355686
355889
  },
355687
355890
  {
355688
355891
  name: "malloy-html-data-apps/archetypes",
@@ -355864,7 +356067,7 @@ Write down that you did this and why. A reader who later sees hand-built drill q
355864
356067
 
355865
356068
  ## Drill-down: the panel side
355866
356069
 
355867
- An entity drawer has six parts. Skipping any one produces a bug that only shows up in use, not in a screenshot.
356070
+ An entity drawer has six parts, and four more decide whether it feels finished. Skipping any one produces a bug that only shows up in use, not in a screenshot.
355868
356071
 
355869
356072
  \`\`\`js
355870
356073
  const drill = { entity: null, charts: [], returnFocus: null };
@@ -355894,7 +356097,7 @@ function closeDrill() {
355894
356097
  document.removeEventListener("keydown", onTab);
355895
356098
  drill.charts.forEach((c) => c.destroy()); // 4. tear charts down
355896
356099
  drill.charts = [];
355897
- drill.returnFocus?.focus(); // 5. give focus back
356100
+ drill.returnFocus?.focus({ preventScroll: true }); // 5. give focus back, without scrolling
355898
356101
  }
355899
356102
  \`\`\`
355900
356103
 
@@ -355907,6 +356110,13 @@ What each part prevents:
355907
356110
  5. **Chart teardown** - most chart libraries keep a registry and attach resize listeners; reopening a drawer twenty times without destroying leaks memory and can leave ghost tooltips from the old instance.
355908
356111
  6. Also re-render the drawer when the global filter changes underneath it, or it silently shows numbers for a period the rest of the page has moved off.
355909
356112
 
356113
+ The four that separate a working drawer from a polished one:
356114
+
356115
+ 7. **Open on the click, resolve inside.** When the drawer needs a lookup before it can query (a name resolved to an id), open it at once with the name as its title and a skeleton, and swap the content in when the id arrives, under the stale-response guard. Waiting for the lookup before opening anything makes the click look dead for as long as the query takes.
356116
+ 8. **Drilling from a drawer replaces it in place.** A product opened from inside a market drawer swaps the drawer's title and body without a second slide-in, and the new subject reads from its top: reset the body's \`scrollTop\` to 0, or it opens wherever the reader had scrolled the previous one.
356117
+ 9. **The panel body is its only scroller, and its scroll stays in it.** Wheel and touch inside the drawer scroll the drawer; nothing scrolls the page behind it, over the scrim either, and closing returns the reader to exactly where they were. On a real build the wheel inside an open drawer scrolled the page behind it by 2,000 to 3,000px, and closing left the reader that far from the row they had clicked.
356118
+ 10. **It lands on the reader's screen when embedded.** Inside an auto-sized frame \`position: fixed\` means the top of the whole document, so a drawer built the standalone way opens far above the reader and drags the host page up to meet it. \`reference/overlays.md\` is the recipe for 9 and 10: finding the visible band, one clipped layer over it, scroll containment, and focus that does not scroll.
356119
+
355910
356120
  ## Cross-filtering (shared scope)
355911
356121
 
355912
356122
  Clicking a category filters the other tiles. Express scope as a \`where:\` refinement on the **same named view** each tile already runs, so a filtered tile and an unfiltered one still share one definition.
@@ -356136,6 +356346,15 @@ Omit \`height\` and the frame auto-sizes. The embedded page measures its real co
356136
356346
 
356137
356347
  Do not rely on \`body { min-height: 100vh }\` to drive the frame height. The runtime deliberately measures the content's bottom edge, not the viewport, to avoid a grow-forever loop.
356138
356348
 
356349
+ ## Inside the frame there is no viewport
356350
+
356351
+ The host scrolls; the frame does not. Inside an auto-sized frame \`window.innerHeight\` is the whole document, \`window.scrollY\` stays 0, and \`position: fixed\` means the top of the document rather than the top of the reader's screen. Two consequences, both invisible when the page is opened directly:
356352
+
356353
+ - **An auto-sized frame must never become scrollable.** Anything whose box reaches past the content's bottom edge (a sentinel, an absolutely positioned helper, an overlay) is scrollable overflow: the document grows past the height the host sized, and the reader's first wheel turns scroll the app inside its frame before the host moves. Keep every added element within the content, and check it with the harness rather than hiding overflow: a fixed-height frame (\`height\` above, or \`publisher:fit\`) needs the app to scroll inside it.
356354
+ - **Anything that floats over the page needs placing.** A drawer, a compare tray, a toast: built with \`position: fixed\` it opens at the top of the document, far above the reader, and focusing it scrolls the host page up to meet it. \`reference/overlays.md\` is the recipe: track the slice of the page the reader can see, place one clipped layer over it, contain its scroll, and focus without scrolling.
356355
+
356356
+ Test embedded, not just standalone: the Publisher console renders a package's app at \`/<env>/<package>/data-apps/<path>\`, which exercises the same resize contract a host does (\`reference/verification-harness.md\`).
356357
+
356139
356358
  ## Auth
356140
356359
 
356141
356360
  - Same-origin or same-tenant: pass no token. The browser's cookies authenticate the iframe.
@@ -356233,11 +356452,262 @@ for (let y = 0; y <= pageH; y += vh) {
356233
356452
  }
356234
356453
  await page.waitForFunction((n) => document.querySelectorAll(".tile .value").length >= n, total);
356235
356454
  \`\`\``
356455
+ },
356456
+ {
356457
+ name: "malloy-html-data-apps/overlays",
356458
+ description: "Drawers, trays and toasts in an embedded app. Reference detail for the malloy-html-data-apps skill.",
356459
+ body: "# Drawers, trays and toasts in an embedded app\n\n> How to place anything that floats over the page (an entity drawer, a compare tray, a toast) so it lands on the reader's screen and the page behind it holds still, standalone and inside an auto-sized frame. Referenced from `SKILL.md` and `reference/embedding.md`. Every rule below is a bug a real build shipped and a reader found.\n\n## Why `position: fixed` breaks when embedded\n\nA host that auto-sizes the frame (the Publisher console, a page using `Publisher.embed` without a `height`, any host that honours the resize messages) sizes the iframe to the app's full content height and scrolls its own container. So inside the frame:\n\n- `window.innerHeight` is the whole document, thousands of pixels;\n- `window.scrollY` is always 0, because the frame never scrolls; the host does;\n- `position: fixed; top: 0` means **the top of the document**, not the top of the reader's screen.\n\nA drawer built the standalone way therefore opens at the top of the app, far above where the reader is. Then focusing its close button scrolls the *host* up to meet it, and the page the reader was on jumps. Measured on a real build: the clicked row moved about 1,000px, the drawer was the height of the whole document so it could not scroll on its own, and closing it left the reader thousands of pixels from where they had been. None of this shows up when the page is opened directly, because standalone `fixed` is correct.\n\nThe host does not tell the frame where the reader is: the resize protocol goes one way, frame to host. The frame has to work it out.\n\n## Find the visible band\n\nAn `IntersectionObserver` with the implicit root reports against the **top-level** viewport, clipped by the host's scroll container, for same-origin and cross-origin frames alike, and its `intersectionRect` comes back in the frame's own coordinates. Observe a column of thin strips down the page, keep the union of their visible parts current from page load, and read it synchronously when something opens.\n\n```js\n// view.js: which slice of our document is on the reader's screen.\nexport const EMBEDDED = (() => { try { return window.self !== window.top; } catch { return true; } })();\nlet VIEW = { top: 0, height: innerHeight, known: !EMBEDDED };\nconst listeners = new Set();\nexport const onView = (fn) => (listeners.add(fn), () => listeners.delete(fn));\n\nif (EMBEDDED && \"IntersectionObserver\" in window) {\n const STEP = 100, holder = document.createElement(\"div\"), strips = [], vis = {};\n holder.className = \"view-strips\"; // height 0: see the rules below\n const io = new IntersectionObserver((entries) => {\n const sy = window.scrollY; // viewport -> document coordinates\n for (const e of entries) {\n const r = e.intersectionRect;\n if (e.isIntersecting && r.height > 0) vis[e.target.__i] = [r.top + sy, r.bottom + sy];\n else delete vis[e.target.__i];\n }\n const spans = Object.values(vis);\n if (!spans.length) return;\n const top = Math.min(...spans.map((s) => s[0])), bottom = Math.max(...spans.map((s) => s[1]));\n VIEW = { top: Math.round(top), height: Math.round(bottom - top), known: true };\n listeners.forEach((fn) => fn(VIEW));\n }, { threshold: Array.from({ length: 21 }, (_, i) => i / 20) }); // every 5% of a strip\n\n // Cover the content and STOP AT ITS BOTTOM EDGE, measured the way the SDK\n // measures the height it reports: the lowest bottom among body's children,\n // plus body's bottom padding.\n const contentBottom = () => Math.ceil(Math.max(0, ...[...document.body.children]\n .filter((k) => k !== holder && !k.classList.contains(\"overlay\"))\n .map((k) => k.getBoundingClientRect().bottom + window.scrollY))\n + (parseFloat(getComputedStyle(document.body).paddingBottom) || 0));\n const cover = () => {\n const end = contentBottom(), need = Math.max(1, Math.ceil(end / STEP));\n while (strips.length < need) { const s = document.createElement(\"div\"); s.__i = strips.length; strips.push(s); holder.append(s); io.observe(s); }\n while (strips.length > need) { const s = strips.pop(); io.unobserve(s); s.remove(); delete vis[s.__i]; }\n strips.forEach((s, i) => {\n const h = Math.max(1, Math.min(STEP, end - i * STEP)) + \"px\";\n s.style.top = i * STEP + \"px\";\n // A strip that is fully visible before and after a resize crosses no threshold,\n // so the observer would keep its old rect: observe it afresh.\n if (s.style.height !== h) { s.style.height = h; io.unobserve(s); io.observe(s); }\n });\n };\n document.body.append(holder);\n cover();\n new ResizeObserver(cover).observe(document.body); // tiles land; the page grows and shrinks\n}\n\n// The band an overlay should occupy, in document coordinates. The top is pinned\n// to the top of what the reader sees; only the height gives way.\nexport function visibleBand() {\n if (!EMBEDDED) return { top: window.scrollY, height: innerHeight };\n if (!VIEW.known) return { top: 0, height: Math.min(innerHeight, 900) };\n const room = document.documentElement.scrollHeight - VIEW.top;\n return { top: VIEW.top, height: Math.min(Math.max(VIEW.height, 360), room) };\n}\n```\n\n```css\n.view-strips { position: absolute; top: 0; left: 0; width: 1px; height: 0; pointer-events: none; visibility: hidden; }\n.view-strips > div { position: absolute; left: 0; width: 1px; }\n```\n\n## Place one layer over the band\n\nPut the scrim and the panel in one layer. Standalone it is `position: fixed; inset: 0`. Embedded it is `position: absolute` with its `top` and `height` set from `visibleBand()`, re-set whenever `onView` fires, so it follows the host if the reader scrolls it by other means. The layer clips its contents (`overflow: hidden`), so the panel's slide-in never widens the page.\n\n```css\n.overlay { position: fixed; inset: 0; z-index: 40; overflow: hidden; pointer-events: none; }\n.overlay.is-open, .overlay.is-open > * { pointer-events: auto; }\n.overlay.is-anchored { position: absolute; inset: auto 0 auto 0; } /* top/height from JS */\n.overlay .scrim { position: absolute; inset: 0; background: var(--scrim); opacity: 0; transition: opacity var(--dur-base); }\n.overlay.is-open .scrim { opacity: 1; }\n.overlay .panel {\n position: absolute; top: 0; right: 0; bottom: 0; width: min(760px, 94vw);\n display: flex; flex-direction: column;\n transform: translateX(calc(100% + 64px)); /* parked past its own shadow */\n transition: transform var(--dur-base) var(--ease-out);\n}\n.overlay.is-open .panel { transform: none; }\n.overlay .panel-head { flex: none; }\n.overlay .panel-body { /* the panel's only scroller */\n flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;\n display: grid; align-content: start; gap: var(--space-4);\n}\n```\n\nA tray or toast pinned to the bottom of the screen follows the same rule: embedded, give it `position: absolute` and set its `top` to `band.top + band.height - itsHeight - gap` on every `onView`.\n\n## The rules, and the bug each one prevents\n\n- **Read the band synchronously; never wait on a fresh measurement to open.** An async probe with a timeout fallback raced the click, lost, and opened the drawer at the top of the document.\n- **Pin the overlay's top to the top of the band; let only its height give way.** Pushing the top up to honour a minimum height puts the header above the screen when only the bottom of the app is visible.\n- **Nothing you add may extend the content.** Sentinels, strips, an overlay layer: anything whose box reaches past the content's bottom edge is scrollable overflow. The document then becomes taller than the frame the host sized, the reader's first wheel turns scroll the app *inside* its frame, and everything placed by the band is offset by that much. Measured: strips rounded up to the next 100px made every drawer open 7 to 46px too high, header under the host's breadcrumb bar, bottom short of the window. Keep sentinels in a zero-height holder, end the last one at the content edge, and keep overlays inside the band.\n- **An auto-sized frame never scrolls.** It cannot, as long as nothing extends the content (the rule above), and the harness asserts it. Do not force it with `overflow: hidden` on the document: the app cannot tell an auto-sized frame from a fixed-height one (`Publisher.embed({ height })`, a page with `<meta name=\"publisher:fit\" content=\"viewport\">`), and a fixed-height frame needs the app to scroll inside it. Hidden overflow there makes everything below the fold unreachable.\n- **Work in document coordinates.** `intersectionRect` is in viewport coordinates; an absolutely positioned layer is placed in document coordinates. Add `window.scrollY` at the moment you read it. They agree only while the frame is unscrolled, which is exactly the assumption the previous bug broke.\n- **Focus without scrolling.** `el.focus({ preventScroll: true })`, both when focusing into the panel and when handing focus back on close. A plain `focus()` scrolls whatever ancestor (including the host) it takes to bring the element into view.\n- **Consume scroll that is not the panel's.** A non-passive `wheel` and `touchmove` listener on the layer calls `preventDefault()` unless the event is inside the panel body and the body can still scroll that way. `overscroll-behavior: contain` on the body stops chaining at its ends. Without both, the wheel inside a drawer scrolled the page behind it by 2,000 to 3,000px.\n- **Scroll keys scroll the panel.** With focus on the close button, PageDown and Space go to the page behind. Handle the scroll keys on the panel and scroll the body. Escape closes; Tab stays inside.\n- **Nothing behind the panel moves.** No `overflow: hidden` toggled on the page (it removes a scrollbar and reflows everything), no `backdrop-filter: blur` on the scrim (it re-rasterises the page on every frame of the slide and reads as the page itself moving).\n- **Exits keep their content.** Remove the open class, let the panel slide out with what it showed, then remove the layer. Under `prefers-reduced-motion: reduce`, remove it at once.\n- **Page rules leak into the panel.** A panel section built from `<section>` inherits the page's `section { margin-top }`, and in a grid that margin does not collapse: measured as a 44px hole above every panel. Reset margins on panel content.\n\n## Verify it embedded\n\nNone of this can be checked standalone. Load the app inside a real host (the Publisher console at `/<env>/<package>/data-apps/<path>`, or a test page that calls `Publisher.embed` inside a scrolling container), scroll the host with real wheel events, open a drawer from well down the page, and assert on screen coordinates. `reference/verification-harness.md` has the checks."
356460
+ },
356461
+ {
356462
+ name: "malloy-html-data-apps/performance",
356463
+ description: "Performance and layout stability. Reference detail for the malloy-html-data-apps skill.",
356464
+ body: `# Performance and layout stability
356465
+
356466
+ > What a data app downloads, what it asks the server for, and whether the page holds still while it loads. Referenced from \`SKILL.md\`. Every rule here came from a real build that passed its functional checks while being slow, heavy, or jumpy: a working app is not yet a polished one.
356467
+
356468
+ ## Measure first
356469
+
356470
+ Record these per page, before and after any change, with the harness in \`reference/verification-harness.md\`. A change you did not measure is a guess.
356471
+
356472
+ | Measure | How | Bar |
356473
+ |---|---|---|
356474
+ | Bytes downloaded, by type | sum of response sizes on a cold load | the chart library is usually most of it; see below |
356475
+ | Queries issued | count of \`/query\` requests until the page settles | one per tile, no more |
356476
+ | Identical queries | same model + same query text twice on one page | 0 |
356477
+ | Layout shift (CLS) | a \`layout-shift\` \`PerformanceObserver\`, first visit and repeat visit | under 0.1 first visit, 0 on repeat |
356478
+ | Time to settled | until no query has been in flight for ~1.5s | depends on the warehouse; measure, do not promise |
356479
+
356480
+ Measure embedded as well as standalone (\`reference/embedding.md\`). Inside an auto-sized frame the whole document is "the viewport", so every shift anywhere on the page counts, and numbers that looked fine standalone (0.01 to 0.07) measured 0.12 to 0.41 embedded on the same build.
356481
+
356482
+ ## Keep the chart library off the critical path
356483
+
356484
+ A vendored chart library is typically 80 to 90% of a data app's bytes, and as a plain \`<script>\` ahead of the app code it blocks every query until it has downloaded and parsed. It also lets the browser paint the page before the app code can size it, which is where much of the first-visit layout shift comes from.
356485
+
356486
+ - **Preload it, load it on the first chart.** Put \`<link rel="preload" as="script" href="./vendor/echarts.min.js">\` in the \`<head>\` of pages that draw charts, and have the chart layer inject the script the first time it mounts a chart. The download starts at once, nothing waits for it, and a tile whose rows land before the library does waits only for itself. The preload's \`href\` and the injected script's \`src\` must resolve to the same URL, or the preload is a second download that nothing uses.
356487
+ - **A page that draws no chart never loads it.** An overview or landing page of numbers and links should not pay for a chart library at all. Measured: 1,464 KB to 348 KB for one such page.
356488
+ - **Hand back a stand-in while it loads.** Callers usually wire a click handler on the chart instance right after mounting it. Return an object that records \`.on(...)\` calls and replays them on the real instance once it exists, so the calling code does not change shape.
356489
+
356490
+ The loader below is written for ECharts (a global \`echarts\`, click handlers wired with \`.on()\`). For another library change the file name, the global, and the stand-in to record whatever its callers call; Chart.js, for one, takes its click handler as an option rather than through \`.on()\`. The URL is resolved against \`charts.js\` itself, so it is right from a page in any directory as long as \`charts.js\` sits beside \`vendor/\` (the layout in \`reference/runtime.md\`).
356491
+
356492
+ \`\`\`js
356493
+ // charts.js
356494
+ let libP = null;
356495
+ function chartLib() {
356496
+ if (window.echarts) return Promise.resolve(window.echarts);
356497
+ libP ??= new Promise((res, rej) => {
356498
+ const s = document.createElement("script");
356499
+ s.src = new URL("./vendor/echarts.min.js", import.meta.url).href; // same URL as the preload
356500
+ s.onload = () => res(window.echarts);
356501
+ s.onerror = () => { libP = null; rej(new Error("Chart library failed to load")); };
356502
+ document.head.append(s);
356503
+ });
356504
+ return libP;
356505
+ }
356506
+
356507
+ export function mount(host, buildOption) {
356508
+ if (!window.echarts) {
356509
+ const calls = [], stub = { on: (...a) => (calls.push(a), stub) };
356510
+ const mine = (host.__mount = (host.__mount || 0) + 1); // a newer mount wins
356511
+ chartLib().then(() => {
356512
+ if (host.__mount !== mine || !host.isConnected) return;
356513
+ const inst = mount(host, buildOption);
356514
+ calls.forEach((a) => inst.on(...a));
356515
+ }).catch((e) => showError(host, e)); // the tile's own error state
356516
+ return stub;
356517
+ }
356518
+ host.__mount = (host.__mount || 0) + 1;
356519
+ echarts.getInstanceByDom(host)?.dispose(); // a remount replaces, never merges
356520
+ const inst = echarts.init(host);
356521
+ inst.setOption(buildOption());
356522
+ return inst;
356523
+ }
356524
+ \`\`\`
356525
+
356526
+ ### Trim the library to what the app draws
356527
+
356528
+ Most chart libraries publish a modular build. A bundle of only the series types and components the app actually uses is typically a third smaller (one build: 1,122 KB to 713 KB, 369 KB to 243 KB gzipped). Inventory what the code passes the library (every series \`type:\`, and components such as legend, tooltip, visualMap, markLine), bundle exactly those once with a bundler outside the package, and commit the bundle together with its entry file and the command that rebuilt it.
356529
+
356530
+ **A trimmed build fails silently.** A series type left out of the bundle still mounts its canvas, throws nothing and logs nothing, and draws an empty box. A canvas count proves nothing. Verify by reading pixels: open every page and every kind of drawer, and require each chart canvas to have drawn pixels, against the full build first and then the trimmed one. Write the list of included types next to the bundle, so the next person to add a chart type knows to rebuild.
356531
+
356532
+ ## Ask once
356533
+
356534
+ - **One query text per resource.** \`Publisher.query\` caches nothing: every call is a request. Any cache the app keeps, any sharing of an in-flight request, and any prefetch are keyed on the exact query string. Two pages that read the same tile with different text (one adds \`order_by\`, one does not) get two cache entries and two round trips for one answer. Where order does not matter to a caller, send the text the other caller sends.
356535
+ - **A prefetch must match what the destination sends.** Warming the next page's queries on nav hover is worth it only if the warmed text is byte-for-byte what that page will ask for. Check it: list what each page sends and what each prefetch warms, and diff them.
356536
+ - **Count identical queries on a page.** It should be 0. Two tiles or two drawer panels needing the same rows should share one in-flight request.
356537
+
356538
+ ## Hold still while loading
356539
+
356540
+ Layout shift is the most visible difference between a generated app and a polished one, and almost all of it has three causes.
356541
+
356542
+ **1. Script-filled blocks at the top of the page.** A KPI strip, a provenance line or a filter bar that is an empty \`<div>\` in the HTML and is filled by script appears *after* the first paint, and pushes everything below it down. Measured: about 180px of drop on every page of one app, from three such blocks. Give each its final size in the first paint:
356543
+
356544
+ - put placeholder markup in the HTML, built from the same classes the script renders, so the layout reserves the right height at every width (four \`.kpi\` placeholder cards in the KPI grid, replaced when the numbers arrive);
356545
+ - give a one-line block a \`min-height\` while it is \`:empty\`.
356546
+
356547
+ **2. A tile that changes height when its rows land.** A four-line skeleton replaced by a 25-row list shifts everything below it by hundreds of pixels.
356548
+
356549
+ - **Size the skeleton from what is coming.** If the query carries a \`limit:\` or the tile slices to N rows, skeleton N rows at the real row height.
356550
+ - **Remember the settled height.** Store each tile's rendered height per page, tile and width bucket in \`localStorage\`, and hold it as \`min-height\` on the next load. Data that refreshes daily keeps its height, so a returning reader sees the page at its final size from the first frame. Measured: 0 layout shift on repeat visits on every page.
356551
+
356552
+ \`\`\`js
356553
+ const KEY = "app-size:" + location.pathname;
356554
+ const sizes = (() => { try { return JSON.parse(localStorage.getItem(KEY) || "{}"); } catch { return {}; } })();
356555
+ const sizeKey = (host) => host.id && \`\${host.id}@\${Math.round(host.clientWidth / 40)}\`;
356556
+ function holdSize(host) { const k = sizeKey(host); if (k && sizes[k]) host.style.minHeight = sizes[k] + "px"; return k; }
356557
+ function settleSize(host, k) { // call right after render, same task
356558
+ host.style.minHeight = "";
356559
+ if (!k) return;
356560
+ const h = Math.round(host.getBoundingClientRect().height);
356561
+ if (h > 0 && sizes[k] !== h) { sizes[k] = h; try { localStorage.setItem(KEY, JSON.stringify(sizes)); } catch {} }
356562
+ }
356563
+ \`\`\`
356564
+
356565
+ **3. A tile that re-runs for a new filter.** Dropping back to a skeleton on every scope change makes the page collapse and regrow. Keep the last answer on screen, dimmed and \`aria-busy\`, at its current height, until the new one lands. And guard the race: number each run per tile, and drop a response that is not the latest, or a slow query for the previous filter paints over the new one.
356566
+
356567
+ \`\`\`js
356568
+ function load(host, query, render) {
356569
+ const seq = (host.__seq = (host.__seq || 0) + 1);
356570
+ if (host.__drawn) { host.style.minHeight = host.offsetHeight + "px"; host.classList.add("is-busy"); host.setAttribute("aria-busy", "true"); }
356571
+ else showSkeleton(host);
356572
+ const unbusy = () => { host.classList.remove("is-busy"); host.removeAttribute("aria-busy"); };
356573
+ return Publisher.query(MODEL, query).then((rows) => {
356574
+ if (seq !== host.__seq) return; // a newer run owns this tile
356575
+ unbusy(); host.__drawn = true;
356576
+ render(rows, host);
356577
+ host.style.minHeight = ""; // released after the new rows are in
356578
+ }, (e) => {
356579
+ if (seq !== host.__seq) return;
356580
+ unbusy(); showError(host, e); // a failed re-run must not stay dimmed and busy
356581
+ host.style.minHeight = "";
356582
+ });
356583
+ }
356584
+ \`\`\`
356585
+
356586
+ ## Answer from the last visit (opt-in)
356587
+
356588
+ A per-tab cache (\`sessionStorage\`, or memory) starts every new tab and every next-day visit from nothing: every query is re-sent and every tile waits on it. Measured on a real build: 1.6 to 3.8 s before the first screen filled in a new tab. Most data apps read tables built on a schedule, so the last visit's rows are usually still right. A persistent cache can draw them at once, but it is the one recipe here that can show someone rows they may not read, so it is opt-in and has conditions.
356589
+
356590
+ **Use it only when every reader sees the same rows.** Check the model before adding it: no \`given:\` declarations the app or a host sets per reader, no \`#(authorize)\` gates, no access filters, nothing that makes the same query text answer differently for different people. If any of those apply, do not persist results; a per-tab cache is the most you can do.
356591
+
356592
+ **Why the conditions.** Every \`public/\` app on a deployment is served from the API's own origin, so they all share one IndexedDB. The page is served without a sign-in, and it cannot tell who is viewing it. A row-level refusal is a 200 with fewer rows, not a 403, so no error marks the cached answer as wrong. A cache keyed on query text alone shows one reader's rows to the next person on the same browser profile, signed in or not, and keeps showing them to someone whose access was withdrawn.
356593
+
356594
+ How it stays safe:
356595
+
356596
+ - **Nothing paints from the store until this visit has proven it may read.** Every tile sends its query at once, as without a cache. Stored answers are held back until one query on the page has answered successfully, then painted together while the rest of the queries finish. A signed-out visitor, or someone whose access was withdrawn, gets an error before any stored row reaches the screen. The first screen then waits one round trip rather than every query.
356597
+ - **Every stored answer is re-asked.** There is no window in which the page trusts the store and sends nothing. A stored answer older than the build schedule plus a margin (a day and a half for a daily build) is not painted at all.
356598
+ - **The key is everything the answer depends on:** the environment and package (the page's own path), the model, the exact query text, and the query's options (\`givens\`, \`filterParams\`, a target \`environment\` or \`package\`).
356599
+ - **A 401 or 403 on any query wipes the store**, and that tile shows its error.
356600
+ - **Repaint only the tile that still shows that query, and only when the rows differ.** A tile whose filter changed while its query was in flight ignores the late answer (the sequence guard in "Hold still while loading"). An unchanged answer must not redraw: it would re-animate charts for no reason.
356601
+ - **The store is capped.** Every filter combination is a new key; prune the oldest past a few hundred entries.
356602
+ - **Show how old the number is.** A tile that can draw yesterday's answer should carry the as-of date of its data.
356603
+
356604
+ Measured before the session gate was added: the new-tab load drew its first screen in 87 to 93 ms. The gate adds one query's round trip to that.
356605
+
356606
+ \`\`\`js
356607
+ // cache.js: draw the last visit's answer once this visit has proven it may read.
356608
+ // Opt-in: only for models whose rows are the same for every reader (see above).
356609
+ const STALE = 36 * 3600e3, CAP = 300;
356610
+ const SCOPE = location.pathname.split("/").slice(0, 5).join("/"); // /environments/<env>/packages/<pkg>
356611
+ let mayRead; const live = new Promise((res) => (mayRead = res)); // resolves on the first good answer
356612
+ const inflight = new Map();
356613
+ const db = new Promise((res) => {
356614
+ try { const rq = indexedDB.open("app-cache", 1);
356615
+ rq.onupgradeneeded = () => rq.result.createObjectStore("q").createIndex("t", "t");
356616
+ rq.onsuccess = () => res(rq.result); rq.onerror = rq.onblocked = () => res(null);
356617
+ } catch { res(null); } // private mode: no persistence
356618
+ });
356619
+ const store = (mode, fn) => db.then((d) => d && new Promise((res) => {
356620
+ try { const tx = d.transaction("q", mode); const r = fn(tx.objectStore("q")); tx.oncomplete = () => res(r?.result ?? null); tx.onerror = () => res(null); }
356621
+ catch { res(null); }
356622
+ }));
356623
+ const wipe = () => store("readwrite", (s) => s.clear());
356624
+ const prune = () => store("readwrite", (s) => {
356625
+ const c = s.count(); c.onsuccess = () => {
356626
+ let extra = c.result - CAP; if (extra <= 0) return;
356627
+ s.index("t").openCursor().onsuccess = (e) => { const cur = e.target.result; if (cur && extra-- > 0) { cur.delete(); cur.continue(); } };
356628
+ };
356629
+ });
356630
+
356631
+ // Resolves with rows for the tile: the stored answer once the page may read, or the
356632
+ // network answer if that comes first. onFresh(rows, err) fires if the network answer
356633
+ // arrives after a stored one was drawn and differs, or if the query is refused.
356634
+ export function cachedQuery(model, q, opts = {}, onFresh) {
356635
+ const k = JSON.stringify([SCOPE, model, q, opts]);
356636
+ let net = inflight.get(k); // two tiles, one request
356637
+ if (!net) {
356638
+ net = Publisher.query(model, q, opts).then((rows) => {
356639
+ mayRead();
356640
+ store("readwrite", (s) => s.put({ t: Date.now(), r: rows }, k)).then(prune);
356641
+ return rows;
356642
+ }, (e) => { if (e?.status === 401 || e?.status === 403) wipe(); throw e; })
356643
+ .finally(() => inflight.delete(k));
356644
+ inflight.set(k, net);
356645
+ }
356646
+ const stored = store("readonly", (s) => s.get(k)).then((box) =>
356647
+ box && Date.now() - box.t < STALE ? live.then(() => box.r) : new Promise(() => {})); // never: wait for the network
356648
+ return Promise.race([net.then((r) => ({ r })), stored.then((r) => ({ r, stored: true }))]).then((first) => {
356649
+ if (first.stored) net.then( // drawn from the store: follow up once
356650
+ (rows) => { if (JSON.stringify(rows) !== JSON.stringify(first.r)) onFresh?.(rows, null); },
356651
+ (e) => onFresh?.(null, e));
356652
+ return first.r;
356653
+ });
356654
+ }
356655
+ \`\`\`
356656
+
356657
+ ## Start on intent (opt-in)
356658
+
356659
+ Resting the pointer on something that opens a drawer, or focusing it, usually comes a few hundred milliseconds before the click. Start the drawer's queries then, and the click finds them in flight or answered. Measured on one build: all 7 queries of a market drawer and all 15 of a product drawer (a name lookup included) were in flight before the click, and the click itself sent none. Worth it on pages whose drawers are the main way in; skip it where drawers are rare.
356660
+
356661
+ Build each drawer in one function that both the click and the warm-up call. A hand-written list of each drawer's queries, warmed on hover, is a second copy of every drawer's query text and drifts the first time a panel changes. Do not warm by dispatching a synthetic click on the row either: capture-phase listeners still run, a row that is a link navigates, a \`<summary>\` toggles, and an app that wires its drawers by delegation gets no warm-up at all and no error saying so.
356662
+
356663
+ \`\`\`js
356664
+ // drawers.js: a drill row names its drawer (data-drawer="market" data-key="42").
356665
+ // DRAWERS.market(key, body, { warm }) sends the drawer's queries; warm, it stops there.
356666
+ export function drawerFor(row, { warm = false } = {}) {
356667
+ const build = DRAWERS[row.dataset.drawer];
356668
+ if (!build) return;
356669
+ if (warm) return build(row.dataset.key, null, { warm }); // queries only: nothing rendered, nothing opened
356670
+ return build(row.dataset.key, openDrawer(row), { warm }); // openDrawer: the real open (reference/overlays.md)
356671
+ }
356672
+ document.addEventListener("click", (e) => { const row = e.target.closest?.("[data-drawer]"); if (row) drawerFor(row); });
356673
+
356674
+ // Intent: an 80ms rest, by pointer or keyboard, so a sweep down a list warms nothing;
356675
+ // at most two drawers warming at once.
356676
+ let timer = 0, warmingNow = 0;
356677
+ const warmed = new WeakSet();
356678
+ const intent = (e) => {
356679
+ clearTimeout(timer);
356680
+ const row = e.target.closest?.("[data-drawer]");
356681
+ if (!row || warmed.has(row) || e.pointerType === "touch") return;
356682
+ timer = setTimeout(() => {
356683
+ if (warmingNow >= 2) return;
356684
+ warmed.add(row); warmingNow++;
356685
+ Promise.resolve(drawerFor(row, { warm: true })).catch(() => {}).finally(() => warmingNow--);
356686
+ }, 80);
356687
+ };
356688
+ document.addEventListener("pointerover", intent);
356689
+ document.addEventListener("focusin", intent);
356690
+ \`\`\`
356691
+
356692
+ What makes it safe:
356693
+
356694
+ - **A warm build only asks.** It sends the drawer's queries through the same cache as the real open (see "Ask once") and returns; it renders nothing, so no chart instance is left on a detached node, and nothing opens, navigates, or writes.
356695
+ - **The warm flag is an argument, not a global.** A drawer that first resolves a name to an id passes \`{ warm }\` on to the step that runs after the lookup, so a warm-up never opens a drawer when the lookup returns.
356696
+ - **Warm what the click will ask for.** The click and the warm-up run the same \`DRAWERS\` function, so they send the same query text.
356697
+ - **Never warm on page load.** Warm-ups wait for intent. Warming every drawer a page could open is a query storm the reader pays for.
356698
+
356699
+ ## Respond before the answer arrives
356700
+
356701
+ A click that waits on a query before anything visible happens reads as a dead click. The common case is a drill that must first resolve a name to an id: open the drawer immediately, titled with the name and a skeleton, run the lookup inside it, and replace the skeleton in place when the id arrives. Drop the late answer if the reader has closed the drawer or opened another subject meanwhile (\`reference/depth-patterns.md\`, stale-response guard).
356702
+
356703
+ ## What the page cannot fix
356704
+
356705
+ Compression and cache headers belong to whatever serves \`public/\`. Check them (\`curl -sI -H 'Accept-Encoding: gzip, br' <url>\`): a chart library served uncompressed is downloaded at full size on the first visit, and with \`max-age=0\` every later page asks the server again before using it (a conditional request answered 304, one round trip per file). Report it to whoever runs the server rather than working around it in the page.`
356236
356706
  },
356237
356707
  {
356238
356708
  name: "malloy-html-data-apps/runtime",
356239
356709
  description: "HTML Data App Runtime. Reference detail for the malloy-html-data-apps skill.",
356240
- body: '# HTML Data App Runtime\n\n> `Publisher.query(modelPath, malloy)` returns an array of plain row objects. Build the Malloy string, let the model do the work, and render the rows with whatever front-end code you like.\n\nThe runtime loads from the root-relative `<script src="/sdk/publisher.js">` and adds one global, `window.Publisher`.\n\n## The query contract\n\n| Call | Returns | Use for |\n|---|---|---|\n| `Publisher.query(modelPath, malloy, opts?)` | `Promise<Array>` of rows | driving your own charts and tables |\n| `Publisher.queryFull(modelPath, malloy, opts?)` | `Promise<MalloyResult>` | handing to `<malloy-render>` |\n\n- `modelPath` is the model FILE path within the package, with `/` separators (`"subscriptions.malloy"`, `"models/events.malloy"`). It is not the source name.\n- `malloy` is any query string, written in standard Malloy. This skill covers only the JavaScript glue, not Malloy syntax.\n- `opts` (all optional): `sourceName`, `queryName` (`queryName` runs a saved query, with `sourceName` qualifying the source a view hangs off; `sourceName` on its own is a 400), `givens` (a `{ name: value }` map bound to the model\'s Malloy `given:` runtime parameters for this query; safe parameterization, values are bound by Publisher server-side, not string-interpolated), `filterParams` (values for the model\'s legacy `#(filter)` source filters), `bypassFilters`, and `environment` / `package` (only if the page is served from outside `/environments/<env>/packages/<pkg>/`). `givens` and `filterParams` compose (both apply).\n\n## Structure the app as modules, not one inline script\n\nPast a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: put your shared libraries first as plain globals, then one ES-module entry point that `import`s your own files.\n\n```html\n<!-- Globals first: the runtime, then any vendored chart library. -->\n<script src="/sdk/publisher.js"></script>\n<script src="./vendor/chart.umd.js"></script>\n<!-- One module entry; it imports the rest. ES modules resolve with no bundler. -->\n<script type="module" src="./app.js"></script>\n```\n\nA separation that keeps each piece testable and changeable on its own:\n\n- **`format.js`**. Pure functions only: number/date formatting, a series-align-by-month helper, status thresholds. No DOM, no globals. This is the file `node --test` can cover directly.\n- **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library.\n- **`tiles.js`**. Your tiles as *data*: for each, its model/source/view (and target source, if any), plus a pure `build(rows)` that shapes query rows for the chart. This is the single source of truth for what each tile queries.\n- **`app.js`**. The thin entry point: reads `tiles.js`, runs the queries, wires results to the DOM. Adding a tile means adding a `tiles.js` entry, not editing `app.js`.\n\nDeclare each tile\'s source and view names once, in `tiles.js`, and have everything else (render code, any agent prompt, tests) read from there. A second copy of those names in another file is the classic drift bug, and a *derived* name (`okr_4_4_2_targets` invented from a tile code) is simply wrong: a target source may have an irregular name or not exist at all. Read the model; don\'t compute names.\n\n## Patterns that work\n\nThese assume a `subscriptions.malloy` model whose `subscriptions` source defines the views `plan_mix`, `mrr_by_industry`, and `kpis`. The names are illustrative; swap in your own model and view names.\n\nRun a named view:\n\n```js\nconst rows = await Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nRefine a view from UI state by appending a `where:`. Restrict the values to ones you control (for example a dropdown populated from the model\'s own distinct values) and escape each interpolated value with a backslash before quotes and backslashes (Malloy rejects the SQL-style `\'\'` doubling). An unescaped apostrophe in a value breaks out of the literal:\n\n```js\nfunction whereClause(state) {\n const q = (s) => s.replace(/\\\\/g, "\\\\\\\\").replace(/\'/g, "\\\\\'"); // backslash-escape for Malloy\n const parts = [];\n if (state.plan) parts.push(`plan = \'${q(state.plan)}\'`);\n if (state.industry) parts.push(`industry = \'${q(state.industry)}\'`);\n return parts.length ? `where: ${parts.join(", ")}` : "";\n}\nconst rows = await Publisher.query(\n "subscriptions.malloy",\n `run: subscriptions -> plan_mix + { ${whereClause(state)} }`,\n);\n```\n\nDo not interpolate free-text or otherwise untrusted input into the query string. Route parameterized input through `opts.givens` (or the legacy `opts.filterParams`) instead: Publisher binds those values server-side as typed parameters, so they are never concatenated into query text and can\'t inject query syntax. (One nuance: a `filter<T>`-typed given takes Malloy filter syntax as its value, so validate it against a known set like any other input; scalar givens carry no syntax at all.) `opts.givens` is safe *parameterization*, not an authorization boundary: a client-supplied given is client-trusted unless a trusted tier upstream sets it from verified identity. Publisher has no per-package control that strips or finalizes one; identity-bound givens are a planned milestone, not a shipped feature. Where you must build query text from input, constrain it to a known set and escape it, or keep the filtering in model-defined views.\n\nKPI or single-row view. Destructure element zero:\n\n```js\nconst [kpis] = await Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis");\nel.textContent = kpis.active_mrr; // the result is an array; kpis.active_mrr, not rows.active_mrr\n```\n\nRefresh a dashboard. Fire the tiles together:\n\n```js\nconst [planMix, byIndustry, kpisRows] = await Promise.all([\n Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> mrr_by_industry"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis"),\n]);\n```\n\nPrefer defining the views in the model (one per tile, pre-aggregated and sorted) over building long query strings in JS.\n\nGet the numbers right. The fastest way to ship a wrong-but-convincing dashboard is to paper over missing data:\n\n- **Missing is not zero.** When you join two series (actuals to a separately-keyed target) and a key is absent, leave it `null` so the chart skips it. Do not `|| 0`, which plots a real-looking zero and reads as "we hit nothing that month." Align on a normalized key (`"YYYY-MM"`), and let the renderer omit null points:\n\n ```js\n // monthKey/monthLabel are your own format.js helpers: monthKey normalizes a\n // date to a "YYYY-MM" string; monthLabel formats it for display.\n // target may not cover every actual month; an absent month stays null, never 0.\n const target = new Map(planRows.map((r) => [monthKey(r.plan_month), Number(r.target_revenue)]));\n const data = actualRows.map((r) => ({\n label: monthLabel(r.order_month),\n actual: Number(r.revenue),\n target: target.has(monthKey(r.order_month)) ? target.get(monthKey(r.order_month)) : null,\n }));\n ```\n\n- **"Current" means latest non-null.** For a KPI scorecard, scan back to the last month that actually has data rather than reading the final row, which may be an incomplete current month.\n- **Guard division in Malloy, not after.** `avg(paid / nullif(active, 0))`. A `nullif` in the query beats catching `Infinity`/`NaN` in JS.\n- **Convert units explicitly.** If the model stores a 0 to 1 fraction and you show a percent, multiply once in `build()` and comment it. Mismatched units are a silent off-by-100.\n\nLoading, empty, and error states. Handle all three; a bare `.then()` that assumes rows leaves the page blank when the query is slow or fails:\n\n```js\nconst el = document.getElementById("out");\nel.textContent = "Loading...";\nPublisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix")\n .then((rows) => {\n if (!rows.length) { el.textContent = "No data."; return; }\n render(rows);\n })\n .catch((err) => {\n el.textContent = `Query failed (${err.status ?? ""}): ${err.response?.message ?? err.message}`;\n });\n```\n\nRender through `<malloy-render>`. `queryFull` returns the full Malloy result envelope (the JSON form of the server\'s result, not a live result object) to hand to the component:\n\n```js\nconst el = document.querySelector("malloy-render");\nel.result = await Publisher.queryFull("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nPublisher does not serve or bundle `<malloy-render>`; you must obtain a built component bundle matched to your model\'s Malloy version and vendor it into `public/` yourself, then confirm it accepts the envelope as-is. The `storefront` example draws with Chart.js and has no `<malloy-render>` element in its pages, so the component itself is not exercised there. A view tagged in the model (for example `# bar_chart`) drives how it draws.\n\nValidate every query before wiring it into render code, using whatever query tool your environment provides, or by POSTing the query to a running Publisher at `/api/v0/environments/<env>/packages/<pkg>/models/<modelPath>/query` with body `{"compactJson":true,"query":"..."}`, or by running `Publisher.query` once and logging the rows. Malloy names result columns after the `group_by` / `aggregate` field names (`group_by: plan` gives a `plan` column; `aggregate: account_count` gives an `account_count` column), so confirm those names against real output before you read them.\n\nIf you validate the rendered page in a headless browser (Playwright or Puppeteer), do not wait for network idle: `publisher.js` holds the live-reload SSE stream open, so the page never reaches it. Wait on `domcontentloaded` or `load` plus a content selector instead.\n\n## Context, auth, live reload (all automatic)\n\n- Context. A page served under `/environments/<env>/packages/<pkg>/...` infers its environment and package, so `query` needs no env or package args. Serving from elsewhere? Pass `opts.environment` and `opts.package`.\n- Auth. By default the runtime sends cookies (`credentials: include`), so a signed-in user is authenticated with no code. For a bearer token, call `Publisher.setToken(token)` first; `Publisher.setToken(null)` reverts to cookies.\n- Live reload. Under `--watch-env`, the page reloads on package changes by itself. Nothing to wire.\n\n## When the app fails\n\n| Symptom | Likely cause and fix |\n|---|---|\n| 404 or "model not found" | `modelPath` wrong. It is the file path (`"subscriptions.malloy"`), with `/` separators, not the source name. |\n| "source/view not defined" | View or source name guessed. Read the model (your environment\'s context tool, or open the `.malloy` file) and use the real names. |\n| Promise rejects, message starts `Publisher.query:` | Read `error.status` and `error.response` for the server\'s reason (compile error, missing required parameter, permission). |\n| Empty array when you expect rows | Filter value mismatch (case, spelling, type, or a non-ASCII character like `≤` or an en-dash in the literal). Copy the literal verbatim from the model, do not retype the user\'s paraphrase, and confirm it with a distinct-values query (`run: src -> { group_by: the_dimension }`). Quote strings, use `@` for dates. |\n| 400 on a given (ungated source) | An unknown given name (check spelling; names are case-sensitive), a required given left unset, or a value that doesn\'t fit the declared type. Malloy rejects it when preparing the query; supply declared givens via `opts.givens` with the right shape (see the givens type table). |\n| 403 on a query that should be allowed, when passing givens to a gated source | On a gated source, a bad given (unknown name or wrong-typed value) fails closed, so it looks like access denied rather than validation. A `#(authorize)` lock also answers 403 for a caller it simply does not admit. Check the given names and values against the model. |\n| KPI shows `undefined` | The result is an array. Read `rows[0].field` (or destructure `const [k] = ...`), not `rows.field`. |\n| A NEW file under `public/` 404s while the rest of the app serves | The server loaded the package **before** `public/` existed, and it is not watching. Without `--watch-env` the package is COPIED into `publisher_data/<env>/<pkg>/` at load and static files are served from that copy, so a file you add afterwards is never seen. Restart with `--watch-env <env>`, or mirror the file into the served copy. Verified by writing a probe file into the real package and getting a 404 while the copy served fine - and note the existing row below sends you looking under `public/`, which is exactly the wrong place for this one. |\n| Page loads in dev but is not listed or not served | The file is not under the package\'s `public/` directory. Publisher serves only `public/`; a page written anywhere else (for example `/tmp`) is never reachable at `/environments/<env>/packages/<pkg>/<file>`. |\n| Queries fail only when embedded cross-origin | Cookies are not sent cross-site. Serve same-origin, or pass a bearer token. |\n| No live reload | Watch mode is off. Start with `--watch-env <env>`; without it the events stream reports `mode: disabled` and never reloads. |'
356710
+ body: '# HTML Data App Runtime\n\n> `Publisher.query(modelPath, malloy)` returns an array of plain row objects. Build the Malloy string, let the model do the work, and render the rows with whatever front-end code you like.\n\nThe runtime loads from the root-relative `<script src="/sdk/publisher.js">` and adds one global, `window.Publisher`.\n\n## The query contract\n\n| Call | Returns | Use for |\n|---|---|---|\n| `Publisher.query(modelPath, malloy, opts?)` | `Promise<Array>` of rows | driving your own charts and tables |\n| `Publisher.queryFull(modelPath, malloy, opts?)` | `Promise<MalloyResult>` | handing to `<malloy-render>` |\n\n- `modelPath` is the model FILE path within the package, with `/` separators (`"subscriptions.malloy"`, `"models/events.malloy"`). It is not the source name.\n- `malloy` is any query string, written in standard Malloy. This skill covers only the JavaScript glue, not Malloy syntax.\n- `opts` (all optional): `sourceName`, `queryName` (`queryName` runs a saved query, with `sourceName` qualifying the source a view hangs off; `sourceName` on its own is a 400), `givens` (a `{ name: value }` map bound to the model\'s Malloy `given:` runtime parameters for this query; safe parameterization, values are bound by Publisher server-side, not string-interpolated), `filterParams` (values for the model\'s legacy `#(filter)` source filters), `bypassFilters`, and `environment` / `package` (only if the page is served from outside `/environments/<env>/packages/<pkg>/`). `givens` and `filterParams` compose (both apply).\n\n## Structure the app as modules, not one inline script\n\nPast a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: the runtime first as a plain global, then one ES-module entry point that `import`s your own files.\n\n```html\n<head>\n <!-- Start the chart library\'s download now, without making anything wait for it.\n Only on pages that draw a chart. -->\n <link rel="preload" as="script" href="./vendor/chart.umd.js">\n</head>\n...\n<script src="/sdk/publisher.js"></script>\n<!-- One module entry; it imports the rest. ES modules resolve with no bundler. -->\n<script type="module" src="./app.js"></script>\n```\n\n**Do not load the chart library with a plain `<script>` ahead of your code.** It is usually most of the page\'s weight, and loaded that way it holds every query until it has downloaded and parsed. `charts.js` injects it the first time it draws, so the queries start at once. `reference/performance.md` has the loader, and the stand-in that lets callers wire chart clicks before the library has arrived.\n\nA separation that keeps each piece testable and changeable on its own:\n\n- **`format.js`**. Pure functions only: number/date formatting, a series-align-by-month helper, status thresholds. No DOM, no globals. This is the file `node --test` can cover directly.\n- **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library, and the one that loads it.\n- **`tiles.js`**. Your tiles as *data*: for each, its model/source/view (and target source, if any), plus a pure `build(rows)` that shapes query rows for the chart. This is the single source of truth for what each tile queries.\n- **`app.js`**. The thin entry point: reads `tiles.js`, runs the queries, wires results to the DOM. Adding a tile means adding a `tiles.js` entry, not editing `app.js`.\n\nDeclare each tile\'s source and view names once, in `tiles.js`, and have everything else (render code, any agent prompt, tests) read from there. A second copy of those names in another file is the classic drift bug, and a *derived* name (`okr_4_4_2_targets` invented from a tile code) is simply wrong: a target source may have an irregular name or not exist at all. Read the model; don\'t compute names.\n\n## Patterns that work\n\nThese assume a `subscriptions.malloy` model whose `subscriptions` source defines the views `plan_mix`, `mrr_by_industry`, and `kpis`. The names are illustrative; swap in your own model and view names.\n\nRun a named view:\n\n```js\nconst rows = await Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nRefine a view from UI state by appending a `where:`. Restrict the values to ones you control (for example a dropdown populated from the model\'s own distinct values) and escape each interpolated value with a backslash before quotes and backslashes (Malloy rejects the SQL-style `\'\'` doubling). An unescaped apostrophe in a value breaks out of the literal:\n\n```js\nfunction whereClause(state) {\n const q = (s) => s.replace(/\\\\/g, "\\\\\\\\").replace(/\'/g, "\\\\\'"); // backslash-escape for Malloy\n const parts = [];\n if (state.plan) parts.push(`plan = \'${q(state.plan)}\'`);\n if (state.industry) parts.push(`industry = \'${q(state.industry)}\'`);\n return parts.length ? `where: ${parts.join(", ")}` : "";\n}\nconst rows = await Publisher.query(\n "subscriptions.malloy",\n `run: subscriptions -> plan_mix + { ${whereClause(state)} }`,\n);\n```\n\nDo not interpolate free-text or otherwise untrusted input into the query string. Route parameterized input through `opts.givens` (or the legacy `opts.filterParams`) instead: Publisher binds those values server-side as typed parameters, so they are never concatenated into query text and can\'t inject query syntax. (One nuance: a `filter<T>`-typed given takes Malloy filter syntax as its value, so validate it against a known set like any other input; scalar givens carry no syntax at all.) `opts.givens` is safe *parameterization*, not an authorization boundary: a client-supplied given is client-trusted unless a trusted tier upstream sets it from verified identity. Publisher has no per-package control that strips or finalizes one; identity-bound givens are a planned milestone, not a shipped feature. Where you must build query text from input, constrain it to a known set and escape it, or keep the filtering in model-defined views.\n\nKPI or single-row view. Destructure element zero:\n\n```js\nconst [kpis] = await Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis");\nel.textContent = kpis.active_mrr; // the result is an array; kpis.active_mrr, not rows.active_mrr\n```\n\nRefresh a dashboard. Fire the tiles together:\n\n```js\nconst [planMix, byIndustry, kpisRows] = await Promise.all([\n Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> mrr_by_industry"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis"),\n]);\n```\n\nPrefer defining the views in the model (one per tile, pre-aggregated and sorted) over building long query strings in JS.\n\n**Same question, same query text.** `Publisher.query` caches nothing, but a cache the app keeps, a shared in-flight request, and any prefetch are all keyed on the exact string. Two pages reading the same view with different text (one adds an `order_by`) pay for two round trips for one answer, and a prefetch that warms different text from what the destination page sends warms nothing. Build each tile\'s query in one place and reuse that string wherever the same rows are needed.\n\n**Set ARIA and data attributes as attributes.** A small element helper that does `Object.assign(document.createElement(tag), props)` silently drops `aria-modal`, `aria-label`, `aria-busy` and `data-*`: they are not properties, so assigning them does nothing and no error says so. Route any `aria-*`, `data-*` and `role` key through `setAttribute`. And a clickable row is a control: give it `tabIndex = 0` and Enter/Space handling, or keyboard users cannot open what a mouse user can.\n\nGet the numbers right. The fastest way to ship a wrong-but-convincing dashboard is to paper over missing data:\n\n- **Missing is not zero.** When you join two series (actuals to a separately-keyed target) and a key is absent, leave it `null` so the chart skips it. Do not `|| 0`, which plots a real-looking zero and reads as "we hit nothing that month." Align on a normalized key (`"YYYY-MM"`), and let the renderer omit null points:\n\n ```js\n // monthKey/monthLabel are your own format.js helpers: monthKey normalizes a\n // date to a "YYYY-MM" string; monthLabel formats it for display.\n // target may not cover every actual month; an absent month stays null, never 0.\n const target = new Map(planRows.map((r) => [monthKey(r.plan_month), Number(r.target_revenue)]));\n const data = actualRows.map((r) => ({\n label: monthLabel(r.order_month),\n actual: Number(r.revenue),\n target: target.has(monthKey(r.order_month)) ? target.get(monthKey(r.order_month)) : null,\n }));\n ```\n\n- **"Current" means latest non-null.** For a KPI scorecard, scan back to the last month that actually has data rather than reading the final row, which may be an incomplete current month.\n- **Guard division in Malloy, not after.** `avg(paid / nullif(active, 0))`. A `nullif` in the query beats catching `Infinity`/`NaN` in JS.\n- **Convert units explicitly.** If the model stores a 0 to 1 fraction and you show a percent, multiply once in `build()` and comment it. Mismatched units are a silent off-by-100.\n\nLoading, empty, and error states. Handle all three; a bare `.then()` that assumes rows leaves the page blank when the query is slow or fails:\n\n```js\nconst el = document.getElementById("out");\nel.textContent = "Loading...";\nPublisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix")\n .then((rows) => {\n if (!rows.length) { el.textContent = "No data."; return; }\n render(rows);\n })\n .catch((err) => {\n el.textContent = `Query failed (${err.status ?? ""}): ${err.response?.message ?? err.message}`;\n });\n```\n\nRender through `<malloy-render>`. `queryFull` returns the full Malloy result envelope (the JSON form of the server\'s result, not a live result object) to hand to the component:\n\n```js\nconst el = document.querySelector("malloy-render");\nel.result = await Publisher.queryFull("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nPublisher does not serve or bundle `<malloy-render>`; you must obtain a built component bundle matched to your model\'s Malloy version and vendor it into `public/` yourself, then confirm it accepts the envelope as-is. The `storefront` example draws with Chart.js and has no `<malloy-render>` element in its pages, so the component itself is not exercised there. A view tagged in the model (for example `# bar_chart`) drives how it draws.\n\nValidate every query before wiring it into render code, using whatever query tool your environment provides, or by POSTing the query to a running Publisher at `/api/v0/environments/<env>/packages/<pkg>/models/<modelPath>/query` with body `{"compactJson":true,"query":"..."}`, or by running `Publisher.query` once and logging the rows. Malloy names result columns after the `group_by` / `aggregate` field names (`group_by: plan` gives a `plan` column; `aggregate: account_count` gives an `account_count` column), so confirm those names against real output before you read them.\n\nIf you validate the rendered page in a headless browser (Playwright or Puppeteer), do not wait for network idle: `publisher.js` holds the live-reload SSE stream open, so the page never reaches it. Wait on `domcontentloaded` or `load` plus a content selector instead.\n\n## Context, auth, live reload (all automatic)\n\n- Context. A page served under `/environments/<env>/packages/<pkg>/...` infers its environment and package, so `query` needs no env or package args. Serving from elsewhere? Pass `opts.environment` and `opts.package`.\n- Auth. By default the runtime sends cookies (`credentials: include`), so a signed-in user is authenticated with no code. For a bearer token, call `Publisher.setToken(token)` first; `Publisher.setToken(null)` reverts to cookies.\n- Live reload. Under `--watch-env`, the page reloads on package changes by itself. Nothing to wire.\n\n## When the app fails\n\n| Symptom | Likely cause and fix |\n|---|---|\n| 404 or "model not found" | `modelPath` wrong. It is the file path (`"subscriptions.malloy"`), with `/` separators, not the source name. |\n| "source/view not defined" | View or source name guessed. Read the model (your environment\'s context tool, or open the `.malloy` file) and use the real names. |\n| Promise rejects, message starts `Publisher.query:` | Read `error.status` and `error.response` for the server\'s reason (compile error, missing required parameter, permission). |\n| Empty array when you expect rows | Filter value mismatch (case, spelling, type, or a non-ASCII character like `≤` or an en-dash in the literal). Copy the literal verbatim from the model, do not retype the user\'s paraphrase, and confirm it with a distinct-values query (`run: src -> { group_by: the_dimension }`). Quote strings, use `@` for dates. |\n| 400 on a given (ungated source) | An unknown given name (check spelling; names are case-sensitive), a required given left unset, or a value that doesn\'t fit the declared type. Malloy rejects it when preparing the query; supply declared givens via `opts.givens` with the right shape (see the givens type table). |\n| 403 on a query that should be allowed, when passing givens to a gated source | On a gated source, a bad given (unknown name or wrong-typed value) fails closed, so it looks like access denied rather than validation. A `#(authorize)` lock also answers 403 for a caller it simply does not admit. Check the given names and values against the model. |\n| KPI shows `undefined` | The result is an array. Read `rows[0].field` (or destructure `const [k] = ...`), not `rows.field`. |\n| A NEW file under `public/` 404s while the rest of the app serves | The server loaded the package **before** `public/` existed, and it is not watching. Without `--watch-env` the package is COPIED into `publisher_data/<env>/<pkg>/` at load and static files are served from that copy, so a file you add afterwards is never seen. Restart with `--watch-env <env>`, or mirror the file into the served copy. Verified by writing a probe file into the real package and getting a 404 while the copy served fine - and note the existing row below sends you looking under `public/`, which is exactly the wrong place for this one. |\n| Page loads in dev but is not listed or not served | The file is not under the package\'s `public/` directory. Publisher serves only `public/`; a page written anywhere else (for example `/tmp`) is never reachable at `/environments/<env>/packages/<pkg>/<file>`. |\n| Queries fail only when embedded cross-origin | Cookies are not sent cross-site. Serve same-origin, or pass a bearer token. |\n| No live reload | Watch mode is off. Start with `--watch-env <env>`; without it the events stream reports `mode: disabled` and never reloads. |'
356241
356711
  },
356242
356712
  {
356243
356713
  name: "malloy-html-data-apps/token-contract",
@@ -356603,6 +357073,59 @@ Three things bite on this path, and each makes a correct page look broken:
356603
357073
  - **Switch themes by clicking the app's own control**, not by setting an attribute on the root. Setting the attribute skips whatever re-resolves chart colours, so the charts keep their construction-time palette and you get a screenshot that looks like a real theming bug.
356604
357074
  - **Kill the browser when you finish** and use a port nobody else is on. A stray headless Chrome holding a profile directory is a confusing thing to debug later.
356605
357075
 
357076
+ ## 4. Embedded, layout stability, and drawers
357077
+
357078
+ Sections 2 and 3 prove the tiles render. They do not prove the app is polished, and every check below caught a real bug on a build that passed them. These need a live Publisher (the layout and timing are the point), a real browser, and real wheel events.
357079
+
357080
+ **Load it embedded, not only standalone.** Two hosts, in order of effort:
357081
+
357082
+ - **The Publisher console**, at \`http://localhost:4000/<env>/<package>/data-apps/<path>\` (for example \`.../data-apps/index.html\`). Same origin, no setup, and it sizes the frame through the same resize contract a host does.
357083
+ - **A test host page** that calls \`Publisher.embed\` inside a fixed header and a *scrolling container*, which is how real hosts lay out. Serve it from another origin (\`python3 -m http.server 4100 --bind 127.0.0.1\`) and start Publisher with \`PUBLISHER_FRAME_ANCESTORS="'self' http://127.0.0.1:4100"\`, or the browser refuses to frame the app.
357084
+
357085
+ Then, per page, embedded and standalone:
357086
+
357087
+ \`\`\`js
357088
+ // Layout shift: install before navigation, applies to every frame.
357089
+ await context.addInitScript(() => {
357090
+ window.__cls = 0;
357091
+ new PerformanceObserver((l) => l.getEntries().forEach((e) => { if (!e.hadRecentInput) window.__cls += e.value; }))
357092
+ .observe({ type: "layout-shift", buffered: true });
357093
+ });
357094
+ // Read __cls from the APP's frame after the page settles, on a first visit and
357095
+ // again after page.reload() in the same context (the repeat visit). Target:
357096
+ // under 0.1 first, 0 repeat. Embedded, every shift on the page counts.
357097
+
357098
+ // An auto-sized frame (the console, Publisher.embed without a height) must never
357099
+ // scroll: the document is exactly as tall as its frame. Skip this for a
357100
+ // fixed-height embed, which is meant to scroll inside its frame.
357101
+ const innerScroll = await appFrame.evaluate(() => document.documentElement.scrollHeight - innerHeight);
357102
+ if (innerScroll > 0) problems.push(\`app scrolls \${innerScroll}px inside its frame\`);
357103
+
357104
+ // Ask once: identical (model, query) pairs on one page.
357105
+ const seen = new Set(); let dup = 0;
357106
+ page.on("request", (r) => { if (r.url().includes("/query")) { const k = r.url() + r.postData(); dup += seen.has(k); seen.add(k); } });
357107
+ \`\`\`
357108
+
357109
+ **Drawer checks**, at two or three window sizes (1280×720, 1512×857 at 2×, 1920×1080). Scroll the host with \`page.mouse.wheel\` (not \`scrollTo\`: a real wheel is what exposes inner-frame scrolling), click a drill row that sits well down the page, and assert in **screen** coordinates (an element's rect inside the frame plus the iframe's rect in the host):
357110
+
357111
+ | Check | Pass |
357112
+ |---|---|
357113
+ | The clicked row's screen position, sampled every frame while the drawer opens | moves 0px |
357114
+ | Host scroll position during the open | unchanged |
357115
+ | Drawer header top | equals the top of the host's visible area (below the host's own header) |
357116
+ | Drawer bottom | equals the bottom of the visible area, within a few pixels (the band moves in steps of the observer's thresholds) |
357117
+ | Drawer body \`scrollTop\` on open, and after drilling into a second subject from inside it | 0 |
357118
+ | 12 wheel turns over the drawer | the body scrolls; the host and the app move 0px |
357119
+ | 4 wheel turns over the scrim | the host and the app move 0px |
357120
+ | Host scrolled by other means (\`scrollBy\` on the host container) while open | drawer still spans the visible area |
357121
+ | Escape | drawer gone, host where it was, focus back on the clicked row |
357122
+
357123
+ Compare screen coordinates against the host's *visible* area, not against the window: a host header or breadcrumb bar sits over the top of it. And sample the open as frames, not as one before/after pair: the page jump this catches can return to its starting point by the time a single later sample is taken.
357124
+
357125
+ **Charts draw, everywhere they appear.** Read pixels (section 3) on every page *and inside every kind of drawer*. Run it against the full chart library first, then again after any change to how the library is built or loaded: a series type missing from a trimmed bundle mounts its canvas and draws nothing, with no error.
357126
+
357127
+ **Wait for the warehouse, not a fixed time.** Locally a tile can take seconds; a fixed sleep then measures loading, not the app. Count \`/query\` requests in flight and treat the page as settled after about 1.5s with none, and attach those listeners *before* \`goto\`, or the first wave of queries is missed and the page "settles" at once.
357128
+
356606
357129
  ## Gotchas (each cost a real debugging cycle)
356607
357130
 
356608
357131
  - **Wait out the mock's async delay before asserting.** Asserting immediately after \`load\` reads the skeleton, not the resolved tile, and reports a false "stuck skeleton." Wait until every tile shows its own resolved content (the per-tile \`waitForFunction\` above), never on \`networkidle\`, because \`publisher.js\` keeps the live-reload SSE stream open, so the page never reaches network idle.
@@ -360803,7 +361326,7 @@ For anything not covered here, call \`search_malloy_docs\` with the topic (for e
360803
361326
  {
360804
361327
  name: "malloy-reports",
360805
361328
  description: "Build or modify a Malloy Publisher report: a .malloy file with an ## artifact tag, written at the package root unless asked otherwise, laid out as a grid (a dashboard, scanned at a glance) or linear (a notebook or data story, read top to bottom), with filter controls rendered from givens and # drill click-through. Use when the user asks for a dashboard, a notebook, a report, a data story, a filterable view, or drill-through between views, and no code is wanted. Legacy .malloynb files are read, not authored.",
360806
- body: "# Publisher Reports\n\n> A `.malloy` file with an `## artifact { layout=… tiles=[…] }` tag **is** a report. It imports the model, names the views to show, binds the filtering on those views, and says how to lay them out. Publisher discovers it at package load, renders the filter controls from the givens its tiles reference, offers `# drill` click-through, and serves it at `/<env>/<pkg>/<path>`, its package path without `.malloy`. No code, no build step.\n\nWhat other tools call a dashboard and a notebook are both reports here. The **layout** is the only difference:\n\n- **`layout=grid`**: tiles across a grid behind one filter row, scanned at a glance (a dashboard). Read `reference/grid.md` before you lay one out.\n- **`layout=linear`**: tiles and prose top to bottom, read in order (a notebook, a data story). Read `reference/linear.md` before you write one.\n\nEverything else (filters, text tiles, drill, the builder, the lint, the write path) is the same for both, and switching layout later is a one-property edit.\n\n## Where the file goes\n\n- **Write a new report at the package root**, as `<slug>.malloy`, unless the author asks for another location. No directory is required, and none is a convention: a report can live anywhere in the package except `public/`, and the root `index.malloy` is never a report. Do not create a `dashboards/` or `notebooks/` directory for it.\n- **Always write `layout=grid` or `layout=linear`** in the tag. A tag without one is read by a fallback rule, and a linear report that lists tiles without `layout=linear` is read as a grid. `kind=` is no longer read: a tag that sets it makes no report (`report-kind-removed`), so replace it with `layout=`.\n- **The package path is the report's identity.** `sales/q3.malloy` is the report `sales/q3`: its URL, its `GET …/reports/{path}` address, and its `# drill` target. Two reports may share a basename in different directories.\n- **Imports are relative to the report's own location.** At the root: `import { order_items } from \"storefront.malloy\"`. In a subdirectory: `\"../storefront.malloy\"`.\n- **An untagged `.malloy` file is an ordinary model**, wherever it lives. Shared definitions several reports import go in one.\n\n## When this is the right tool\n\n| The user wants | Use |\n| ---------------------------------------------------- | ------------------------------------------------------------ |\n| A recurring, at-a-glance view behind shared filters | this skill, `layout=grid` |\n| A narrative, with prose between the numbers | this skill, `layout=linear` |\n| A quick write-up of queries you already ran | `skill:malloy-analysis-report`, which writes a linear report |\n| Custom design, branding, or interactions beyond tags | an HTML data app |\n| The model itself: sources, measures, joins | `malloy-model` |\n\nInteractivity is not the axis: both layouts get filter controls, URL-addressable state, Apply batching, and `# drill`. Pick on the shape of the document: scanned at a glance is grid, read top to bottom is linear. When in doubt, linear.\n\n## Build sequence\n\nTool names below are bare (`get_context`); match each against the tools you actually have, since a host may prefix or rename them. A host names its compile and reload tools its own way. Where you have none, use the REST endpoint named beside the step.\n\n1. **READ THE MODEL FIRST.** Get the real source, view, dimension, and given names from the package: `get_context` if you have it, otherwise the REST model endpoint for `index.malloy` (it answers 404 for a file off the surface) or the `.malloy` files. Never guess a name. A guessed field in a query fails the whole package load, not just that one report; a guessed tile or suggest source is quieter, and only shows up in the package warnings. If the package root holds an `index.malloy` (every scaffolded package does), a tile reads only the sources that file exports.\n2. **PICK THE LAYOUT AND THE TILES.** Which views, in what order, and on a grid how wide each sits. A query tile is a `view:` you declare in the report, named as `\"source -> view\"`.\n3. **DECLARE THE GIVENS** the report will filter by, in the report file itself, with their control tags (see \"Filter controls\"). A report holds its own filters: the builder edits the report file and nothing else, so a filter it can add, change or remove is a declaration in that file. A given the model already declares and reads is imported by name, never declared again; if it is only a reader's filter, move it into the report instead (see \"Givens that stay in the model\").\n4. **COMPOSE THE FILE** at its path (the package root unless asked otherwise), following the template below, but do not save it yet. Name the sources you need in the import. Each tile binds its controls with a refinement, `view: t is v + { where: category ~ $CATEGORY }`, one clause per given it answers to; a `filter<...>` binds with `~`, a plain `date` or `number` given is a value and binds with `>=`, `<=` or `=`. Only the givens some tile references become controls. Then import every source or query any referenced given names in a `suggest`; **a `suggest` naming a `query=` needs that query's own source imported as well**, because an import is not transitive.\n5. **COMPILE IT** with your compile tool (`POST …/models/<path>/compile` over REST) at the path the file will have, with **`\"scope\": \"file\"`**: a report opens with an `import`, which the default `append` scope refuses, and an existing file compiled at `append` collides with itself. **`\"scope\": \"file\"` does not check tiles, drills or suggest queries.** To see those before you save, compile the same text at `\"scope\": \"package\"` with `modelPath` set to the report's path: it runs the lint step 6 reads and returns its findings with code `dashboard-lint` (or `render-tag`). Editing an untagged model several reports import also wants `\"scope\": \"package\"`, which recompiles every file as saved.\n6. **SAVE IT, RELOAD, AND READ THE MANIFEST AND THE WARNINGS.** Save by writing the file, or with `PUT …/models/<path>` over REST (omit `expectedHash` to create; it creates at any new path and replaces only a file the package already serves as a report). Then your reload tool, or `GET …/packages/<pkg>?reload=true`. A 424 means the package did not load and your edit is not live. **The `warnings` key is absent when there are none.** Then read `GET …/packages/<pkg>/reports/<path>`: its `givens` are exactly the controls that will render, and its `layout` is the layout Publisher read. See \"Read the lint\".\n7. **OPEN IT AND LOOK.** Not optional; see \"What done means\".\n\n## The form\n\n```malloy\n##! experimental.givens\n##\" One line saying what this report is for.\n##| artifact { layout=grid title=\"Storefront overview\"\n tiles=[\n intro { kind=text colspan=12 },\n \"overview -> kpis\",\n \"overview -> revenue_trend\",\n \"overview -> revenue_by_state\"\n ]\n} dashboard { columns=12 }\n|##\nimport { order_items, products } from \"storefront.malloy\"\n\n# label=\"Category\" control=select suggest { source=products dimension=category }\ngiven: CATEGORY :: filter<string> is f''\n# label=\"Ordered since\"\ngiven: SINCE :: date is @2023-01-01\n\n##|(markdown) intro\nRevenue and margin for the selected category.\n|##\n\nsource: overview is order_items extend {\n # colspan=12\n # label=\"Key figures\"\n # big_value\n view: kpis is key_figures + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n\n # colspan=8\n # break\n # label=\"Revenue by month\"\n view: revenue_trend is sales_by_month + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n\n # colspan=4\n # label=\"Revenue by state\"\n view: revenue_by_state is sales_by_state + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n}\n```\n\nThe same file with `layout=linear` and no `dashboard { … }` or `colspan` is a linear report.\n\n- **The tag is model-level**, one line (`## artifact { … }`) or a `##|` … `|##` block with the same text inside, the form to use once the list is long: one tile per line, `}` on its own line, `|##` closing at the opener's column. A one-line `##` tag must stay on one line: wrapping it is a compile error, or worse, compiles clean and quietly stops being a report.\n- **The header** above the tag holds only `##!` flags, `//` comments and the unnamed `\"` notes that are the report's description. Keep statements and `#` tags below `## artifact`: nothing refuses one above it, but the header is where readers and the builder look.\n- **Quote every query entry.** `\"overview -> kpis\"` is a string; unquoted, the tag does not parse (`notebook-artifact-unparsed`). A bare quoted name (`\"top_products\"`) names a model-level `query:` in the file.\n- **A query tile is a thin `view:` on an extension the report declares** (`source: overview is order_items extend { … }`): the modelled view keeps its chart tag, the wrapper carries the tile's `# label`, its width on a grid, and its binding.\n- **A text tile is a `##|(markdown) name` block** listed as `name { kind=text }`. The name is one bare word on the opener line, the body starts on the next line (a heading goes inside the block), and `|##` closes it at the opener's column. A bare `intro` without `{ kind=text }` is the query-tile form for a `query:`, so the block is not shown. On a grid the entry also takes `colspan` and `break`, and nothing else.\n- **File order:** header, imports and givens, the prose blocks in tile order, then the extension that defines the tile views. That is the order the builder writes.\n- **A `run:` in a report that lists `tiles` is never shown** (`notebook-layout-run`). Define a view, list it, delete the `run:`.\n- **A tile expression is a string, so the compiler never checks it.** Rename a view and the file still compiles; the tile fails at package load, where the lint names it.\n- **Chart tags go on the view** (`# line_chart`, `# bar_chart`, `# big_value`, `# label=\"…\"`). `skill:malloy-charts` covers choosing them.\n\n## Filter controls\n\nControls come from the `given:` declarations the tiles reference. The tags on the declaration are the control contract:\n\n```malloy\n##! experimental.givens\n\n# label=\"Category\" control=select suggest { source=products dimension=category }\ngiven: CATEGORY :: filter<string> is f''\n\n# label=\"Brand\" control=multiselect suggest { query=brand_suggest dimension=brand }\ngiven: BRAND :: filter<string> is f''\n\n# label=\"Minimum line total\" range_min=0 range_max=250\ngiven: MIN_SALE :: filter<number> is f''\n\n# label=\"Ordered since\"\ngiven: SINCE :: date is @2023-01-01\n```\n\n`control=select`/`multiselect` with a `suggest` renders a picker filled from the data; `range_min`/`range_max` on a `filter<number>` renders a two-handled range slider; a `filter<date>` or `filter<timestamp>` renders a time-range control with preset windows and a custom day range; a bare `date` or `timestamp` renders a date picker; a `filter<string>` with no tag renders a text box taking filter syntax. Which controls appear is per report, decided by which givens its tiles reference.\n\n- `#(description=\"…\")` renders as helper text under the control and draws a cosmetic \"not a well-formed route\" compile diagnostic for a multi-word value; `# description=\"…\"` publishes to the API but the UI does not render it.\n- `dimension=` can name a field of a directly joined source, quoted: `suggest { source=sales dimension=\"products.department\" }`. On the `query=` form, `dimension` names a column of that query's output; match it to the query's `group_by`.\n- **A suggest's source or query has to resolve in the report file**, so import it there. Getting it wrong does not error: the control still looks like a picker and says \"Could not load the options for this control\"; the package warnings name it.\n- Two options on the artifact tag, either layout: `autorun=false` batches control changes behind an Apply button; `givens { CATEGORY=f'Outerwear' }` sets starting values (a URL parameter wins). A file-level `## autorun=false` does nothing on a `.malloy` report.\n\n### Binding, and givens that stay in the model\n\n**Binding is per declaration, not per name.** A report declaring its own `CATEGORY` over a source whose model-level `where:` reads the model's `CATEGORY` compiles, shows the control, and filters nothing. So bind on the tiles, with `+ { where: … }`. The other design, model-level scoping, still works: put a scoped source in an untagged model that imports the givens it reads, and import that model and those givens into the report. Do not mix the two on one given.\n\nA given stays in the model, declared once there, when a model source, view or measure, an `#(authorize)` or `#(access_filter)` gate, or an HTML data app reads it. The report imports it by name (`import { CATEGORY } from \"givens.malloy\"`) and never re-declares it:\n\n- **Import a name and also declare it: a compile error.** Drop the local declaration.\n- **Declare a name the model already reads, without importing it: no error at all.** The control renders and moves, and the model's own `where:` or gate never sees the value.\n\n**A model that already scopes a source by an optional filter given** (`where: Manufacturer ~ $manufacturer_filter` in the model) needs a decision:\n\n- **It is a reader's filter and you may change the model: move it.** Delete the given and its `where:` clause from the model, declare it in the report with its control tags, and bind it on each tile that should answer to it. A reader's filter is anything a person picks on the page; a given stays in the model only for the reasons `malloy-model` lists (a gate, a value every query must supply, a parameter a field is defined with, scoping meant for every consumer).\n- **The model can't change, or the given is semantic or governance: import it** as above. Say so plainly: the filter shows in the reader and in the builder, and can be set in both, but the builder cannot remove or reconfigure it. It is the model's.\n\nBefore, in the model:\n\n```malloy\n##! experimental.givens\ngiven:\n manufacturer_filter :: filter<string> is f''\n subject_filter :: filter<string> is f''\n major_recall_filter :: filter<boolean> is f''\n recall_date_filter :: filter<date> is f''\n\nsource: recalls is duckdb.table('data/auto_recalls.csv') extend {\n where:\n Manufacturer ~ $manufacturer_filter,\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n dimension: `Major Recall` is ifnull(`Potentially Affected`, 0) > 100000\n}\n```\n\nAfter: the model declares no givens and `recalls` has no `where:`; the report declares the four and binds each tile:\n\n```malloy\n##! experimental.givens\n##| artifact { layout=grid title=\"Recalls\"\n tiles=[\"recalls_tiles -> recalls_by_year\", \"recalls_tiles -> recalls_by_manufacturer\"]\n} dashboard { columns=12 }\n|##\nimport { recalls } from \"auto_recalls.malloy\"\n\n# label=\"Manufacturer\" control=multiselect suggest { source=recalls dimension=Manufacturer }\ngiven: manufacturer_filter :: filter<string> is f''\n# label=\"Subject\"\ngiven: subject_filter :: filter<string> is f''\n# label=\"Major recalls only\"\ngiven: major_recall_filter :: filter<boolean> is f''\n# label=\"Filed\"\ngiven: recall_date_filter :: filter<date> is f''\n\nsource: recalls_tiles is recalls extend {\n # colspan=6\n view: recalls_by_year is by_year + {\n where:\n Manufacturer ~ $manufacturer_filter,\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n }\n # colspan=6\n view: recalls_by_manufacturer is by_manufacturer + {\n where:\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n }\n}\n```\n\nA tile view takes a name of its own: `view: by_year is by_year + { … }` is \"Cannot redefine 'by_year'\". Each tile binds only the givens it should answer to: `recalls_by_manufacturer` leaves the manufacturer filter off, since it is the breakdown by manufacturer. Every given here is a `filter<…>`, so each binds with `~`; a plain `date` or `number` given binds with `>=`, `<=` or `=` (step 4).\n\nA given has to be in the report file's own scope to be bindable: declared there or imported. Never add a `#(filter)` annotation: it is deprecated, a report renders no control for it, and every use has a `given:` form (`malloy-model` covers the migration). `malloy-model` and `docs/givens.md` cover givens themselves.\n\n## Drill\n\n`# drill` goes on a model **dimension**, never on a report:\n\n```malloy\n# drill { to=[\"category\", \"self\"] given=CATEGORY }\ndimension: category is products.category\n\n# drill { to=\"regions/west\" given=REGION }\ndimension: region is regions.region\n```\n\n- **`to=<path>`** navigates to a report, of either layout, by its package path without `.malloy`, read from the package root: `to=category` is `category.malloy` at the root, `to=\"regions/west\"` is `regions/west.malloy`. Quote a path with a `/`. **Always write the path from the package root**: a bare name is a root file, never a report of that name elsewhere.\n- **`to=self`** filters in place; two or more destinations pop a menu.\n- **Always write `given=`.** Without it the given is the dimension name verbatim, so `dimension: category` seeds `category`, not `CATEGORY`. `to=self` survives that (it folds case); `to=<path>` does not, and lands on an unfiltered page with nothing erroring.\n- **The destination needs a control for the given**, declared as `filter<T>`. No lint checks that half, so click it and look.\n- **Only table cells are marked drillable.** A report meant to be drilled wants at least one untagged (table) tile, grouped by the tagged dimension. A column whose rendered header another, non-drillable tile also shows loses its marking: give the two different `# label=`s.\n\n## Read the lint\n\nPackage warnings after a reload are the report's test suite. Fix all of them, and read the text rather than the severity or the count. **\"Dashboard lint stopped early, so this list is incomplete\"** means the rest of the list is partial.\n\n| Finding | Fix |\n| ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\n| `# drill … targets \"x\", which is not a report in this package` | A dead click. Write the target's path |\n| `to=self, but no model in this package declares a given \"X\"` | The clicked value has nowhere to go. Declare the given, or fix `given=` |\n| `given \"X\" suggests options from source \"y\", which this file does not define` | Import `y` into the report |\n| `filters by given \"X\", which this file does not import` | No control is shown for it. Import the given |\n| A tile that does not resolve to a real view | Fix the view name, or declare the view |\n| `notebook-artifact-unparsed` | The tag does not parse, usually an unquoted `source -> view` entry |\n| `notebook-layout-run` | A `run:` beside `tiles`. Make it a view, list it, delete the `run:` |\n| `notebook-tile-layout-ignored` | `colspan` or `break` on a linear report. Remove them, or switch to `layout=grid` |\n| `report-kind-removed` | The tag sets `kind=`, which is no longer read, so the file is no report. Write the `layout=` the finding gives |\n| `report-cells-removed` | A linear tag with no `tiles`, the retired cell format, so the file is no report. List its tiles (`reference/linear.md`) |\n| `report-on-surface` | A model `index.malloy` imports (or `explores` lists) is tagged, so its sources left the surface. Untag it, or move the report out |\n| `index-is-not-a-report` | The root `index.malloy` carries a tag. Remove it; put the report in its own file |\n| `report-name-ambiguous` | Two grid reports share a file name, so the deprecated by-name dashboards route answers 409 for it. Rename one of the files |\n| `Tile … reads <source>, which index.malloy doesn't export` | Add the source to the `export { ... }` in `index.malloy`. Never add an `explores` to `publisher.json` |\n| A grid's `columns`, `colspan`, or `dashboard_columns=` findings | `reference/grid.md` |\n| Prose the reader cannot place (`notebook-markdown-attached-nowhere`, `notebook-markdown-block-unnamed`) | `reference/linear.md` |\n\n**Read the status the reload itself returns, not the listing.** One report that fails to compile fails the whole package load (424), and a package already serving keeps its previous version, so the listing still looks healthy. `GET /api/v0/status` still knows: a package serving an older model than its files appears in `loadErrors` with `stale: true`. If the reload is 200 and your report is not listed, discovery skipped it: usually a missing or misspelled artifact tag, or a file under `public/`.\n\n**A clean reload is not proof the tags are right.** The parse warning is syntax only and carries no position. That is why the last step is opening the page.\n\n## Template\n\n```malloy\n##! experimental.givens\n##\" [One-line description of the report.]\n##| artifact { layout=linear title=\"[Title]\"\n tiles=[\n intro { kind=text },\n \"main_source_tiles -> broad_tile\",\n \"main_source_tiles -> drill_tile\"\n ]\n}\n|##\nimport { main_source } from \"model.malloy\"\n\n##|(markdown) intro\n[Framing question: what are we trying to understand?]\n|##\n\nsource: main_source_tiles is main_source extend {\n view: broad_tile is [broad view]\n view: drill_tile is [drill view]\n}\n```\n\nFor a grid, write `layout=grid`, add `dashboard { columns=12 }` after the tag's closing `}`, and give every tile view a `# colspan` (`reference/grid.md`).\n\n## Editing in the Console\n\nA person can open any report in the Console's builder (**Edit** on the report page; **+ Report** on the package page writes `<slug>.malloy` at the package root). The builder splices the file rather than regenerating it, so comments and anything it does not model survive; its layout switch rewrites `layout=` and nothing else. A file written as `run:` cells, the retired linear form, is no report and does not open (`reference/linear.md`). A save whose text declares a real `#(authorize)` or `#(access_filter)` gate outside prose is refused with a 400: gates live in a model file the report imports.\n\n## What \"done\" means\n\n- The file is at the package root (or where the author asked), and its tag says `layout=`.\n- Every source, view, and field name came from the model you read in step 1.\n- The reload returned **200**, not 424, with **zero** report warnings, and your report is in `GET …/reports`.\n- You opened the page and every tile shows real numbers: not stuck loading, not an error, not an empty state you did not intend.\n- Each control renders as the widget you intended, and changing one changes the numbers.\n- If you added a `# drill`, its `to=` is a path, and you clicked it and landed where you meant to, with the given seeded.\n- On a grid: every tile carries a colspan, each row sums to `columns`, rows end flush, nothing is clipped, and no legend or card label ends in `…`.\n\n## Reference\n\n- `reference/grid.md`: the grid recipe, its traps, and one query as the whole page.\n- `reference/linear.md`: linear reports, rewriting a retired `run:`-cell file, legacy `.malloynb`, style, and common mistakes.\n- `docs/reports.md`: the full guide this skill condenses.\n- `docs/givens.md`: the givens the controls are generated from.\n\n## Reference files over MCP\n\nThis skill's `reference/` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read `reference/<name>.md`, get the prompt named `malloy-reports/<name>` instead.\n\nAvailable: grid, linear."
361329
+ body: "# Publisher Reports\n\n> A `.malloy` file with an `## artifact { layout=… tiles=[…] }` tag **is** a report. It imports the model, names the views to show, binds the filtering on those views, and says how to lay them out. Publisher discovers it at package load, renders the filter controls from the givens its tiles reference, offers `# drill` click-through, and serves it at `/<env>/<pkg>/<path>`, its package path without `.malloy`. No code, no build step.\n\nWhat other tools call a dashboard and a notebook are both reports here. The **layout** is the only difference:\n\n- **`layout=grid`**: tiles across a grid behind one filter row, scanned at a glance (a dashboard). Read `reference/grid.md` before you lay one out.\n- **`layout=linear`**: tiles and prose top to bottom, read in order (a notebook, a data story). Read `reference/linear.md` before you write one.\n\nEverything else (filters, text tiles, drill, the builder, the lint, the write path) is the same for both, and switching layout later is a one-property edit.\n\n## Where the file goes\n\n- **Write a new report at the package root**, as `<slug>.malloy`, unless the author asks for another location. No directory is required, and none is a convention: a report can live anywhere in the package except `public/`, and the root `index.malloy` is never a report. Do not create a `dashboards/` or `notebooks/` directory for it.\n- **Always write `layout=grid` or `layout=linear`** in the tag. A tag without one is read by a fallback rule, and a linear report that lists tiles without `layout=linear` is read as a grid. `kind=` is no longer read: a tag that sets it makes no report (`report-kind-removed`), so replace it with `layout=`.\n- **The package path is the report's identity.** `sales/q3.malloy` is the report `sales/q3`: its URL, its `GET …/reports/{path}` address, and its `# drill` target. Two reports may share a basename in different directories.\n- **Imports are relative to the report's own location.** At the root: `import { order_items } from \"storefront.malloy\"`. In a subdirectory: `\"../storefront.malloy\"`.\n- **An untagged `.malloy` file is an ordinary model**, wherever it lives. Shared definitions several reports import go in one.\n\n## When this is the right tool\n\n| The user wants | Use |\n| ---------------------------------------------------- | ------------------------------------------------------------ |\n| A recurring, at-a-glance view behind shared filters | this skill, `layout=grid` |\n| A narrative, with prose between the numbers | this skill, `layout=linear` |\n| A quick write-up of queries you already ran | `skill:malloy-analysis-report`, which writes a linear report |\n| Custom design, branding, or interactions beyond tags | an HTML data app |\n| The model itself: sources, measures, joins | `malloy-model` |\n\nInteractivity is not the axis: both layouts get filter controls, URL-addressable state, Apply batching, and `# drill`. Pick on the shape of the document: scanned at a glance is grid, read top to bottom is linear. When in doubt, linear.\n\n## Build sequence\n\nTool names below are bare (`get_context`); match each against the tools you actually have, since a host may prefix or rename them. A host names its compile and reload tools its own way. Where you have none, use the REST endpoint named beside the step.\n\n1. **READ THE MODEL FIRST.** Get the real source, view, dimension, and given names from the package: `get_context` if you have it, otherwise the REST model endpoint for `index.malloy` (it answers 404 for a file off the surface) or the `.malloy` files. Never guess a name. A guessed field in a query fails the whole package load, not just that one report; a guessed tile or suggest source is quieter, and only shows up in the package warnings. If the package root holds an `index.malloy` (every scaffolded package does), a tile reads only the sources that file exports.\n2. **PICK THE LAYOUT AND THE TILES.** Which views, in what order, and on a grid how wide each sits. A query tile is a `view:` you declare in the report, named as `\"source -> view\"`.\n3. **DECLARE THE GIVENS** the report will filter by, in the report file itself, with their control tags (see \"Filter controls\"). A report holds its own filters: the builder edits the report file and nothing else, so a filter it can add, change or remove is a declaration in that file. A given the model already declares and reads is imported by name, never declared again; if it is only a reader's filter, move it into the report instead (see \"Givens that stay in the model\").\n4. **COMPOSE THE FILE** at its path (the package root unless asked otherwise), following the template below, but do not save it yet. Name the sources you need in the import. Each tile binds its controls with a refinement, `view: t is v + { where: category ~ $CATEGORY }`, one clause per given it answers to; a `filter<...>` binds with `~`, a plain `date` or `number` given is a value and binds with `>=`, `<=` or `=`. Only the givens some tile references become controls. Then import every source or query any referenced given names in a `suggest`; **a `suggest` naming a `query=` needs that query's own source imported as well**, because an import is not transitive.\n5. **COMPILE IT** with your compile tool (`POST …/models/<path>/compile` over REST) at the path the file will have, with **`\"scope\": \"file\"`**: a report opens with an `import`, which the default `append` scope refuses, and an existing file compiled at `append` collides with itself. **`\"scope\": \"file\"` does not check tiles, drills or suggest queries.** To see those before you save, compile the same text at `\"scope\": \"package\"` with `modelPath` set to the report's path: it runs the lint step 6 reads and returns its findings with code `dashboard-lint` (or `render-tag`). Editing an untagged model several reports import also wants `\"scope\": \"package\"`, which recompiles every file as saved.\n6. **SAVE IT, RELOAD, AND READ THE MANIFEST AND THE WARNINGS.** Save by writing the file, or with `PUT …/models/<path>` over REST (omit `expectedHash` to create; it creates at any new path and replaces only a file the package already serves as a report). Then your reload tool, or `GET …/packages/<pkg>?reload=true`. A 424 means the package did not load and your edit is not live. **The `warnings` key is absent when there are none.** Then read `GET …/packages/<pkg>/reports/<path>`: its `givens` are exactly the controls that will render, and its `layout` is the layout Publisher read. See \"Read the lint\".\n7. **OPEN IT AND LOOK.** Not optional; see \"What done means\".\n\n## The form\n\n```malloy\n##! experimental.givens\n##\" One line saying what this report is for.\n##| artifact { layout=grid title=\"Storefront overview\"\n tiles=[\n intro { kind=text colspan=12 },\n \"overview -> kpis\",\n \"overview -> revenue_trend\",\n \"overview -> revenue_by_state\"\n ]\n} dashboard { columns=12 }\n|##\nimport { order_items, products } from \"storefront.malloy\"\n\n# label=\"Category\" control=select suggest { source=products dimension=category }\ngiven: CATEGORY :: filter<string> is f''\n# label=\"Ordered since\"\ngiven: SINCE :: date is @2023-01-01\n\n##|(markdown) intro\nRevenue and margin for the selected category.\n|##\n\nsource: overview is order_items extend {\n # colspan=12\n # label=\"Key figures\"\n # big_value\n view: kpis is key_figures + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n\n # colspan=8\n # break\n # label=\"Revenue by month\"\n view: revenue_trend is sales_by_month + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n\n # colspan=4\n # label=\"Revenue by state\"\n view: revenue_by_state is sales_by_state + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n}\n```\n\nThe same file with `layout=linear` and no `dashboard { … }` or `colspan` is a linear report.\n\n- **The tag is model-level**, one line (`## artifact { … }`) or a `##|` … `|##` block with the same text inside, the form to use once the list is long: one tile per line, `}` on its own line, `|##` closing at the opener's column. A one-line `##` tag must stay on one line: wrapping it is a compile error, or worse, compiles clean and quietly stops being a report.\n- **The header** above the tag holds only `##!` flags, `//` comments and the unnamed `\"` notes that are the report's description. Keep statements and `#` tags below `## artifact`: nothing refuses one above it, but the header is where readers and the builder look.\n- **Quote every query entry.** `\"overview -> kpis\"` is a string; unquoted, the tag does not parse (`notebook-artifact-unparsed`). A bare quoted name (`\"top_products\"`) names a model-level `query:` in the file.\n- **A query tile is a thin `view:` on an extension the report declares** (`source: overview is order_items extend { … }`): the modelled view keeps its chart tag, the wrapper carries the tile's `# label`, its width on a grid, and its binding.\n- **A text tile is a `##|(markdown) name` block** listed as `name { kind=text }`. The name is one bare word on the opener line, the body starts on the next line (a heading goes inside the block), and `|##` closes it at the opener's column. A bare `intro` without `{ kind=text }` is the query-tile form for a `query:`, so the block is not shown. On a grid the entry also takes `colspan` and `break`, and nothing else.\n- **File order:** header, imports and givens, the prose blocks in tile order, then the extension that defines the tile views. That is the order the builder writes.\n- **A `run:` in a report that lists `tiles` is never shown** (`notebook-layout-run`). Define a view, list it, delete the `run:`.\n- **A tile expression is a string, so the compiler never checks it.** Rename a view and the file still compiles; the tile fails at package load, where the lint names it.\n- **Chart tags go on the view** (`# line_chart`, `# bar_chart`, `# big_value`, `# label=\"…\"`). `skill:malloy-charts` covers choosing them.\n\n## Filter controls\n\nControls come from the `given:` declarations the tiles reference. The tags on the declaration are the control contract:\n\n```malloy\n##! experimental.givens\n\n# label=\"Category\" control=select suggest { source=products dimension=category }\ngiven: CATEGORY :: filter<string> is f''\n\n# label=\"Brand\" control=multiselect suggest { query=brand_suggest dimension=brand }\ngiven: BRAND :: filter<string> is f''\n\n# label=\"Minimum line total\" range_min=0 range_max=250\ngiven: MIN_SALE :: filter<number> is f''\n\n# label=\"Ordered since\"\ngiven: SINCE :: date is @2023-01-01\n```\n\n`control=select`/`multiselect` with a `suggest` renders a picker filled from the data; `range_min`/`range_max` on a `filter<number>` renders a two-handled range slider; a `filter<date>` or `filter<timestamp>` renders a time-range control with preset windows and a custom day range; a bare `date` or `timestamp` renders a date picker; a `filter<string>` with no tag renders a text box taking filter syntax. Which controls appear is per report, decided by which givens its tiles reference.\n\n- `#(description=\"…\")` renders as helper text under the control and draws a cosmetic \"not a well-formed route\" compile diagnostic for a multi-word value; `# description=\"…\"` publishes to the API but the UI does not render it.\n- `dimension=` can name a field of a directly joined source, quoted: `suggest { source=sales dimension=\"products.department\" }`. On the `query=` form, `dimension` names a column of that query's output; match it to the query's `group_by`.\n- **A suggest's source or query has to resolve in the report file**, so import it there. Getting it wrong does not error: the control still looks like a picker and says \"Could not load the options for this control\"; the package warnings name it.\n- Two options on the artifact tag, either layout: `autorun=false` batches control changes behind an Apply button; `givens { CATEGORY=f'Outerwear' }` sets starting values (a URL parameter wins). A file-level `## autorun=false` does nothing on a `.malloy` report.\n\n### Binding, and givens that stay in the model\n\n**Binding is per declaration, not per name.** A report declaring its own `CATEGORY` over a source whose model-level `where:` reads the model's `CATEGORY` compiles, shows the control, and filters nothing. So bind on the tiles, with `+ { where: … }`. The other design, model-level scoping, still works: put a scoped source in an untagged model that imports the givens it reads, and import that model and those givens into the report. Do not mix the two on one given.\n\nA given stays in the model, declared once there, when a model source, view or measure, an `#(authorize)` or `#(access_filter)` gate, or an HTML data app reads it. The report imports it by name (`import { CATEGORY } from \"givens.malloy\"`) and never re-declares it:\n\n- **Import a name and also declare it: a compile error.** Drop the local declaration.\n- **Declare a name the model already reads, without importing it: no error at all.** The control renders and moves, and the model's own `where:` or gate never sees the value.\n\n**A model that already scopes a source by an optional filter given** (`where: Manufacturer ~ $manufacturer_filter` in the model) needs a decision:\n\n- **It is a reader's filter and you may change the model: move it.** Delete the given and its `where:` clause from the model, declare it in the report with its control tags, and bind it on each tile that should answer to it. A reader's filter is anything a person picks on the page; a given stays in the model only for the reasons `malloy-model` lists (a gate, a value every query must supply, a parameter a field is defined with, scoping meant for every consumer).\n- **The model can't change, or the given is semantic or governance: import it** as above. Say so plainly: the filter shows in the reader and in the builder, and can be set in both, but the builder cannot remove or reconfigure it. It is the model's.\n\nBefore, in the model:\n\n```malloy\n##! experimental.givens\ngiven:\n manufacturer_filter :: filter<string> is f''\n subject_filter :: filter<string> is f''\n major_recall_filter :: filter<boolean> is f''\n recall_date_filter :: filter<date> is f''\n\nsource: recalls is duckdb.table('data/auto_recalls.csv') extend {\n where:\n Manufacturer ~ $manufacturer_filter,\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n dimension: `Major Recall` is ifnull(`Potentially Affected`, 0) > 100000\n}\n```\n\nAfter: the model declares no givens and `recalls` has no `where:`; the report declares the four and binds each tile:\n\n```malloy\n##! experimental.givens\n##| artifact { layout=grid title=\"Recalls\"\n tiles=[\"recalls_tiles -> recalls_by_year\", \"recalls_tiles -> recalls_by_manufacturer\"]\n} dashboard { columns=12 }\n|##\nimport { recalls } from \"auto_recalls.malloy\"\n\n# label=\"Manufacturer\" control=multiselect suggest { source=recalls dimension=Manufacturer }\ngiven: manufacturer_filter :: filter<string> is f''\n# label=\"Subject\"\ngiven: subject_filter :: filter<string> is f''\n# label=\"Major recalls only\"\ngiven: major_recall_filter :: filter<boolean> is f''\n# label=\"Filed\"\ngiven: recall_date_filter :: filter<date> is f''\n\nsource: recalls_tiles is recalls extend {\n # colspan=6\n view: recalls_by_year is by_year + {\n where:\n Manufacturer ~ $manufacturer_filter,\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n }\n # colspan=6\n view: recalls_by_manufacturer is by_manufacturer + {\n where:\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n }\n}\n```\n\nA tile view takes a name of its own: `view: by_year is by_year + { … }` is \"Cannot redefine 'by_year'\". Each tile binds only the givens it should answer to: `recalls_by_manufacturer` leaves the manufacturer filter off, since it is the breakdown by manufacturer. Every given here is a `filter<…>`, so each binds with `~`; a plain `date` or `number` given binds with `>=`, `<=` or `=` (step 4).\n\nA given has to be in the report file's own scope to be bindable: declared there or imported. Never add a `#(filter)` annotation: it is deprecated, a report renders no control for it, and every use has a `given:` form (`malloy-model` covers the migration). `malloy-model` and `docs/givens.md` cover givens themselves.\n\n## Drill\n\n`# drill` goes on a model **dimension**, never on a report:\n\n```malloy\n# drill { to=[\"category\", \"self\"] given=CATEGORY }\ndimension: category is products.category\n\n# drill { to=\"regions/west\" given=REGION }\ndimension: region is regions.region\n```\n\n- **`to=<path>`** navigates to a report, of either layout, by its package path without `.malloy`, read from the package root: `to=category` is `category.malloy` at the root, `to=\"regions/west\"` is `regions/west.malloy`. Quote a path with a `/`. **Always write the path from the package root**: a bare name is a root file, never a report of that name elsewhere.\n- **`to=self`** filters in place; two or more destinations pop a menu.\n- **Always write `given=`.** Without it the given is the dimension name verbatim, so `dimension: category` seeds `category`, not `CATEGORY`. `to=self` survives that (it folds case); `to=<path>` does not, and lands on an unfiltered page with nothing erroring.\n- **The destination needs a control for the given**, declared as `filter<T>`. No lint checks that half, so click it and look.\n- **Only table cells are marked drillable.** A report meant to be drilled wants at least one untagged (table) tile, grouped by the tagged dimension. A column whose rendered header another, non-drillable tile also shows loses its marking: give the two different `# label=`s.\n\n## Read the lint\n\nPackage warnings after a reload are the report's test suite. Fix all of them, and read the text rather than the severity or the count. **\"Dashboard lint stopped early, so this list is incomplete\"** means the rest of the list is partial.\n\n| Finding | Fix |\n| ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\n| `# drill … targets \"x\", which is not a report in this package` | A dead click. Write the target's path |\n| `to=self, but no model in this package declares a given \"X\"` | The clicked value has nowhere to go. Declare the given, or fix `given=` |\n| `given \"X\" suggests options from source \"y\", which this file does not define` | Import `y` into the report |\n| `filters by given \"X\", which this file does not import` | No control is shown for it. Import the given |\n| A tile that does not resolve to a real view | Fix the view name, or declare the view |\n| `notebook-artifact-unparsed` | The tag does not parse, usually an unquoted `source -> view` entry |\n| `notebook-layout-run` | A `run:` beside `tiles`. Make it a view, list it, delete the `run:` |\n| `notebook-tile-layout-ignored` | `colspan` or `break` on a linear report. Remove them, or switch to `layout=grid` |\n| `report-kind-removed` | The tag sets `kind=`, which is no longer read, so the file is no report. Write the `layout=` the finding gives |\n| `report-cells-removed` | A linear tag with no `tiles`, the retired cell format, so the file is no report. List its tiles (`reference/linear.md`) |\n| `report-on-surface` | A model `index.malloy` imports (or `explores` lists) is tagged, so its sources left the surface. Untag it, or move the report out |\n| `index-is-not-a-report` | The root `index.malloy` carries a tag. Remove it; put the report in its own file |\n| `report-name-ambiguous` | Two grid reports share a file name, so the deprecated by-name dashboards route answers 409 for it. Rename one of the files |\n| `Tile … reads <source>, which index.malloy doesn't export` | Add the source to the `export { ... }` in `index.malloy`. Never add an `explores` to `publisher.json` |\n| A grid's `columns`, `colspan`, or `dashboard_columns=` findings | `reference/grid.md` |\n| Prose the reader cannot place (`notebook-markdown-attached-nowhere`, `notebook-markdown-block-unnamed`) | `reference/linear.md` |\n\n**Read the status the reload itself returns, not the listing.** One report that fails to compile fails the whole package load (424), and a package already serving keeps its previous version, so the listing still looks healthy. `GET /api/v0/status` still knows: a package serving an older model than its files appears in `loadErrors` with `stale: true`. If the reload is 200 and your report is not listed, discovery skipped it: usually a missing or misspelled artifact tag, or a file under `public/`.\n\n**A clean reload is not proof the tags are right.** The parse warning is syntax only and carries no position. That is why the last step is opening the page.\n\n## Template\n\n```malloy\n##! experimental.givens\n##\" [One-line description of the report.]\n##| artifact { layout=linear title=\"[Title]\"\n tiles=[\n intro { kind=text },\n \"main_source_tiles -> broad_tile\",\n \"main_source_tiles -> drill_tile\"\n ]\n}\n|##\nimport { main_source } from \"model.malloy\"\n\n##|(markdown) intro\n[Framing question: what are we trying to understand?]\n|##\n\nsource: main_source_tiles is main_source extend {\n view: broad_tile is [broad view]\n view: drill_tile is [drill view]\n}\n```\n\nFor a grid, write `layout=grid`, add `dashboard { columns=12 }` after the tag's closing `}`, and give every tile view a `# colspan` (`reference/grid.md`).\n\n## Editing in the Console\n\nA person can open any report in the Console's builder (**Edit** on the report page; **+ Report** on the package page writes `<slug>.malloy` at the package root). The builder splices the file rather than regenerating it, so comments and anything it does not model survive; it never rewrites `layout=`, so the layout is set in the file. A file written as `run:` cells, the retired linear form, is no report and does not open (`reference/linear.md`). A save whose text declares a real `#(authorize)` or `#(access_filter)` gate outside prose is refused with a 400: gates live in a model file the report imports.\n\n## What \"done\" means\n\n- The file is at the package root (or where the author asked), and its tag says `layout=`.\n- Every source, view, and field name came from the model you read in step 1.\n- The reload returned **200**, not 424, with **zero** report warnings, and your report is in `GET …/reports`.\n- You opened the page and every tile shows real numbers: not stuck loading, not an error, not an empty state you did not intend.\n- Each control renders as the widget you intended, and changing one changes the numbers.\n- If you added a `# drill`, its `to=` is a path, and you clicked it and landed where you meant to, with the given seeded.\n- On a grid: every tile carries a colspan, each row sums to `columns`, rows end flush, nothing is clipped, and no legend or card label ends in `…`.\n\n## Reference\n\n- `reference/grid.md`: the grid recipe, its traps, and one query as the whole page.\n- `reference/linear.md`: linear reports, rewriting a retired `run:`-cell file, legacy `.malloynb`, style, and common mistakes.\n- `docs/reports.md`: the full guide this skill condenses.\n- `docs/givens.md`: the givens the controls are generated from.\n\n## Reference files over MCP\n\nThis skill's `reference/` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read `reference/<name>.md`, get the prompt named `malloy-reports/<name>` instead.\n\nAvailable: grid, linear."
360807
361330
  },
360808
361331
  {
360809
361332
  name: "malloy-reports/grid",
@@ -381478,7 +382001,7 @@ var storageServeRoutingCounter = lazyCounter4("publisher_storage_serve_routing_t
381478
382001
  var storageTableRetainedCounter = lazyCounter4("publisher_storage_tables_retained_total", "Tables a FAILED run left in a storage= destination and deliberately did not " + "reclaim, because the source is refreshed incrementally and the name may be " + "the one it serves from. Label: destination. Not all of these are orphans — " + "a rebuild at a fresh generational name is, a seed on the live serving name " + "is not, and the manifest entry cannot separate them — so read this as an " + "upper bound on what is accumulating rather than a leak count. It is the " + "only accounting there is until reclaiming a destination exists, which is " + "why it is a counter and not just a log line: the question is a rate, not " + "whether it ever happened.");
381479
382002
  var storageBuildFailureCounter = lazyCounter4("publisher_storage_build_failures_total", "storage= build failures (federation/passthrough/attach/CTAS), distinct from " + "in-warehouse build failures. Labels: destination (connection name), " + "reason ('build_failed'|'billed_read_not_captured'). The second is the " + "expensive one: the warehouse read ran and was charged, and the rows could " + "not be captured — so a re-drive pays for it again. Worth alerting on " + "separately from a failure that costs only a retry.");
381480
382003
  var attributionSkippedCounter = lazyCounter4("publisher_storage_build_attribution_skipped_total", "storage= builds whose warehouse read went out UNATTRIBUTED while tagging was " + "on. Label: reason ('job_listing_unavailable'|'tag_failed'|" + "'read_row_not_found'|'read_row_ambiguous'|'cost_query_failed'). " + "The read still ran and the " + "build still succeeded — what was lost is the label in the customer's own " + "query history, and the cost on this side. Without this an operator who " + "turns tagging on and sees nothing has a single log line to go on.");
381481
- var eligibilityRefusedCounter = lazyCounter4("publisher_materialization_eligibility_refused_total", "materialization-eligibility refusals, both tiers. Label: reason " + "('free_parameter'|'given_in_persisted_query'|'dynamic_projection'|" + "'dynamic_join'|'dynamic_joined_where'|'partition_without_storage'|" + "'partition_column_unknown'|'partition_column_not_public'|" + "'merge_key_scope_unresolved'|'preaggregate_over_dynamic_source'|" + "'authorize'|'partition'|'not_duckdb_portable'|" + "'public_surface_unknown'). 'given' is retained on the enum for records " + "that carry it and is no longer produced: where a given sits decides the " + "outcome, so one reason covering every placement cannot be raised.");
382004
+ var eligibilityRefusedCounter = lazyCounter4("publisher_materialization_eligibility_refused_total", "materialization-eligibility refusals, both tiers. Label: reason " + "('free_parameter'|'given_in_persisted_query'|'dynamic_projection'|" + "'dynamic_join'|'dynamic_joined_where'|'partition_without_storage'|" + "'partition_column_unknown'|'partition_column_not_public'|" + "'merge_key_scope_unresolved'|'preaggregate_over_dynamic_source'|" + "'authorize'|'partition'|'not_duckdb_portable'|" + "'public_surface_unknown'|'read_only_dialect'). 'given' is retained on the enum for records " + "that carry it and is no longer produced: where a given sits decides the " + "outcome, so one reason covering every placement cannot be raised.");
381482
382005
  var serveShapeTierDropCounter = lazyCounter4("publisher_storage_serve_shape_tier_drop_total", "storage serve-shape compile escalations: a refinement tier failed to " + "compile and the riskiest category was dropped, or one or more lifted " + "entry points failed to compile and were dropped alone. Label: tier (the " + "failed tier index, 0=full; 'lifts' for dropped lifts).");
381483
382006
  var serveShapeTypeFallbackCounter = lazyCounter4("publisher_storage_serve_shape_type_fallback_total", "Captured DuckDB column types mapped to json in the serve shape (type " + "fidelity loss). Label: kind ('array'|'unrecognized').");
381484
382007
  var chainedStorageBuildCounter = lazyCounter4("publisher_storage_chained_build_total", "Chained storage= source builds (a source reading a storage-materialized " + "upstream). Label: outcome ('parent_reuse'|'inline_fallback'|" + "'strict_shape_fallback'|'strict_refused'|'infra_failure'). The " + "parent_reuse share is the headline signal for how far the " + "stack-on-the-parent path gets us vs recompute-from-raw.");
@@ -384511,7 +385034,16 @@ function assertMergeKeyScopeResolvable(persistSource, terms, annotationFields, w
384511
385034
  message: `${what} cannot be materialized: it declares 'merge_key=' and is ` + `scoped by a term this pass cannot express as a predicate over the ` + `stored table's own columns (${unscopable.map((term) => `'${term.code}'`).join(", ")}). An incremental refresh matches rows by the merge ` + `key, and the stored table holds every caller's rows, so the match ` + `must also carry the scoping columns or it would update rows ` + `belonging to other callers. Scope this source with a term over its ` + `own columns, or drop 'merge_key=' to refresh by watermark range.`
384512
385035
  });
384513
385036
  }
385037
+ var READ_ONLY_DIALECTS = new Set(["sqlserver"]);
384514
385038
  function assertColocatedPersistNotAuthorizeGated(persistSource, sourceName = persistSource.name, origin2 = "persist", gateOutcome, annotationFields = {}) {
385039
+ if (!annotationFields.storage && READ_ONLY_DIALECTS.has(persistSource.dialectName)) {
385040
+ recordEligibilityRefused("read_only_dialect");
385041
+ const what2 = origin2 === "preaggregate" ? `Pre-aggregation rollup '${sourceName}'` : `Source '${sourceName}'`;
385042
+ throw new MaterializationEligibilityError({
385043
+ reason: "read_only_dialect",
385044
+ message: `${what2} cannot be materialized (colocated '#@ persist'): its ` + `connection '${persistSource.connectionName}' is on the ` + `'${persistSource.dialectName}' dialect, whose connection is ` + `read-only and creates no tables, so there is nowhere in the ` + `source warehouse to write the result. The source serves live. ` + `Remove the annotation, or persist a source on a connection that ` + `can write.`
385045
+ });
385046
+ }
384515
385047
  const partition = resolvePartitionColumns(persistSource, annotationFields);
384516
385048
  if (!partition.ok) {
384517
385049
  recordEligibilityRefused(partition.reason);
@@ -394266,7 +394798,7 @@ ${query}`);
394266
394798
  }
394267
394799
  }
394268
394800
  // package.json
394269
- var version2 = "0.9.11";
394801
+ var version2 = "0.9.13";
394270
394802
 
394271
394803
  // src/version.ts
394272
394804
  var SERVER_VERSION = version2;
@@ -396237,6 +396769,817 @@ class MaterializationController {
396237
396769
  }
396238
396770
  }
396239
396771
 
396772
+ // src/controller/storage_destination.controller.ts
396773
+ init_errors();
396774
+ init_logger();
396775
+
396776
+ // src/service/materialization_build_session.ts
396777
+ init_errors();
396778
+ init_logger();
396779
+ import { DuckDBConnection as DuckDBConnection4 } from "@malloydata/db-duckdb";
396780
+ import {
396781
+ FixedConnectionMap as FixedConnectionMap3,
396782
+ InMemoryURLReader as InMemoryURLReader3,
396783
+ Runtime as Runtime4
396784
+ } from "@malloydata/malloy";
396785
+ import {
396786
+ existsSync as existsSync3,
396787
+ mkdirSync as mkdirSync3,
396788
+ mkdtempSync as mkdtempSync2,
396789
+ readdirSync as readdirSync3,
396790
+ rmSync
396791
+ } from "node:fs";
396792
+ import os5 from "node:os";
396793
+ import path20 from "node:path";
396794
+
396795
+ // src/service/build_query_tag.ts
396796
+ var MAX_QUERY_TAG_LENGTH = 2000;
396797
+ var BQ_MAX_LEN = 63;
396798
+ function snowflakeQueryTagValue(metadata) {
396799
+ if (metadata === undefined || Object.keys(metadata).length === 0) {
396800
+ return;
396801
+ }
396802
+ const tag = JSON.stringify(metadata);
396803
+ return tag.length > MAX_QUERY_TAG_LENGTH ? undefined : tag;
396804
+ }
396805
+ function snowflakeSetQueryTagSQL(metadata, secretName) {
396806
+ const tag = snowflakeQueryTagValue(metadata);
396807
+ if (tag === undefined)
396808
+ return;
396809
+ const inner = `ALTER SESSION SET QUERY_TAG = '${sqlLiteral(tag, "snowflake")}'`;
396810
+ return `SELECT * FROM snowflake_query('${inner.replace(/'/g, "''")}', '${secretName.replace(/'/g, "''")}')`;
396811
+ }
396812
+ function sanitizeBigQueryValue(value) {
396813
+ return value.toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, BQ_MAX_LEN);
396814
+ }
396815
+ function sanitizeBigQueryKey(key) {
396816
+ const sanitized = sanitizeBigQueryValue(key);
396817
+ return /^[a-z]/.test(sanitized) ? sanitized : undefined;
396818
+ }
396819
+ function bigQueryQueryLabelValue(metadata) {
396820
+ if (metadata === undefined)
396821
+ return;
396822
+ const rendered = new Map;
396823
+ for (const [key, value] of Object.entries(metadata)) {
396824
+ const sanitizedKey = sanitizeBigQueryKey(key);
396825
+ if (sanitizedKey === undefined)
396826
+ continue;
396827
+ rendered.set(sanitizedKey, sanitizeBigQueryValue(value));
396828
+ }
396829
+ if (rendered.size === 0)
396830
+ return;
396831
+ return [...rendered].map(([key, value]) => `${key}:${value}`).join(",");
396832
+ }
396833
+
396834
+ // src/service/build_read_cost.ts
396835
+ var SNOWFLAKE_HISTORY_LIMIT = 1e4;
396836
+ var BIGQUERY_COST_COLUMNS = `
396837
+ total_slot_time_ms,
396838
+ json_extract_string(statistics, '$.query.totalBytesProcessed') AS bytes_processed,
396839
+ json_extract_string(statistics, '$.query.totalBytesBilled') AS bytes_billed,
396840
+ json_extract_string(statistics, '$.query.cacheHit') AS cache_hit`;
396841
+ function bigQueryReadCost(row, jobId, parentJobId) {
396842
+ return {
396843
+ engine: "bigquery",
396844
+ jobId,
396845
+ parentJobId,
396846
+ bytesScanned: num(row.bytes_processed),
396847
+ bytesBilled: num(row.bytes_billed),
396848
+ slotTimeMs: num(row.total_slot_time_ms),
396849
+ executionTimeMs: null,
396850
+ cacheHit: bool(row.cache_hit)
396851
+ };
396852
+ }
396853
+ function snowflakeDatabaseQualifier(database) {
396854
+ return typeof database === "string" && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(database) ? database : "SNOWFLAKE";
396855
+ }
396856
+ function snowflakeCostSQL(queryTag, database) {
396857
+ const qualifier = `${snowflakeDatabaseQualifier(database)}.INFORMATION_SCHEMA`;
396858
+ return `
396859
+ SELECT QUERY_ID AS "job_id",
396860
+ QUERY_TEXT AS "query_text",
396861
+ BYTES_SCANNED AS "bytes_scanned",
396862
+ EXECUTION_TIME AS "execution_time_ms",
396863
+ ROWS_PRODUCED AS "rows_produced"
396864
+ FROM TABLE(${qualifier}.QUERY_HISTORY_BY_SESSION(
396865
+ RESULT_LIMIT => ${SNOWFLAKE_HISTORY_LIMIT}))
396866
+ WHERE QUERY_TAG = '${sqlLiteral(queryTag, "snowflake")}'
396867
+ AND EXECUTION_STATUS = 'SUCCESS'`;
396868
+ }
396869
+ function pickSnowflakeReadRow(rows, buildSQL) {
396870
+ const matches = rows.filter((row) => str(col(row, "query_text"))?.trim() === buildSQL.trim());
396871
+ return matches.length === 1 ? matches[0] : null;
396872
+ }
396873
+ function snowflakeReadCost(row) {
396874
+ const scanned = num(col(row, "bytes_scanned"));
396875
+ const produced = num(col(row, "rows_produced"));
396876
+ return {
396877
+ engine: "snowflake",
396878
+ jobId: str(col(row, "job_id")),
396879
+ parentJobId: null,
396880
+ bytesScanned: scanned,
396881
+ bytesBilled: null,
396882
+ slotTimeMs: null,
396883
+ executionTimeMs: num(col(row, "execution_time_ms")),
396884
+ cacheHit: scanned === null || produced === null ? null : scanned === 0 && produced > 0
396885
+ };
396886
+ }
396887
+ function col(row, key) {
396888
+ if (key in row)
396889
+ return row[key];
396890
+ const upper = key.toUpperCase();
396891
+ if (upper in row)
396892
+ return row[upper];
396893
+ const hit = Object.keys(row).find((k2) => k2.toLowerCase() === key);
396894
+ return hit === undefined ? undefined : row[hit];
396895
+ }
396896
+ function num(value) {
396897
+ if (value === null || value === undefined)
396898
+ return null;
396899
+ const n = Number(value);
396900
+ return Number.isFinite(n) ? n : null;
396901
+ }
396902
+ function str(value) {
396903
+ return typeof value === "string" && value.length > 0 ? value : null;
396904
+ }
396905
+ function bool(value) {
396906
+ if (typeof value === "boolean")
396907
+ return value;
396908
+ if (typeof value === "string") {
396909
+ if (value === "true")
396910
+ return true;
396911
+ if (value === "false")
396912
+ return false;
396913
+ }
396914
+ return null;
396915
+ }
396916
+
396917
+ // src/service/materialization_build_session.ts
396918
+ var sharedGateSession;
396919
+ var PASSTHROUGH_SOURCE_TYPES = [
396920
+ "bigquery",
396921
+ "snowflake",
396922
+ "postgres"
396923
+ ];
396924
+ var STORAGE_DESTINATION_TYPES = ["ducklake", "duckdb"];
396925
+ var STORAGE_TARGET_DIALECT = "duckdb";
396926
+ function wrapPassthrough(sourceType, handle, innerSQL) {
396927
+ const sql = `'${escapeSQL(innerSQL)}'`;
396928
+ const h4 = `'${escapeSQL(handle)}'`;
396929
+ switch (sourceType) {
396930
+ case "bigquery":
396931
+ return `SELECT * FROM bigquery_query(${h4}, ${sql})`;
396932
+ case "postgres":
396933
+ return `SELECT * FROM postgres_query(${h4}, ${sql})`;
396934
+ case "snowflake":
396935
+ return `SELECT * FROM snowflake_query(${sql}, ${h4})`;
396936
+ default: {
396937
+ const exhaustive = sourceType;
396938
+ throw new BadRequestError(`Unsupported passthrough source type: ${String(exhaustive)}`);
396939
+ }
396940
+ }
396941
+ }
396942
+ function orderByPartitionColumns(selectSQL, partitionColumns, dialect) {
396943
+ if (partitionColumns.length === 0) {
396944
+ return selectSQL;
396945
+ }
396946
+ const order = partitionColumns.map((name) => quoteIdentifier(name, dialect)).join(", ");
396947
+ const inner = selectSQL.replace(/[\s;]+$/, "");
396948
+ return `SELECT * FROM (
396949
+ ${inner}
396950
+ ) AS partitioned_build ORDER BY ${order}`;
396951
+ }
396952
+ async function tagSnowflakeSession(session, sourceType, handle, queryMetadata) {
396953
+ if (sourceType !== "snowflake")
396954
+ return;
396955
+ const sql = snowflakeSetQueryTagSQL(queryMetadata, handle);
396956
+ if (sql === undefined)
396957
+ return;
396958
+ try {
396959
+ await session.runSQL(sql);
396960
+ } catch (error2) {
396961
+ logger.warn("Could not tag the Snowflake build session", {
396962
+ error: error2 instanceof Error ? error2.message : String(error2)
396963
+ });
396964
+ recordAttributionSkipped("tag_failed");
396965
+ }
396966
+ }
396967
+ async function canSplitBigQueryRead(session, handle) {
396968
+ try {
396969
+ await session.runSQL(`SELECT job_id FROM bigquery_jobs('${escapeSQL(handle)}', maxResults := 1)`);
396970
+ return true;
396971
+ } catch (error2) {
396972
+ logger.warn("Cannot list BigQuery jobs for this connection, so this build's " + "warehouse read will not be labelled or costed", {
396973
+ error: error2 instanceof Error ? error2.message : String(error2)
396974
+ });
396975
+ recordAttributionSkipped("job_listing_unavailable");
396976
+ return false;
396977
+ }
396978
+ }
396979
+
396980
+ class BilledReadNotCapturedError extends Error {
396981
+ constructor(message, options) {
396982
+ super(message, options);
396983
+ this.name = "BilledReadNotCapturedError";
396984
+ }
396985
+ }
396986
+ var BIGQUERY_LOOKUP_ATTEMPTS = 3;
396987
+ var BIGQUERY_LOOKUP_BACKOFF_MS = 250;
396988
+ async function withBigQueryLookupRetry(work, parentJobId) {
396989
+ let lastError;
396990
+ for (let attempt = 1;attempt <= BIGQUERY_LOOKUP_ATTEMPTS; attempt++) {
396991
+ try {
396992
+ return await work();
396993
+ } catch (error2) {
396994
+ lastError = error2;
396995
+ if (attempt < BIGQUERY_LOOKUP_ATTEMPTS) {
396996
+ logger.warn("Could not read the BigQuery job record for a build's read; retrying", {
396997
+ attempt,
396998
+ of: BIGQUERY_LOOKUP_ATTEMPTS,
396999
+ parentJobId,
397000
+ error: error2 instanceof Error ? error2.message : String(error2)
397001
+ });
397002
+ await new Promise((resolve7) => setTimeout(resolve7, BIGQUERY_LOOKUP_BACKOFF_MS * attempt));
397003
+ }
397004
+ }
397005
+ }
397006
+ throw new BilledReadNotCapturedError(`Could not read the BigQuery job record for a build's read (parent job ` + `${parentJobId}) after ${BIGQUERY_LOOKUP_ATTEMPTS} attempts, so its ` + `rows cannot be captured: ` + `${lastError instanceof Error ? lastError.message : String(lastError)}`, { cause: lastError });
397007
+ }
397008
+ async function issuePassthroughRead(session, sourceType, handle, buildSQL, queryMetadata) {
397009
+ const label = sourceType === "bigquery" ? bigQueryQueryLabelValue(queryMetadata) : undefined;
397010
+ if (label === undefined || !await canSplitBigQueryRead(session, handle)) {
397011
+ return {
397012
+ selectSQL: wrapPassthrough(sourceType, handle, buildSQL),
397013
+ jobId: null,
397014
+ cost: null
397015
+ };
397016
+ }
397017
+ const script = `SET @@query_label = "${label}";
397018
+ ${buildSQL};`;
397019
+ const executed = await session.runSQL(`SELECT * FROM bigquery_execute('${escapeSQL(handle)}', '${escapeSQL(script)}')`);
397020
+ const parentJobId = firstColumn(executed, "job_id");
397021
+ if (parentJobId === null) {
397022
+ throw new BilledReadNotCapturedError("bigquery_execute returned no job_id for the build's read, so its " + "result table cannot be located");
397023
+ }
397024
+ const located = await withBigQueryLookupRetry(async () => {
397025
+ const child = await session.runSQL(`
397026
+ SELECT job_id,
397027
+ json_extract_string(configuration, '$.query.destinationTable.projectId') AS project,
397028
+ json_extract_string(configuration, '$.query.destinationTable.datasetId') AS dataset,
397029
+ json_extract_string(configuration, '$.query.destinationTable.tableId') AS table,
397030
+ ${BIGQUERY_COST_COLUMNS}
397031
+ FROM (SELECT * FROM bigquery_jobs('${escapeSQL(handle)}',
397032
+ parentJobId := '${escapeSQL(parentJobId)}')
397033
+ WHERE job_type = 'QUERY')
397034
+ ORDER BY creation_time DESC`);
397035
+ const row = resultRows(child).find((r3) => str2(r3.dataset) && str2(r3.table));
397036
+ if (row === undefined) {
397037
+ throw new Error(`no child job with a result table under parent ${parentJobId}`);
397038
+ }
397039
+ return row;
397040
+ }, parentJobId);
397041
+ const path21 = [
397042
+ str2(located.project) ?? handle,
397043
+ str2(located.dataset),
397044
+ str2(located.table)
397045
+ ].join(".");
397046
+ const jobId = str2(located.job_id);
397047
+ return {
397048
+ selectSQL: `SELECT * FROM bigquery_scan('${escapeSQL(path21)}')`,
397049
+ jobId,
397050
+ cost: bigQueryReadCost(located, jobId, parentJobId)
397051
+ };
397052
+ }
397053
+ async function snowflakeReadCostAfterBuild(session, sourceType, handle, buildSQL, queryMetadata, database) {
397054
+ if (sourceType !== "snowflake")
397055
+ return null;
397056
+ const tag = snowflakeQueryTagValue(queryMetadata);
397057
+ if (tag === undefined)
397058
+ return null;
397059
+ try {
397060
+ const costResult = await session.runSQL(passthroughSnowflake(snowflakeCostSQL(tag, database), handle));
397061
+ const candidates = resultRows(costResult);
397062
+ const row = pickSnowflakeReadRow(candidates, buildSQL);
397063
+ if (row === null) {
397064
+ recordAttributionSkipped(candidates.length === 0 ? "read_row_not_found" : "read_row_ambiguous");
397065
+ return null;
397066
+ }
397067
+ return snowflakeReadCost(row);
397068
+ } catch (error2) {
397069
+ logger.warn("Could not read back what the Snowflake build read cost", {
397070
+ error: error2 instanceof Error ? error2.message : String(error2)
397071
+ });
397072
+ recordAttributionSkipped("cost_query_failed");
397073
+ return null;
397074
+ }
397075
+ }
397076
+ function passthroughSnowflake(innerSQL, handle) {
397077
+ return `SELECT * FROM snowflake_query('${escapeSQL(innerSQL)}', '${escapeSQL(handle)}')`;
397078
+ }
397079
+ function resultRows(result) {
397080
+ if (Array.isArray(result))
397081
+ return result;
397082
+ const rows = result?.rows;
397083
+ return Array.isArray(rows) ? rows : [];
397084
+ }
397085
+ function firstColumn(result, column) {
397086
+ const rows = resultRows(result);
397087
+ return rows.length > 0 ? str2(rows[0][column]) : null;
397088
+ }
397089
+ function str2(value) {
397090
+ return typeof value === "string" && value.length > 0 ? value : null;
397091
+ }
397092
+ async function clearSnowflakeSessionTag(session, sourceType, handle) {
397093
+ if (sourceType !== "snowflake" || handle === undefined)
397094
+ return;
397095
+ try {
397096
+ await session.runSQL(passthroughSnowflake("ALTER SESSION UNSET QUERY_TAG", handle));
397097
+ } catch (error2) {
397098
+ logger.warn("Could not clear the Snowflake build session's query tag", {
397099
+ error: error2 instanceof Error ? error2.message : String(error2)
397100
+ });
397101
+ }
397102
+ }
397103
+ function passthroughSourceType(sourceConnection) {
397104
+ const type = sourceConnection.type;
397105
+ if (PASSTHROUGH_SOURCE_TYPES.includes(type ?? "")) {
397106
+ return type;
397107
+ }
397108
+ throw new BadRequestError(`Cannot materialize a '${type}' source into a storage destination: the ` + `native query-passthrough build supports source connections of type ` + `${PASSTHROUGH_SOURCE_TYPES.join(", ")} only.`);
397109
+ }
397110
+ function createIsolatedBuildSession(sessionName) {
397111
+ const workDir = mkdtempSync2(path20.join(os5.tmpdir(), "malloy-build-"));
397112
+ let session;
397113
+ try {
397114
+ session = new DuckDBConnection4(sessionName, ":memory:", workDir, {
397115
+ rowLimit: Number.MAX_SAFE_INTEGER
397116
+ });
397117
+ } catch (err) {
397118
+ rmSync(workDir, { recursive: true, force: true });
397119
+ throw err;
397120
+ }
397121
+ const dispose = async () => {
397122
+ const nodeConnection = session.connection;
397123
+ try {
397124
+ await session.close();
397125
+ } catch (err) {
397126
+ logger.warn("Failed to close build session (leaked session)", {
397127
+ sessionName,
397128
+ error: err instanceof Error ? err.message : String(err)
397129
+ });
397130
+ }
397131
+ try {
397132
+ nodeConnection?.disconnectSync?.();
397133
+ } catch (err) {
397134
+ logger.warn("Failed to disconnect build session (leaked connection)", {
397135
+ sessionName,
397136
+ error: err instanceof Error ? err.message : String(err)
397137
+ });
397138
+ }
397139
+ try {
397140
+ rmSync(workDir, { recursive: true, force: true });
397141
+ } catch (err) {
397142
+ logger.warn("Failed to remove build session working directory", {
397143
+ sessionName,
397144
+ workDir,
397145
+ error: err instanceof Error ? err.message : String(err)
397146
+ });
397147
+ }
397148
+ };
397149
+ return { session, dispose, workDir };
397150
+ }
397151
+ async function pinSessionToUTC(session) {
397152
+ await session.runSQL("SET TimeZone='UTC'");
397153
+ }
397154
+ async function buildSourceIntoStorage(params) {
397155
+ const {
397156
+ destinationName,
397157
+ destinationConnection,
397158
+ sourceConnection,
397159
+ buildSQL,
397160
+ physicalTableName,
397161
+ environmentPath,
397162
+ queryMetadata
397163
+ } = params;
397164
+ const federate = params.deps?.federate ?? federateSourceForPassthrough;
397165
+ const read = params.deps?.read ?? issuePassthroughRead;
397166
+ assertSupportedDestination(destinationName, destinationConnection);
397167
+ const sourceType = passthroughSourceType(sourceConnection);
397168
+ const { session, dispose, workDir } = createIsolatedBuildSession(`build_${destinationName}`);
397169
+ let federatedHandle;
397170
+ let federatedClose;
397171
+ try {
397172
+ await applySessionResourceLimits(session, { tempDirectory: workDir });
397173
+ await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
397174
+ if (destinationConnection.type === "ducklake") {
397175
+ await session.runSQL("SET ducklake_default_data_inlining_row_limit=0");
397176
+ }
397177
+ await pinSessionToUTC(session);
397178
+ const federated = await federate(session, sourceType, sourceFederationConfig(sourceConnection));
397179
+ federatedClose = federated.close;
397180
+ await tagSnowflakeSession(session, sourceType, federated.handle, queryMetadata);
397181
+ const target = quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT);
397182
+ if (params.incremental) {
397183
+ const partitioned = (params.partitionColumns?.length ?? 0) > 0;
397184
+ const flushThreshold = getPartitionedWriteFlushThreshold();
397185
+ const boundDelta = partitioned && flushThreshold !== undefined;
397186
+ if (boundDelta) {
397187
+ await session.runSQL(`SET partitioned_write_flush_threshold = ${flushThreshold}`);
397188
+ await session.runSQL("SET threads = 1");
397189
+ }
397190
+ let refreshed;
397191
+ try {
397192
+ refreshed = await params.incremental.plan({
397193
+ session,
397194
+ sourceType,
397195
+ handle: federated.handle,
397196
+ quotedTablePath: target
397197
+ });
397198
+ } finally {
397199
+ if (boundDelta) {
397200
+ try {
397201
+ await session.runSQL("RESET threads");
397202
+ await session.runSQL("RESET partitioned_write_flush_threshold");
397203
+ } catch {}
397204
+ }
397205
+ }
397206
+ if (refreshed) {
397207
+ return {
397208
+ storageDestinationName: destinationName,
397209
+ schema: await describeTable(session, target),
397210
+ readCost: refreshed.readCost,
397211
+ refresh: refreshed
397212
+ };
397213
+ }
397214
+ }
397215
+ const passthrough = await read(session, sourceType, federated.handle, buildSQL, queryMetadata);
397216
+ const schema = await createTableAndDescribe(session, target, passthrough.selectSQL, params.partitionColumns ?? [], { sourceType });
397217
+ const seededThrough = await params.incremental?.afterSeed({
397218
+ session,
397219
+ quotedTablePath: target
397220
+ });
397221
+ return {
397222
+ seededThrough,
397223
+ storageDestinationName: destinationName,
397224
+ schema,
397225
+ readCost: passthrough.cost ?? await snowflakeReadCostAfterBuild(session, sourceType, federated.handle, buildSQL, queryMetadata, sourceConnection.snowflakeConnection?.database)
397226
+ };
397227
+ } finally {
397228
+ await clearSnowflakeSessionTag(session, sourceType, federatedHandle);
397229
+ await dispose();
397230
+ if (federatedClose) {
397231
+ await federatedClose().catch((e2) => logger.warn(`Failed to close the SSH proxy a storage build federated through: ${String(e2)}`));
397232
+ }
397233
+ }
397234
+ }
397235
+ async function buildDownstreamIntoStorage(params) {
397236
+ const {
397237
+ destinationName,
397238
+ destinationConnection,
397239
+ transientModel,
397240
+ downstreamName,
397241
+ virtualMap,
397242
+ physicalTableName,
397243
+ environmentPath
397244
+ } = params;
397245
+ assertSupportedDestination(destinationName, destinationConnection);
397246
+ const { session, dispose, workDir } = createIsolatedBuildSession(`build_${destinationName}`);
397247
+ try {
397248
+ await applySessionResourceLimits(session, { tempDirectory: workDir });
397249
+ await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
397250
+ if (destinationConnection.type === "ducklake") {
397251
+ await session.runSQL("SET ducklake_default_data_inlining_row_limit=0");
397252
+ }
397253
+ await pinSessionToUTC(session);
397254
+ const root = "file:///chained-build/";
397255
+ const url3 = `${root}m.malloy`;
397256
+ const runtime = new Runtime4({
397257
+ urlReader: new InMemoryURLReader3(new Map([[url3, transientModel]])),
397258
+ connections: new FixedConnectionMap3(new Map([[destinationName, session]]), destinationName)
397259
+ });
397260
+ let model;
397261
+ try {
397262
+ model = await runtime.loadModel(new URL(url3), { importBaseURL: new URL(root) }).getModel();
397263
+ } catch (err) {
397264
+ throw new MaterializationEligibilityError({
397265
+ message: `Chained build model did not compile over the rebound parents: ${errMessage(err)}`
397266
+ });
397267
+ }
397268
+ const plan = model.getBuildPlan();
397269
+ let downstream;
397270
+ for (const ps2 of Object.values(plan.sources)) {
397271
+ if (ps2.name === downstreamName) {
397272
+ downstream = ps2;
397273
+ break;
397274
+ }
397275
+ }
397276
+ if (!downstream) {
397277
+ throw new MaterializationEligibilityError({
397278
+ message: `Chained build model did not yield a persist source named ` + `'${downstreamName}' (the downstream references something the ` + `rebound parents don't provide).`
397279
+ });
397280
+ }
397281
+ const sql = projectToPublicColumns(downstream, downstream.getSQL({ virtualMap }));
397282
+ const target = quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT);
397283
+ const schema = await createTableAndDescribe(session, target, sql, params.partitionColumns ?? []);
397284
+ return {
397285
+ storageDestinationName: destinationName,
397286
+ schema,
397287
+ readCost: null,
397288
+ upstreamReuse: "reused"
397289
+ };
397290
+ } finally {
397291
+ await dispose();
397292
+ }
397293
+ }
397294
+ async function assertStorageServeShapeCompiles(params) {
397295
+ const { destinationName, sourceName, virtualHandle, physicalTableName } = params;
397296
+ const binding = {
397297
+ sourceName,
397298
+ destinationName,
397299
+ virtualHandle,
397300
+ tablePath: `${destinationName}.${physicalTableName}`,
397301
+ schema: params.schema.filter((c3) => c3.name && c3.type).map((c3) => ({ name: c3.name, type: c3.type }))
397302
+ };
397303
+ sharedGateSession ??= createIsolatedBuildSession("gate_shared").session;
397304
+ await applySessionResourceLimits(sharedGateSession);
397305
+ await assertServesInDuckDB(sourceName, binding, new FixedConnectionMap3(new Map([[destinationName, sharedGateSession]]), destinationName));
397306
+ }
397307
+ function dropStorageTableSql(destinationName, physicalTableName) {
397308
+ return `DROP TABLE IF EXISTS ${quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT)}`;
397309
+ }
397310
+ async function dropStorageTable(params) {
397311
+ const {
397312
+ destinationName,
397313
+ destinationConnection,
397314
+ physicalTableName,
397315
+ environmentPath
397316
+ } = params;
397317
+ assertSupportedDestination(destinationName, destinationConnection);
397318
+ const { session, dispose, workDir } = createIsolatedBuildSession(`gc_${destinationName}`);
397319
+ try {
397320
+ await applySessionResourceLimits(session, { tempDirectory: workDir });
397321
+ await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
397322
+ await session.runSQL(dropStorageTableSql(destinationName, physicalTableName));
397323
+ } finally {
397324
+ await dispose();
397325
+ }
397326
+ }
397327
+ async function listStorageTables(params) {
397328
+ const {
397329
+ destinationName,
397330
+ destinationConnection,
397331
+ schemaName,
397332
+ environmentPath
397333
+ } = params;
397334
+ assertSupportedDestination(destinationName, destinationConnection);
397335
+ const { session, dispose, workDir } = createIsolatedBuildSession(`list_${destinationName}`);
397336
+ try {
397337
+ await applySessionResourceLimits(session, { tempDirectory: workDir });
397338
+ if (destinationConnection.type === "ducklake") {
397339
+ const cfg = requireDuckLakeConfig(destinationName, destinationConnection);
397340
+ await attachDuckLakeReadOnly(session, destinationName, cfg);
397341
+ } else {
397342
+ const destinationRoot = storageDestinationRoot(environmentPath);
397343
+ const fileName = `${destinationName}.duckdb`;
397344
+ if (!existsSync3(destinationRoot) || !readdirSync3(destinationRoot).includes(fileName)) {
397345
+ return [];
397346
+ }
397347
+ const dbPath = path20.join(destinationRoot, fileName);
397348
+ await session.runSQL(`ATTACH '${escapeSQL(dbPath)}' AS ${quoteIdentifier(destinationName, STORAGE_TARGET_DIALECT)} (READ_ONLY)`);
397349
+ }
397350
+ const rows = resultRows(await session.runSQL(`SELECT table_name FROM duckdb_tables() ` + `WHERE database_name = '${escapeSQL(destinationName)}' ` + `AND schema_name = '${escapeSQL(schemaName)}' ` + `ORDER BY table_name`));
397351
+ return rows.map((row) => String(row.table_name));
397352
+ } finally {
397353
+ await dispose();
397354
+ }
397355
+ }
397356
+ async function cleanupStorageFiles(params) {
397357
+ const {
397358
+ destinationName,
397359
+ destinationConnection,
397360
+ snapshotsOlderThan,
397361
+ filesOlderThan
397362
+ } = params;
397363
+ if (destinationConnection.type !== "ducklake") {
397364
+ throw new BadRequestError(`Storage destination '${destinationName}' is type ` + `'${destinationConnection.type ?? ""}'; file cleanup applies only to ` + `'ducklake' destinations.`);
397365
+ }
397366
+ const cfg = requireDuckLakeConfig(destinationName, destinationConnection);
397367
+ const { session, dispose, workDir } = createIsolatedBuildSession(`cleanup_${destinationName}`);
397368
+ try {
397369
+ await applySessionResourceLimits(session, { tempDirectory: workDir });
397370
+ await attachDuckLakeReadWrite(session, destinationName, cfg);
397371
+ const catalog = escapeSQL(destinationName);
397372
+ const expired = resultRows(await session.runSQL(`CALL ducklake_expire_snapshots('${catalog}', older_than => ` + `TIMESTAMPTZ '${snapshotsOlderThan.toISOString()}')`));
397373
+ const deleted = resultRows(await session.runSQL(`CALL ducklake_cleanup_old_files('${catalog}', older_than => ` + `TIMESTAMPTZ '${filesOlderThan.toISOString()}')`));
397374
+ return { expiredSnapshots: expired.length, deletedFiles: deleted.length };
397375
+ } finally {
397376
+ await dispose();
397377
+ }
397378
+ }
397379
+ function requireDuckLakeConfig(destinationName, destinationConnection) {
397380
+ const cfg = destinationConnection.ducklakeConnection;
397381
+ if (!cfg) {
397382
+ throw new BadRequestError(`Storage destination '${destinationName}' is type 'ducklake' but ` + `has no ducklakeConnection config.`);
397383
+ }
397384
+ return cfg;
397385
+ }
397386
+ function destinationDatabasePath(environmentPath, destinationName) {
397387
+ return path20.join(storageDestinationRoot(environmentPath), `${destinationName}.duckdb`);
397388
+ }
397389
+ async function attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath) {
397390
+ if (destinationConnection.type === "ducklake") {
397391
+ const cfg = requireDuckLakeConfig(destinationName, destinationConnection);
397392
+ await attachDuckLakeReadWrite(session, destinationName, cfg);
397393
+ return;
397394
+ }
397395
+ mkdirSync3(storageDestinationRoot(environmentPath), { recursive: true });
397396
+ const dbPath = destinationDatabasePath(environmentPath, destinationName);
397397
+ await session.runSQL(`ATTACH '${escapeSQL(dbPath)}' AS ${quoteIdentifier(destinationName, STORAGE_TARGET_DIALECT)}`);
397398
+ }
397399
+ function assertSupportedDestination(destinationName, destinationConnection) {
397400
+ const type = destinationConnection.type ?? "";
397401
+ if (!STORAGE_DESTINATION_TYPES.includes(type)) {
397402
+ throw new BadRequestError(`Storage destination '${destinationName}' is type '${type}', but ` + `'storage=' destinations must be one of ` + `${STORAGE_DESTINATION_TYPES.join(", ")}.`);
397403
+ }
397404
+ }
397405
+ function sourceFederationConfig(sourceConnection) {
397406
+ return {
397407
+ name: sourceConnection.name ?? "src",
397408
+ bigqueryConnection: sourceConnection.bigqueryConnection,
397409
+ snowflakeConnection: sourceConnection.snowflakeConnection,
397410
+ postgresConnection: sourceConnection.postgresConnection,
397411
+ proxy: sourceConnection.proxy
397412
+ };
397413
+ }
397414
+ async function createTableAndDescribe(session, quotedTablePath, selectSQL, partitionColumns = [], options = {}) {
397415
+ if (partitionColumns.length === 0) {
397416
+ await session.runSQL(`CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL})`);
397417
+ return await describeOrDrop(session, quotedTablePath);
397418
+ }
397419
+ const columns = partitionColumns.map((name) => quoteIdentifier(name, STORAGE_TARGET_DIALECT)).join(", ");
397420
+ const flushThreshold = getPartitionedWriteFlushThreshold();
397421
+ const bounded = options.sourceType !== undefined && flushThreshold !== undefined;
397422
+ if (bounded) {
397423
+ await session.runSQL(`SET partitioned_write_flush_threshold = ${flushThreshold}`);
397424
+ await session.runSQL("SET threads = 1");
397425
+ }
397426
+ let schema;
397427
+ await session.runSQL("BEGIN TRANSACTION");
397428
+ try {
397429
+ await session.runSQL(`CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL}) WITH NO DATA`);
397430
+ await session.runSQL(`ALTER TABLE ${quotedTablePath} SET PARTITIONED BY (${columns})`);
397431
+ const inserted = bounded ? orderByPartitionColumns(selectSQL, partitionColumns, STORAGE_TARGET_DIALECT) : selectSQL;
397432
+ await session.runSQL(`INSERT INTO ${quotedTablePath} (${inserted})`);
397433
+ schema = await describeTable(session, quotedTablePath);
397434
+ await session.runSQL("COMMIT");
397435
+ } catch (buildErr) {
397436
+ try {
397437
+ await session.runSQL("ROLLBACK");
397438
+ } catch (rollbackErr) {
397439
+ logger.warn("Failed to roll back a partitioned storage build; the served " + "generation may have been replaced", {
397440
+ table: quotedTablePath,
397441
+ error: errMessage(rollbackErr),
397442
+ cause: errMessage(buildErr)
397443
+ });
397444
+ }
397445
+ throw buildErr;
397446
+ } finally {
397447
+ if (bounded) {
397448
+ try {
397449
+ await session.runSQL("RESET threads");
397450
+ } catch {}
397451
+ }
397452
+ }
397453
+ return schema;
397454
+ }
397455
+ async function describeOrDrop(session, quotedTablePath) {
397456
+ try {
397457
+ return await describeTable(session, quotedTablePath);
397458
+ } catch (describeErr) {
397459
+ await dropStranded(session, quotedTablePath, describeErr);
397460
+ throw describeErr;
397461
+ }
397462
+ }
397463
+ async function dropStranded(session, quotedTablePath, cause) {
397464
+ try {
397465
+ await session.runSQL(`DROP TABLE IF EXISTS ${quotedTablePath}`);
397466
+ } catch (dropErr) {
397467
+ logger.warn("Failed to drop a storage table stranded by a failed build " + "(physical leak)", {
397468
+ table: quotedTablePath,
397469
+ error: errMessage(dropErr),
397470
+ cause: errMessage(cause)
397471
+ });
397472
+ }
397473
+ }
397474
+ async function describeTable(session, quotedTablePath) {
397475
+ const result = await session.runSQL(`DESCRIBE ${quotedTablePath}`);
397476
+ const rows = Array.isArray(result) ? result : result.rows ?? [];
397477
+ const columns = [];
397478
+ for (const row of rows) {
397479
+ const name = row.column_name;
397480
+ const type = row.column_type;
397481
+ if (typeof name === "string" && typeof type === "string") {
397482
+ columns.push({ name, type });
397483
+ }
397484
+ }
397485
+ return columns;
397486
+ }
397487
+
397488
+ // src/controller/storage_destination.controller.ts
397489
+ var IDENTIFIER = /^[a-zA-Z0-9_-]+$/;
397490
+
397491
+ class StorageDestinationController {
397492
+ environmentStore;
397493
+ constructor(environmentStore) {
397494
+ this.environmentStore = environmentStore;
397495
+ }
397496
+ async listTables(environmentName, destinationName, schemaName) {
397497
+ assertIdentifier("schema", schemaName);
397498
+ const { environment, destination } = await this.resolve(environmentName, destinationName);
397499
+ const names = await listStorageTables({
397500
+ destinationName,
397501
+ destinationConnection: destination,
397502
+ schemaName,
397503
+ environmentPath: environment.getEnvironmentPath()
397504
+ });
397505
+ return names.map((name) => ({ name }));
397506
+ }
397507
+ async dropTable(environmentName, destinationName, schemaName, tableName) {
397508
+ assertIdentifier("schema", schemaName);
397509
+ assertIdentifier("table", tableName);
397510
+ const { environment, destination } = await this.resolve(environmentName, destinationName);
397511
+ try {
397512
+ await dropStorageTable({
397513
+ destinationName,
397514
+ destinationConnection: destination,
397515
+ physicalTableName: schemaName === "main" ? tableName : `${schemaName}.${tableName}`,
397516
+ environmentPath: environment.getEnvironmentPath()
397517
+ });
397518
+ } catch (error2) {
397519
+ recordDropTables("failure", "storage");
397520
+ throw error2;
397521
+ }
397522
+ recordDropTables("success", "storage");
397523
+ logger.info("Dropped a storage destination table", {
397524
+ environmentName,
397525
+ destinationName,
397526
+ schemaName,
397527
+ tableName
397528
+ });
397529
+ }
397530
+ async createFileCleanup(environmentName, destinationName, body) {
397531
+ const snapshotsOlderThan = requireTimestamp(body, "snapshotsOlderThan");
397532
+ const filesOlderThan = requireTimestamp(body, "filesOlderThan");
397533
+ const { destination } = await this.resolve(environmentName, destinationName);
397534
+ const result = await cleanupStorageFiles({
397535
+ destinationName,
397536
+ destinationConnection: destination,
397537
+ snapshotsOlderThan,
397538
+ filesOlderThan
397539
+ });
397540
+ logger.info("Cleaned up a storage destination's retired files", {
397541
+ environmentName,
397542
+ destinationName,
397543
+ snapshotsOlderThan: snapshotsOlderThan.toISOString(),
397544
+ filesOlderThan: filesOlderThan.toISOString(),
397545
+ ...result
397546
+ });
397547
+ return {
397548
+ snapshotsOlderThan: snapshotsOlderThan.toISOString(),
397549
+ filesOlderThan: filesOlderThan.toISOString(),
397550
+ ...result
397551
+ };
397552
+ }
397553
+ async resolve(environmentName, destinationName) {
397554
+ assertIdentifier("storage destination", destinationName);
397555
+ const environment = await this.environmentStore.getEnvironment(environmentName, false);
397556
+ try {
397557
+ return {
397558
+ environment,
397559
+ destination: environment.getStorageDestination(destinationName)
397560
+ };
397561
+ } catch (error2) {
397562
+ if (error2 instanceof DestinationNotFoundError) {
397563
+ throw new StorageDestinationNotFoundError(`Storage destination '${destinationName}' is not configured on ` + `environment '${environmentName}'.`);
397564
+ }
397565
+ throw error2;
397566
+ }
397567
+ }
397568
+ }
397569
+ function assertIdentifier(what, value) {
397570
+ if (!IDENTIFIER.test(value)) {
397571
+ throw new BadRequestError(`Invalid ${what} name '${value}': expected letters, digits, '_' or '-'.`);
397572
+ }
397573
+ }
397574
+ function requireTimestamp(body, field) {
397575
+ const raw = body?.[field];
397576
+ const parsed = typeof raw === "string" ? new Date(raw) : undefined;
397577
+ if (!parsed || Number.isNaN(parsed.getTime())) {
397578
+ throw new BadRequestError(`'${field}' is required and must be an RFC 3339 date-time.`);
397579
+ }
397580
+ return parsed;
397581
+ }
397582
+
396240
397583
  // src/controller/theme.controller.ts
396241
397584
  init_errors();
396242
397585
  class ThemeController {
@@ -396396,11 +397739,11 @@ function normalizeObjectSchema(schema) {
396396
397739
  }
396397
397740
  return;
396398
397741
  }
396399
- function getDotPath(path20) {
396400
- if (path20.length === 0) {
397742
+ function getDotPath(path21) {
397743
+ if (path21.length === 0) {
396401
397744
  return "object root";
396402
397745
  }
396403
- return path20.reduce((acc, seg, index) => {
397746
+ return path21.reduce((acc, seg, index) => {
396404
397747
  if (index === 0) {
396405
397748
  return String(seg);
396406
397749
  }
@@ -400837,7 +402180,7 @@ function registerSearchDatabaseSchemaTool(mcpServer, environmentStore) {
400837
402180
  if (n === 0)
400838
402181
  return [];
400839
402182
  return [
400840
- `${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.`
402183
+ `${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, Trino and SQL Server; backticks on MySQL, BigQuery and Databricks.`
400841
402184
  ];
400842
402185
  };
400843
402186
  const assertCanAdmit = async (environmentName) => {
@@ -401201,8 +402544,8 @@ function initializeMcpServer(environmentStore) {
401201
402544
 
401202
402545
  // src/mcp_config.ts
401203
402546
  import * as fs17 from "fs";
401204
- import * as os5 from "os";
401205
- import * as path20 from "path";
402547
+ import * as os6 from "os";
402548
+ import * as path21 from "path";
401206
402549
  init_logger();
401207
402550
  var MCP_CONFIG_FILENAME = ".mcp.json";
401208
402551
  function malloyServer(endpoint) {
@@ -401227,11 +402570,11 @@ function mcpEndpoint(host, port) {
401227
402570
  return `http://${host}:${port}/mcp`;
401228
402571
  }
401229
402572
  function findGitWorkTreeRoot(dir) {
401230
- let current = path20.resolve(dir);
402573
+ let current = path21.resolve(dir);
401231
402574
  for (;; ) {
401232
- if (fs17.existsSync(path20.join(current, ".git")))
402575
+ if (fs17.existsSync(path21.join(current, ".git")))
401233
402576
  return current;
401234
- const parent = path20.dirname(current);
402577
+ const parent = path21.dirname(current);
401235
402578
  if (parent === current)
401236
402579
  return;
401237
402580
  current = parent;
@@ -401254,7 +402597,7 @@ function mcpConfigEnabled() {
401254
402597
  }
401255
402598
  function ensureMcpConfig(options) {
401256
402599
  const { dir, endpoint, requestedPort, boundPort, homeDir } = options;
401257
- const file = path20.join(dir, MCP_CONFIG_FILENAME);
402600
+ const file = path21.join(dir, MCP_CONFIG_FILENAME);
401258
402601
  try {
401259
402602
  const existing = (() => {
401260
402603
  try {
@@ -401281,18 +402624,18 @@ function ensureMcpConfig(options) {
401281
402624
  try {
401282
402625
  return fs17.realpathSync(p2);
401283
402626
  } catch {
401284
- return path20.resolve(p2);
402627
+ return path21.resolve(p2);
401285
402628
  }
401286
402629
  };
401287
- if (realish(dir) === realish(homeDir ?? os5.homedir())) {
402630
+ if (realish(dir) === realish(homeDir ?? os6.homedir())) {
401288
402631
  return { action: "skipped-home", dir, endpoint, staleConfig };
401289
402632
  }
401290
- if (path20.resolve(dir) === path20.parse(path20.resolve(dir)).root) {
402633
+ if (path21.resolve(dir) === path21.parse(path21.resolve(dir)).root) {
401291
402634
  return { action: "skipped-root", dir, endpoint, staleConfig };
401292
402635
  }
401293
402636
  const gitRoot = findGitWorkTreeRoot(dir);
401294
402637
  if (gitRoot !== undefined) {
401295
- const rootCandidate = path20.join(gitRoot, MCP_CONFIG_FILENAME);
402638
+ const rootCandidate = path21.join(gitRoot, MCP_CONFIG_FILENAME);
401296
402639
  return {
401297
402640
  action: "skipped-git",
401298
402641
  dir,
@@ -402384,654 +403727,6 @@ function reportDeltaApplied(params) {
402384
403727
  logger.info("Applied an incremental delta", params);
402385
403728
  }
402386
403729
 
402387
- // src/service/materialization_build_session.ts
402388
- init_errors();
402389
- init_logger();
402390
- import { DuckDBConnection as DuckDBConnection4 } from "@malloydata/db-duckdb";
402391
- import {
402392
- FixedConnectionMap as FixedConnectionMap3,
402393
- InMemoryURLReader as InMemoryURLReader3,
402394
- Runtime as Runtime4
402395
- } from "@malloydata/malloy";
402396
- import { mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, rmSync } from "node:fs";
402397
- import os6 from "node:os";
402398
- import path21 from "node:path";
402399
-
402400
- // src/service/build_query_tag.ts
402401
- var MAX_QUERY_TAG_LENGTH = 2000;
402402
- var BQ_MAX_LEN = 63;
402403
- function snowflakeQueryTagValue(metadata) {
402404
- if (metadata === undefined || Object.keys(metadata).length === 0) {
402405
- return;
402406
- }
402407
- const tag = JSON.stringify(metadata);
402408
- return tag.length > MAX_QUERY_TAG_LENGTH ? undefined : tag;
402409
- }
402410
- function snowflakeSetQueryTagSQL(metadata, secretName) {
402411
- const tag = snowflakeQueryTagValue(metadata);
402412
- if (tag === undefined)
402413
- return;
402414
- const inner = `ALTER SESSION SET QUERY_TAG = '${sqlLiteral(tag, "snowflake")}'`;
402415
- return `SELECT * FROM snowflake_query('${inner.replace(/'/g, "''")}', '${secretName.replace(/'/g, "''")}')`;
402416
- }
402417
- function sanitizeBigQueryValue(value) {
402418
- return value.toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, BQ_MAX_LEN);
402419
- }
402420
- function sanitizeBigQueryKey(key) {
402421
- const sanitized = sanitizeBigQueryValue(key);
402422
- return /^[a-z]/.test(sanitized) ? sanitized : undefined;
402423
- }
402424
- function bigQueryQueryLabelValue(metadata) {
402425
- if (metadata === undefined)
402426
- return;
402427
- const rendered = new Map;
402428
- for (const [key, value] of Object.entries(metadata)) {
402429
- const sanitizedKey = sanitizeBigQueryKey(key);
402430
- if (sanitizedKey === undefined)
402431
- continue;
402432
- rendered.set(sanitizedKey, sanitizeBigQueryValue(value));
402433
- }
402434
- if (rendered.size === 0)
402435
- return;
402436
- return [...rendered].map(([key, value]) => `${key}:${value}`).join(",");
402437
- }
402438
-
402439
- // src/service/build_read_cost.ts
402440
- var SNOWFLAKE_HISTORY_LIMIT = 1e4;
402441
- var BIGQUERY_COST_COLUMNS = `
402442
- total_slot_time_ms,
402443
- json_extract_string(statistics, '$.query.totalBytesProcessed') AS bytes_processed,
402444
- json_extract_string(statistics, '$.query.totalBytesBilled') AS bytes_billed,
402445
- json_extract_string(statistics, '$.query.cacheHit') AS cache_hit`;
402446
- function bigQueryReadCost(row, jobId, parentJobId) {
402447
- return {
402448
- engine: "bigquery",
402449
- jobId,
402450
- parentJobId,
402451
- bytesScanned: num(row.bytes_processed),
402452
- bytesBilled: num(row.bytes_billed),
402453
- slotTimeMs: num(row.total_slot_time_ms),
402454
- executionTimeMs: null,
402455
- cacheHit: bool(row.cache_hit)
402456
- };
402457
- }
402458
- function snowflakeDatabaseQualifier(database) {
402459
- return typeof database === "string" && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(database) ? database : "SNOWFLAKE";
402460
- }
402461
- function snowflakeCostSQL(queryTag, database) {
402462
- const qualifier = `${snowflakeDatabaseQualifier(database)}.INFORMATION_SCHEMA`;
402463
- return `
402464
- SELECT QUERY_ID AS "job_id",
402465
- QUERY_TEXT AS "query_text",
402466
- BYTES_SCANNED AS "bytes_scanned",
402467
- EXECUTION_TIME AS "execution_time_ms",
402468
- ROWS_PRODUCED AS "rows_produced"
402469
- FROM TABLE(${qualifier}.QUERY_HISTORY_BY_SESSION(
402470
- RESULT_LIMIT => ${SNOWFLAKE_HISTORY_LIMIT}))
402471
- WHERE QUERY_TAG = '${sqlLiteral(queryTag, "snowflake")}'
402472
- AND EXECUTION_STATUS = 'SUCCESS'`;
402473
- }
402474
- function pickSnowflakeReadRow(rows, buildSQL) {
402475
- const matches = rows.filter((row) => str(col(row, "query_text"))?.trim() === buildSQL.trim());
402476
- return matches.length === 1 ? matches[0] : null;
402477
- }
402478
- function snowflakeReadCost(row) {
402479
- const scanned = num(col(row, "bytes_scanned"));
402480
- const produced = num(col(row, "rows_produced"));
402481
- return {
402482
- engine: "snowflake",
402483
- jobId: str(col(row, "job_id")),
402484
- parentJobId: null,
402485
- bytesScanned: scanned,
402486
- bytesBilled: null,
402487
- slotTimeMs: null,
402488
- executionTimeMs: num(col(row, "execution_time_ms")),
402489
- cacheHit: scanned === null || produced === null ? null : scanned === 0 && produced > 0
402490
- };
402491
- }
402492
- function col(row, key) {
402493
- if (key in row)
402494
- return row[key];
402495
- const upper = key.toUpperCase();
402496
- if (upper in row)
402497
- return row[upper];
402498
- const hit = Object.keys(row).find((k2) => k2.toLowerCase() === key);
402499
- return hit === undefined ? undefined : row[hit];
402500
- }
402501
- function num(value) {
402502
- if (value === null || value === undefined)
402503
- return null;
402504
- const n = Number(value);
402505
- return Number.isFinite(n) ? n : null;
402506
- }
402507
- function str(value) {
402508
- return typeof value === "string" && value.length > 0 ? value : null;
402509
- }
402510
- function bool(value) {
402511
- if (typeof value === "boolean")
402512
- return value;
402513
- if (typeof value === "string") {
402514
- if (value === "true")
402515
- return true;
402516
- if (value === "false")
402517
- return false;
402518
- }
402519
- return null;
402520
- }
402521
-
402522
- // src/service/materialization_build_session.ts
402523
- var sharedGateSession;
402524
- var PASSTHROUGH_SOURCE_TYPES = [
402525
- "bigquery",
402526
- "snowflake",
402527
- "postgres"
402528
- ];
402529
- var STORAGE_DESTINATION_TYPES = ["ducklake", "duckdb"];
402530
- var STORAGE_TARGET_DIALECT = "duckdb";
402531
- function wrapPassthrough(sourceType, handle, innerSQL) {
402532
- const sql = `'${escapeSQL(innerSQL)}'`;
402533
- const h4 = `'${escapeSQL(handle)}'`;
402534
- switch (sourceType) {
402535
- case "bigquery":
402536
- return `SELECT * FROM bigquery_query(${h4}, ${sql})`;
402537
- case "postgres":
402538
- return `SELECT * FROM postgres_query(${h4}, ${sql})`;
402539
- case "snowflake":
402540
- return `SELECT * FROM snowflake_query(${sql}, ${h4})`;
402541
- default: {
402542
- const exhaustive = sourceType;
402543
- throw new BadRequestError(`Unsupported passthrough source type: ${String(exhaustive)}`);
402544
- }
402545
- }
402546
- }
402547
- function orderByPartitionColumns(selectSQL, partitionColumns, dialect) {
402548
- if (partitionColumns.length === 0) {
402549
- return selectSQL;
402550
- }
402551
- const order = partitionColumns.map((name) => quoteIdentifier(name, dialect)).join(", ");
402552
- const inner = selectSQL.replace(/[\s;]+$/, "");
402553
- return `SELECT * FROM (
402554
- ${inner}
402555
- ) AS partitioned_build ORDER BY ${order}`;
402556
- }
402557
- async function tagSnowflakeSession(session, sourceType, handle, queryMetadata) {
402558
- if (sourceType !== "snowflake")
402559
- return;
402560
- const sql = snowflakeSetQueryTagSQL(queryMetadata, handle);
402561
- if (sql === undefined)
402562
- return;
402563
- try {
402564
- await session.runSQL(sql);
402565
- } catch (error2) {
402566
- logger.warn("Could not tag the Snowflake build session", {
402567
- error: error2 instanceof Error ? error2.message : String(error2)
402568
- });
402569
- recordAttributionSkipped("tag_failed");
402570
- }
402571
- }
402572
- async function canSplitBigQueryRead(session, handle) {
402573
- try {
402574
- await session.runSQL(`SELECT job_id FROM bigquery_jobs('${escapeSQL(handle)}', maxResults := 1)`);
402575
- return true;
402576
- } catch (error2) {
402577
- logger.warn("Cannot list BigQuery jobs for this connection, so this build's " + "warehouse read will not be labelled or costed", {
402578
- error: error2 instanceof Error ? error2.message : String(error2)
402579
- });
402580
- recordAttributionSkipped("job_listing_unavailable");
402581
- return false;
402582
- }
402583
- }
402584
-
402585
- class BilledReadNotCapturedError extends Error {
402586
- constructor(message, options) {
402587
- super(message, options);
402588
- this.name = "BilledReadNotCapturedError";
402589
- }
402590
- }
402591
- var BIGQUERY_LOOKUP_ATTEMPTS = 3;
402592
- var BIGQUERY_LOOKUP_BACKOFF_MS = 250;
402593
- async function withBigQueryLookupRetry(work, parentJobId) {
402594
- let lastError;
402595
- for (let attempt = 1;attempt <= BIGQUERY_LOOKUP_ATTEMPTS; attempt++) {
402596
- try {
402597
- return await work();
402598
- } catch (error2) {
402599
- lastError = error2;
402600
- if (attempt < BIGQUERY_LOOKUP_ATTEMPTS) {
402601
- logger.warn("Could not read the BigQuery job record for a build's read; retrying", {
402602
- attempt,
402603
- of: BIGQUERY_LOOKUP_ATTEMPTS,
402604
- parentJobId,
402605
- error: error2 instanceof Error ? error2.message : String(error2)
402606
- });
402607
- await new Promise((resolve8) => setTimeout(resolve8, BIGQUERY_LOOKUP_BACKOFF_MS * attempt));
402608
- }
402609
- }
402610
- }
402611
- throw new BilledReadNotCapturedError(`Could not read the BigQuery job record for a build's read (parent job ` + `${parentJobId}) after ${BIGQUERY_LOOKUP_ATTEMPTS} attempts, so its ` + `rows cannot be captured: ` + `${lastError instanceof Error ? lastError.message : String(lastError)}`, { cause: lastError });
402612
- }
402613
- async function issuePassthroughRead(session, sourceType, handle, buildSQL, queryMetadata) {
402614
- const label = sourceType === "bigquery" ? bigQueryQueryLabelValue(queryMetadata) : undefined;
402615
- if (label === undefined || !await canSplitBigQueryRead(session, handle)) {
402616
- return {
402617
- selectSQL: wrapPassthrough(sourceType, handle, buildSQL),
402618
- jobId: null,
402619
- cost: null
402620
- };
402621
- }
402622
- const script = `SET @@query_label = "${label}";
402623
- ${buildSQL};`;
402624
- const executed = await session.runSQL(`SELECT * FROM bigquery_execute('${escapeSQL(handle)}', '${escapeSQL(script)}')`);
402625
- const parentJobId = firstColumn(executed, "job_id");
402626
- if (parentJobId === null) {
402627
- throw new BilledReadNotCapturedError("bigquery_execute returned no job_id for the build's read, so its " + "result table cannot be located");
402628
- }
402629
- const located = await withBigQueryLookupRetry(async () => {
402630
- const child = await session.runSQL(`
402631
- SELECT job_id,
402632
- json_extract_string(configuration, '$.query.destinationTable.projectId') AS project,
402633
- json_extract_string(configuration, '$.query.destinationTable.datasetId') AS dataset,
402634
- json_extract_string(configuration, '$.query.destinationTable.tableId') AS table,
402635
- ${BIGQUERY_COST_COLUMNS}
402636
- FROM (SELECT * FROM bigquery_jobs('${escapeSQL(handle)}',
402637
- parentJobId := '${escapeSQL(parentJobId)}')
402638
- WHERE job_type = 'QUERY')
402639
- ORDER BY creation_time DESC`);
402640
- const row = resultRows(child).find((r3) => str2(r3.dataset) && str2(r3.table));
402641
- if (row === undefined) {
402642
- throw new Error(`no child job with a result table under parent ${parentJobId}`);
402643
- }
402644
- return row;
402645
- }, parentJobId);
402646
- const path22 = [
402647
- str2(located.project) ?? handle,
402648
- str2(located.dataset),
402649
- str2(located.table)
402650
- ].join(".");
402651
- const jobId = str2(located.job_id);
402652
- return {
402653
- selectSQL: `SELECT * FROM bigquery_scan('${escapeSQL(path22)}')`,
402654
- jobId,
402655
- cost: bigQueryReadCost(located, jobId, parentJobId)
402656
- };
402657
- }
402658
- async function snowflakeReadCostAfterBuild(session, sourceType, handle, buildSQL, queryMetadata, database) {
402659
- if (sourceType !== "snowflake")
402660
- return null;
402661
- const tag = snowflakeQueryTagValue(queryMetadata);
402662
- if (tag === undefined)
402663
- return null;
402664
- try {
402665
- const costResult = await session.runSQL(passthroughSnowflake(snowflakeCostSQL(tag, database), handle));
402666
- const candidates = resultRows(costResult);
402667
- const row = pickSnowflakeReadRow(candidates, buildSQL);
402668
- if (row === null) {
402669
- recordAttributionSkipped(candidates.length === 0 ? "read_row_not_found" : "read_row_ambiguous");
402670
- return null;
402671
- }
402672
- return snowflakeReadCost(row);
402673
- } catch (error2) {
402674
- logger.warn("Could not read back what the Snowflake build read cost", {
402675
- error: error2 instanceof Error ? error2.message : String(error2)
402676
- });
402677
- recordAttributionSkipped("cost_query_failed");
402678
- return null;
402679
- }
402680
- }
402681
- function passthroughSnowflake(innerSQL, handle) {
402682
- return `SELECT * FROM snowflake_query('${escapeSQL(innerSQL)}', '${escapeSQL(handle)}')`;
402683
- }
402684
- function resultRows(result) {
402685
- if (Array.isArray(result))
402686
- return result;
402687
- const rows = result?.rows;
402688
- return Array.isArray(rows) ? rows : [];
402689
- }
402690
- function firstColumn(result, column) {
402691
- const rows = resultRows(result);
402692
- return rows.length > 0 ? str2(rows[0][column]) : null;
402693
- }
402694
- function str2(value) {
402695
- return typeof value === "string" && value.length > 0 ? value : null;
402696
- }
402697
- async function clearSnowflakeSessionTag(session, sourceType, handle) {
402698
- if (sourceType !== "snowflake" || handle === undefined)
402699
- return;
402700
- try {
402701
- await session.runSQL(passthroughSnowflake("ALTER SESSION UNSET QUERY_TAG", handle));
402702
- } catch (error2) {
402703
- logger.warn("Could not clear the Snowflake build session's query tag", {
402704
- error: error2 instanceof Error ? error2.message : String(error2)
402705
- });
402706
- }
402707
- }
402708
- function passthroughSourceType(sourceConnection) {
402709
- const type = sourceConnection.type;
402710
- if (PASSTHROUGH_SOURCE_TYPES.includes(type ?? "")) {
402711
- return type;
402712
- }
402713
- throw new BadRequestError(`Cannot materialize a '${type}' source into a storage destination: the ` + `native query-passthrough build supports source connections of type ` + `${PASSTHROUGH_SOURCE_TYPES.join(", ")} only.`);
402714
- }
402715
- function createIsolatedBuildSession(sessionName) {
402716
- const workDir = mkdtempSync2(path21.join(os6.tmpdir(), "malloy-build-"));
402717
- let session;
402718
- try {
402719
- session = new DuckDBConnection4(sessionName, ":memory:", workDir, {
402720
- rowLimit: Number.MAX_SAFE_INTEGER
402721
- });
402722
- } catch (err) {
402723
- rmSync(workDir, { recursive: true, force: true });
402724
- throw err;
402725
- }
402726
- const dispose = async () => {
402727
- const nodeConnection = session.connection;
402728
- try {
402729
- await session.close();
402730
- } catch (err) {
402731
- logger.warn("Failed to close build session (leaked session)", {
402732
- sessionName,
402733
- error: err instanceof Error ? err.message : String(err)
402734
- });
402735
- }
402736
- try {
402737
- nodeConnection?.disconnectSync?.();
402738
- } catch (err) {
402739
- logger.warn("Failed to disconnect build session (leaked connection)", {
402740
- sessionName,
402741
- error: err instanceof Error ? err.message : String(err)
402742
- });
402743
- }
402744
- try {
402745
- rmSync(workDir, { recursive: true, force: true });
402746
- } catch (err) {
402747
- logger.warn("Failed to remove build session working directory", {
402748
- sessionName,
402749
- workDir,
402750
- error: err instanceof Error ? err.message : String(err)
402751
- });
402752
- }
402753
- };
402754
- return { session, dispose, workDir };
402755
- }
402756
- async function pinSessionToUTC(session) {
402757
- await session.runSQL("SET TimeZone='UTC'");
402758
- }
402759
- async function buildSourceIntoStorage(params) {
402760
- const {
402761
- destinationName,
402762
- destinationConnection,
402763
- sourceConnection,
402764
- buildSQL,
402765
- physicalTableName,
402766
- environmentPath,
402767
- queryMetadata
402768
- } = params;
402769
- const federate = params.deps?.federate ?? federateSourceForPassthrough;
402770
- const read = params.deps?.read ?? issuePassthroughRead;
402771
- assertSupportedDestination(destinationName, destinationConnection);
402772
- const sourceType = passthroughSourceType(sourceConnection);
402773
- const { session, dispose, workDir } = createIsolatedBuildSession(`build_${destinationName}`);
402774
- let federatedHandle;
402775
- let federatedClose;
402776
- try {
402777
- await applySessionResourceLimits(session, { tempDirectory: workDir });
402778
- await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
402779
- if (destinationConnection.type === "ducklake") {
402780
- await session.runSQL("SET ducklake_default_data_inlining_row_limit=0");
402781
- }
402782
- await pinSessionToUTC(session);
402783
- const federated = await federate(session, sourceType, sourceFederationConfig(sourceConnection));
402784
- federatedClose = federated.close;
402785
- await tagSnowflakeSession(session, sourceType, federated.handle, queryMetadata);
402786
- const target = quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT);
402787
- if (params.incremental) {
402788
- const partitioned = (params.partitionColumns?.length ?? 0) > 0;
402789
- const flushThreshold = getPartitionedWriteFlushThreshold();
402790
- const boundDelta = partitioned && flushThreshold !== undefined;
402791
- if (boundDelta) {
402792
- await session.runSQL(`SET partitioned_write_flush_threshold = ${flushThreshold}`);
402793
- await session.runSQL("SET threads = 1");
402794
- }
402795
- let refreshed;
402796
- try {
402797
- refreshed = await params.incremental.plan({
402798
- session,
402799
- sourceType,
402800
- handle: federated.handle,
402801
- quotedTablePath: target
402802
- });
402803
- } finally {
402804
- if (boundDelta) {
402805
- try {
402806
- await session.runSQL("RESET threads");
402807
- await session.runSQL("RESET partitioned_write_flush_threshold");
402808
- } catch {}
402809
- }
402810
- }
402811
- if (refreshed) {
402812
- return {
402813
- storageDestinationName: destinationName,
402814
- schema: await describeTable(session, target),
402815
- readCost: refreshed.readCost,
402816
- refresh: refreshed
402817
- };
402818
- }
402819
- }
402820
- const passthrough = await read(session, sourceType, federated.handle, buildSQL, queryMetadata);
402821
- const schema = await createTableAndDescribe(session, target, passthrough.selectSQL, params.partitionColumns ?? [], { sourceType });
402822
- const seededThrough = await params.incremental?.afterSeed({
402823
- session,
402824
- quotedTablePath: target
402825
- });
402826
- return {
402827
- seededThrough,
402828
- storageDestinationName: destinationName,
402829
- schema,
402830
- readCost: passthrough.cost ?? await snowflakeReadCostAfterBuild(session, sourceType, federated.handle, buildSQL, queryMetadata, sourceConnection.snowflakeConnection?.database)
402831
- };
402832
- } finally {
402833
- await clearSnowflakeSessionTag(session, sourceType, federatedHandle);
402834
- await dispose();
402835
- if (federatedClose) {
402836
- await federatedClose().catch((e2) => logger.warn(`Failed to close the SSH proxy a storage build federated through: ${String(e2)}`));
402837
- }
402838
- }
402839
- }
402840
- async function buildDownstreamIntoStorage(params) {
402841
- const {
402842
- destinationName,
402843
- destinationConnection,
402844
- transientModel,
402845
- downstreamName,
402846
- virtualMap,
402847
- physicalTableName,
402848
- environmentPath
402849
- } = params;
402850
- assertSupportedDestination(destinationName, destinationConnection);
402851
- const { session, dispose, workDir } = createIsolatedBuildSession(`build_${destinationName}`);
402852
- try {
402853
- await applySessionResourceLimits(session, { tempDirectory: workDir });
402854
- await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
402855
- if (destinationConnection.type === "ducklake") {
402856
- await session.runSQL("SET ducklake_default_data_inlining_row_limit=0");
402857
- }
402858
- await pinSessionToUTC(session);
402859
- const root = "file:///chained-build/";
402860
- const url3 = `${root}m.malloy`;
402861
- const runtime = new Runtime4({
402862
- urlReader: new InMemoryURLReader3(new Map([[url3, transientModel]])),
402863
- connections: new FixedConnectionMap3(new Map([[destinationName, session]]), destinationName)
402864
- });
402865
- let model;
402866
- try {
402867
- model = await runtime.loadModel(new URL(url3), { importBaseURL: new URL(root) }).getModel();
402868
- } catch (err) {
402869
- throw new MaterializationEligibilityError({
402870
- message: `Chained build model did not compile over the rebound parents: ${errMessage(err)}`
402871
- });
402872
- }
402873
- const plan = model.getBuildPlan();
402874
- let downstream;
402875
- for (const ps2 of Object.values(plan.sources)) {
402876
- if (ps2.name === downstreamName) {
402877
- downstream = ps2;
402878
- break;
402879
- }
402880
- }
402881
- if (!downstream) {
402882
- throw new MaterializationEligibilityError({
402883
- message: `Chained build model did not yield a persist source named ` + `'${downstreamName}' (the downstream references something the ` + `rebound parents don't provide).`
402884
- });
402885
- }
402886
- const sql = projectToPublicColumns(downstream, downstream.getSQL({ virtualMap }));
402887
- const target = quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT);
402888
- const schema = await createTableAndDescribe(session, target, sql, params.partitionColumns ?? []);
402889
- return {
402890
- storageDestinationName: destinationName,
402891
- schema,
402892
- readCost: null,
402893
- upstreamReuse: "reused"
402894
- };
402895
- } finally {
402896
- await dispose();
402897
- }
402898
- }
402899
- async function assertStorageServeShapeCompiles(params) {
402900
- const { destinationName, sourceName, virtualHandle, physicalTableName } = params;
402901
- const binding = {
402902
- sourceName,
402903
- destinationName,
402904
- virtualHandle,
402905
- tablePath: `${destinationName}.${physicalTableName}`,
402906
- schema: params.schema.filter((c3) => c3.name && c3.type).map((c3) => ({ name: c3.name, type: c3.type }))
402907
- };
402908
- sharedGateSession ??= createIsolatedBuildSession("gate_shared").session;
402909
- await applySessionResourceLimits(sharedGateSession);
402910
- await assertServesInDuckDB(sourceName, binding, new FixedConnectionMap3(new Map([[destinationName, sharedGateSession]]), destinationName));
402911
- }
402912
- function dropStorageTableSql(destinationName, physicalTableName) {
402913
- return `DROP TABLE IF EXISTS ${quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT)}`;
402914
- }
402915
- async function dropStorageTable(params) {
402916
- const {
402917
- destinationName,
402918
- destinationConnection,
402919
- physicalTableName,
402920
- environmentPath
402921
- } = params;
402922
- assertSupportedDestination(destinationName, destinationConnection);
402923
- const { session, dispose, workDir } = createIsolatedBuildSession(`gc_${destinationName}`);
402924
- try {
402925
- await applySessionResourceLimits(session, { tempDirectory: workDir });
402926
- await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
402927
- await session.runSQL(dropStorageTableSql(destinationName, physicalTableName));
402928
- } finally {
402929
- await dispose();
402930
- }
402931
- }
402932
- async function attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath) {
402933
- if (destinationConnection.type === "ducklake") {
402934
- const cfg = destinationConnection.ducklakeConnection;
402935
- if (!cfg) {
402936
- throw new BadRequestError(`Storage destination '${destinationName}' is type 'ducklake' but ` + `has no ducklakeConnection config.`);
402937
- }
402938
- await attachDuckLakeReadWrite(session, destinationName, cfg);
402939
- return;
402940
- }
402941
- const destinationRoot = storageDestinationRoot(environmentPath);
402942
- mkdirSync3(destinationRoot, { recursive: true });
402943
- const dbPath = path21.join(destinationRoot, `${destinationName}.duckdb`);
402944
- await session.runSQL(`ATTACH '${escapeSQL(dbPath)}' AS ${quoteIdentifier(destinationName, STORAGE_TARGET_DIALECT)}`);
402945
- }
402946
- function assertSupportedDestination(destinationName, destinationConnection) {
402947
- const type = destinationConnection.type ?? "";
402948
- if (!STORAGE_DESTINATION_TYPES.includes(type)) {
402949
- throw new BadRequestError(`Storage destination '${destinationName}' is type '${type}', but ` + `'storage=' destinations must be one of ` + `${STORAGE_DESTINATION_TYPES.join(", ")}.`);
402950
- }
402951
- }
402952
- function sourceFederationConfig(sourceConnection) {
402953
- return {
402954
- name: sourceConnection.name ?? "src",
402955
- bigqueryConnection: sourceConnection.bigqueryConnection,
402956
- snowflakeConnection: sourceConnection.snowflakeConnection,
402957
- postgresConnection: sourceConnection.postgresConnection,
402958
- proxy: sourceConnection.proxy
402959
- };
402960
- }
402961
- async function createTableAndDescribe(session, quotedTablePath, selectSQL, partitionColumns = [], options = {}) {
402962
- if (partitionColumns.length === 0) {
402963
- await session.runSQL(`CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL})`);
402964
- return await describeOrDrop(session, quotedTablePath);
402965
- }
402966
- const columns = partitionColumns.map((name) => quoteIdentifier(name, STORAGE_TARGET_DIALECT)).join(", ");
402967
- const flushThreshold = getPartitionedWriteFlushThreshold();
402968
- const bounded = options.sourceType !== undefined && flushThreshold !== undefined;
402969
- if (bounded) {
402970
- await session.runSQL(`SET partitioned_write_flush_threshold = ${flushThreshold}`);
402971
- await session.runSQL("SET threads = 1");
402972
- }
402973
- let schema;
402974
- await session.runSQL("BEGIN TRANSACTION");
402975
- try {
402976
- await session.runSQL(`CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL}) WITH NO DATA`);
402977
- await session.runSQL(`ALTER TABLE ${quotedTablePath} SET PARTITIONED BY (${columns})`);
402978
- const inserted = bounded ? orderByPartitionColumns(selectSQL, partitionColumns, STORAGE_TARGET_DIALECT) : selectSQL;
402979
- await session.runSQL(`INSERT INTO ${quotedTablePath} (${inserted})`);
402980
- schema = await describeTable(session, quotedTablePath);
402981
- await session.runSQL("COMMIT");
402982
- } catch (buildErr) {
402983
- try {
402984
- await session.runSQL("ROLLBACK");
402985
- } catch (rollbackErr) {
402986
- logger.warn("Failed to roll back a partitioned storage build; the served " + "generation may have been replaced", {
402987
- table: quotedTablePath,
402988
- error: errMessage(rollbackErr),
402989
- cause: errMessage(buildErr)
402990
- });
402991
- }
402992
- throw buildErr;
402993
- } finally {
402994
- if (bounded) {
402995
- try {
402996
- await session.runSQL("RESET threads");
402997
- } catch {}
402998
- }
402999
- }
403000
- return schema;
403001
- }
403002
- async function describeOrDrop(session, quotedTablePath) {
403003
- try {
403004
- return await describeTable(session, quotedTablePath);
403005
- } catch (describeErr) {
403006
- await dropStranded(session, quotedTablePath, describeErr);
403007
- throw describeErr;
403008
- }
403009
- }
403010
- async function dropStranded(session, quotedTablePath, cause) {
403011
- try {
403012
- await session.runSQL(`DROP TABLE IF EXISTS ${quotedTablePath}`);
403013
- } catch (dropErr) {
403014
- logger.warn("Failed to drop a storage table stranded by a failed build " + "(physical leak)", {
403015
- table: quotedTablePath,
403016
- error: errMessage(dropErr),
403017
- cause: errMessage(cause)
403018
- });
403019
- }
403020
- }
403021
- async function describeTable(session, quotedTablePath) {
403022
- const result = await session.runSQL(`DESCRIBE ${quotedTablePath}`);
403023
- const rows = Array.isArray(result) ? result : result.rows ?? [];
403024
- const columns = [];
403025
- for (const row of rows) {
403026
- const name = row.column_name;
403027
- const type = row.column_type;
403028
- if (typeof name === "string" && typeof type === "string") {
403029
- columns.push({ name, type });
403030
- }
403031
- }
403032
- return columns;
403033
- }
403034
-
403035
403730
  // src/service/incremental_storage.ts
403036
403731
  function storageDeltaTarget(params) {
403037
403732
  const {
@@ -406083,6 +406778,15 @@ function parseFrameAncestors(raw) {
406083
406778
  return raw.trim();
406084
406779
  }
406085
406780
 
406781
+ // src/service/experimental_dialects.ts
406782
+ import { getDialect } from "@malloydata/malloy";
406783
+ var BUNDLED_EXPERIMENTAL_DIALECTS = ["sqlserver"];
406784
+ function acceptBundledExperimentalDialects() {
406785
+ for (const name of BUNDLED_EXPERIMENTAL_DIALECTS) {
406786
+ getDialect(name).acceptExperimental();
406787
+ }
406788
+ }
406789
+
406086
406790
  // src/server.ts
406087
406791
  assertSupportedNodeVersion();
406088
406792
  function parseArgs() {
@@ -406158,6 +406862,7 @@ parseArgs();
406158
406862
  getPersistStorageMode();
406159
406863
  getPersistCollisionEnforce();
406160
406864
  getQueryMetadataMode();
406865
+ acceptBundledExperimentalDialects();
406161
406866
  var PUBLISHER_PORT = Number(process.env.PUBLISHER_PORT || 4000);
406162
406867
  var PUBLISHER_HOST = process.env.PUBLISHER_HOST || "0.0.0.0";
406163
406868
  var MCP_PORT = Number(process.env.MCP_PORT || 4040);
@@ -406228,6 +406933,7 @@ var databaseController = new DatabaseController(environmentStore);
406228
406933
  var queryController = new QueryController(environmentStore);
406229
406934
  var compileController = new CompileController(environmentStore);
406230
406935
  var materializationService = new MaterializationService(environmentStore);
406936
+ var storageDestinationController = new StorageDestinationController(environmentStore);
406231
406937
  var materializationController = new MaterializationController(materializationService);
406232
406938
  function startMaterializationSchedulerFromEnv(store, service) {
406233
406939
  const config2 = getMaterializationSchedulerConfig();
@@ -407109,6 +407815,31 @@ app.delete(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/m
407109
407815
  res.status(status).json(json2);
407110
407816
  }
407111
407817
  });
407818
+ app.get(`${API_PREFIX2}/environments/:environmentName/storageDestinations/:destinationName/schemas/:schemaName/tables`, async (req, res) => {
407819
+ try {
407820
+ res.status(200).json(await storageDestinationController.listTables(req.params.environmentName, req.params.destinationName, req.params.schemaName));
407821
+ } catch (error2) {
407822
+ const { json: json2, status } = internalErrorToHttpError(error2);
407823
+ res.status(status).json(json2);
407824
+ }
407825
+ });
407826
+ app.delete(`${API_PREFIX2}/environments/:environmentName/storageDestinations/:destinationName/schemas/:schemaName/tables/:tableName`, async (req, res) => {
407827
+ try {
407828
+ await storageDestinationController.dropTable(req.params.environmentName, req.params.destinationName, req.params.schemaName, req.params.tableName);
407829
+ res.status(204).send();
407830
+ } catch (error2) {
407831
+ const { json: json2, status } = internalErrorToHttpError(error2);
407832
+ res.status(status).json(json2);
407833
+ }
407834
+ });
407835
+ app.post(`${API_PREFIX2}/environments/:environmentName/storageDestinations/:destinationName/fileCleanups`, async (req, res) => {
407836
+ try {
407837
+ res.status(201).json(await storageDestinationController.createFileCleanup(req.params.environmentName, req.params.destinationName, req.body ?? {}));
407838
+ } catch (error2) {
407839
+ const { json: json2, status } = internalErrorToHttpError(error2);
407840
+ res.status(status).json(json2);
407841
+ }
407842
+ });
407112
407843
  registerLegacyRoutes(app, {
407113
407844
  environmentStore,
407114
407845
  connectionController,