@malloy-publisher/server 0.9.12 → 0.9.14

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 (22) hide show
  1. package/dist/app/api-doc.yaml +186 -0
  2. package/dist/app/assets/{EnvironmentPage-BFcCRfvE.js → EnvironmentPage-C0Y7JsgF.js} +1 -1
  3. package/dist/app/assets/{HomePage-C0LnSonK.js → HomePage-B3LsCOlr.js} +1 -1
  4. package/dist/app/assets/{MainPage-D3EM60u0.js → MainPage-BCDlscxh.js} +1 -1
  5. package/dist/app/assets/{ModelPage-B1wvZV7r.js → ModelPage-DGXivK0m.js} +2 -2
  6. package/dist/app/assets/{PackagePage-QZ59dwip.js → PackagePage-BBvnnh9f.js} +1 -1
  7. package/dist/app/assets/{RenderedResult-CDiXJAh9.es-C9BYYEfH.js → RenderedResult-DnBtf-cI.es-Bs8KIO86.js} +2 -2
  8. package/dist/app/assets/{RouteError-v8LP_Q9x.js → RouteError-1Eclkzb_.js} +1 -1
  9. package/dist/app/assets/{ThemeEditorPage-B5l2nzAq.js → ThemeEditorPage-loRmpKoQ.js} +1 -1
  10. package/dist/app/assets/{ToggleButtonGroup-C6Lespxy.js → ToggleButtonGroup-CnciHZDP.js} +1 -1
  11. package/dist/app/assets/{documentRoutes-xv0fP4cS.js → documentRoutes-e6eAjpuN.js} +1 -1
  12. package/dist/app/assets/{editPageMounted-COiBq84S.js → editPageMounted-BiGObPJ4.js} +2 -2
  13. package/dist/app/assets/{index-Dynd6zeI.js → index-1Jwtzdx_.js} +1 -1
  14. package/dist/app/assets/{index-5On9Gkpl.js → index-B_gOHtqJ.js} +122 -121
  15. package/dist/app/assets/{index-mIetEOsB.js → index-Bp7WJqoi.js} +1 -1
  16. package/dist/app/assets/{index-BG7MWz9o.js → index-Dnzp7ChL.js} +1 -1
  17. package/dist/app/assets/index.es-396NBJA3.js +143 -0
  18. package/dist/app/index.html +1 -1
  19. package/dist/package_load_worker.mjs +8 -1
  20. package/dist/server.mjs +1250 -727
  21. package/package.json +1 -1
  22. package/dist/app/assets/index.es-FZZk_rEa.js +0 -143
package/dist/server.mjs CHANGED
@@ -205316,6 +205316,8 @@ function internalErrorToHttpError(error2, { log = true, onLogged } = {}) {
205316
205316
  return httpError(404, error2.message, "TABLE_NOT_FOUND");
205317
205317
  } else if (error2 instanceof ConnectionNotFoundError) {
205318
205318
  return httpError(404, error2.message);
205319
+ } else if (error2 instanceof StorageDestinationNotFoundError) {
205320
+ return httpError(404, error2.message);
205319
205321
  } else if (error2 instanceof DestinationNotFoundError) {
205320
205322
  return httpError(422, error2.message);
205321
205323
  } else if (error2 instanceof ConnectionAuthError) {
@@ -205383,7 +205385,7 @@ function toQueryTextProblem(problem) {
205383
205385
  ...problem.at ? { at: { range: problem.at.range } } : {}
205384
205386
  };
205385
205387
  }
205386
- 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, 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;
205387
205389
  var init_errors = __esm(() => {
205388
205390
  init_constants();
205389
205391
  init_logger();
@@ -205557,6 +205559,11 @@ var init_errors = __esm(() => {
205557
205559
  super(message);
205558
205560
  }
205559
205561
  };
205562
+ StorageDestinationNotFoundError = class StorageDestinationNotFoundError extends Error {
205563
+ constructor(message) {
205564
+ super(message);
205565
+ }
205566
+ };
205560
205567
  ConnectionAuthError = class ConnectionAuthError extends Error {
205561
205568
  constructor(message) {
205562
205569
  super(message);
@@ -315102,7 +315109,7 @@ var require_code = __commonJS((exports) => {
315102
315109
  }
315103
315110
  exports._ = _3;
315104
315111
  var plus = new _Code("+");
315105
- function str(strs, ...args) {
315112
+ function str3(strs, ...args) {
315106
315113
  const expr = [safeStringify(strs[0])];
315107
315114
  let i2 = 0;
315108
315115
  while (i2 < args.length) {
@@ -315113,7 +315120,7 @@ var require_code = __commonJS((exports) => {
315113
315120
  optimize(expr);
315114
315121
  return new _Code(expr);
315115
315122
  }
315116
- exports.str = str;
315123
+ exports.str = str3;
315117
315124
  function addCodeArg(code, arg) {
315118
315125
  if (arg instanceof _Code)
315119
315126
  code.push(...arg._items);
@@ -315156,7 +315163,7 @@ var require_code = __commonJS((exports) => {
315156
315163
  return;
315157
315164
  }
315158
315165
  function strConcat(c1, c22) {
315159
- return c22.emptyStr() ? c1 : c1.emptyStr() ? c22 : str`${c1}${c22}`;
315166
+ return c22.emptyStr() ? c1 : c1.emptyStr() ? c22 : str3`${c1}${c22}`;
315160
315167
  }
315161
315168
  exports.strConcat = strConcat;
315162
315169
  function interpolate(x4) {
@@ -316106,22 +316113,22 @@ var require_util11 = __commonJS((exports) => {
316106
316113
  return (0, codegen_1._)`${topSchemaRef}${schemaPath}${(0, codegen_1.getProperty)(keyword)}`;
316107
316114
  }
316108
316115
  exports.schemaRefOrVal = schemaRefOrVal;
316109
- function unescapeFragment(str) {
316110
- return unescapeJsonPointer(decodeURIComponent(str));
316116
+ function unescapeFragment(str3) {
316117
+ return unescapeJsonPointer(decodeURIComponent(str3));
316111
316118
  }
316112
316119
  exports.unescapeFragment = unescapeFragment;
316113
- function escapeFragment(str) {
316114
- return encodeURIComponent(escapeJsonPointer(str));
316120
+ function escapeFragment(str3) {
316121
+ return encodeURIComponent(escapeJsonPointer(str3));
316115
316122
  }
316116
316123
  exports.escapeFragment = escapeFragment;
316117
- function escapeJsonPointer(str) {
316118
- if (typeof str == "number")
316119
- return `${str}`;
316120
- return str.replace(/~/g, "~0").replace(/\//g, "~1");
316124
+ function escapeJsonPointer(str3) {
316125
+ if (typeof str3 == "number")
316126
+ return `${str3}`;
316127
+ return str3.replace(/~/g, "~0").replace(/\//g, "~1");
316121
316128
  }
316122
316129
  exports.escapeJsonPointer = escapeJsonPointer;
316123
- function unescapeJsonPointer(str) {
316124
- return str.replace(/~1/g, "/").replace(/~0/g, "~");
316130
+ function unescapeJsonPointer(str3) {
316131
+ return str3.replace(/~1/g, "/").replace(/~0/g, "~");
316125
316132
  }
316126
316133
  exports.unescapeJsonPointer = unescapeJsonPointer;
316127
316134
  function eachItem(xs2, f5) {
@@ -317105,8 +317112,8 @@ var require_json_schema_traverse = __commonJS((exports, module) => {
317105
317112
  post(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex);
317106
317113
  }
317107
317114
  }
317108
- function escapeJsonPtr(str) {
317109
- return str.replace(/~/g, "~0").replace(/\//g, "~1");
317115
+ function escapeJsonPtr(str3) {
317116
+ return str3.replace(/~/g, "~0").replace(/\//g, "~1");
317110
317117
  }
317111
317118
  });
317112
317119
 
@@ -318202,16 +318209,16 @@ var require_utils96 = __commonJS((exports, module) => {
318202
318209
  isIPV6: true
318203
318210
  };
318204
318211
  }
318205
- function findToken(str, token) {
318212
+ function findToken(str3, token) {
318206
318213
  let ind = 0;
318207
- for (let i2 = 0;i2 < str.length; i2++) {
318208
- if (str[i2] === token)
318214
+ for (let i2 = 0;i2 < str3.length; i2++) {
318215
+ if (str3[i2] === token)
318209
318216
  ind++;
318210
318217
  }
318211
318218
  return ind;
318212
318219
  }
318213
- function removeDotSegments(path20) {
318214
- let input = path20;
318220
+ function removeDotSegments(path21) {
318221
+ let input = path21;
318215
318222
  const output = [];
318216
318223
  let nextSlash = -1;
318217
318224
  let len = 0;
@@ -318611,8 +318618,8 @@ var require_schemes = __commonJS((exports, module) => {
318611
318618
  }
318612
318619
  if (wsComponent.resourceName) {
318613
318620
  const queryIndex = wsComponent.resourceName.indexOf("?");
318614
- const path20 = queryIndex === -1 ? wsComponent.resourceName : wsComponent.resourceName.slice(0, queryIndex);
318615
- wsComponent.path = path20 && path20 !== "/" ? path20 : undefined;
318621
+ const path21 = queryIndex === -1 ? wsComponent.resourceName : wsComponent.resourceName.slice(0, queryIndex);
318622
+ wsComponent.path = path21 && path21 !== "/" ? path21 : undefined;
318616
318623
  wsComponent.query = queryIndex === -1 ? undefined : wsComponent.resourceName.slice(queryIndex + 1);
318617
318624
  wsComponent.resourceName = undefined;
318618
318625
  }
@@ -319175,7 +319182,7 @@ var require_core = __commonJS((exports) => {
319175
319182
  var util_1 = require_util11();
319176
319183
  var $dataRefSchema = require_data();
319177
319184
  var uri_1 = require_uri2();
319178
- var defaultRegExp = (str, flags) => new RegExp(str, flags);
319185
+ var defaultRegExp = (str3, flags) => new RegExp(str3, flags);
319179
319186
  defaultRegExp.code = "new RegExp";
319180
319187
  var META_IGNORE_OPTIONS = ["removeAdditional", "useDefaults", "coerceTypes"];
319181
319188
  var EXT_SCOPE_NAMES = new Set([
@@ -319937,16 +319944,16 @@ var require_multipleOf = __commonJS((exports) => {
319937
319944
  // ../../node_modules/@modelcontextprotocol/sdk/node_modules/ajv/dist/runtime/ucs2length.js
319938
319945
  var require_ucs2length = __commonJS((exports) => {
319939
319946
  Object.defineProperty(exports, "__esModule", { value: true });
319940
- function ucs2length(str) {
319941
- const len = str.length;
319947
+ function ucs2length(str3) {
319948
+ const len = str3.length;
319942
319949
  let length = 0;
319943
319950
  let pos = 0;
319944
319951
  let value;
319945
319952
  while (pos < len) {
319946
319953
  length++;
319947
- value = str.charCodeAt(pos++);
319954
+ value = str3.charCodeAt(pos++);
319948
319955
  if (value >= 55296 && value <= 56319 && pos < len) {
319949
- value = str.charCodeAt(pos);
319956
+ value = str3.charCodeAt(pos);
319950
319957
  if ((value & 64512) === 56320)
319951
319958
  pos++;
319952
319959
  }
@@ -321678,8 +321685,8 @@ var require_formats2 = __commonJS((exports) => {
321678
321685
  }
321679
321686
  var DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
321680
321687
  var DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
321681
- function date4(str) {
321682
- const matches = DATE.exec(str);
321688
+ function date4(str3) {
321689
+ const matches = DATE.exec(str3);
321683
321690
  if (!matches)
321684
321691
  return false;
321685
321692
  const year = +matches[1];
@@ -321698,8 +321705,8 @@ var require_formats2 = __commonJS((exports) => {
321698
321705
  }
321699
321706
  var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
321700
321707
  function getTime(strictTimeZone) {
321701
- return function time3(str) {
321702
- const matches = TIME.exec(str);
321708
+ return function time3(str3) {
321709
+ const matches = TIME.exec(str3);
321703
321710
  if (!matches)
321704
321711
  return false;
321705
321712
  const hr2 = +matches[1];
@@ -321745,8 +321752,8 @@ var require_formats2 = __commonJS((exports) => {
321745
321752
  var DATE_TIME_SEPARATOR = /t|\s/i;
321746
321753
  function getDateTime(strictTimeZone) {
321747
321754
  const time3 = getTime(strictTimeZone);
321748
- return function date_time(str) {
321749
- const dateTime = str.split(DATE_TIME_SEPARATOR);
321755
+ return function date_time(str3) {
321756
+ const dateTime = str3.split(DATE_TIME_SEPARATOR);
321750
321757
  return dateTime.length === 2 && date4(dateTime[0]) && time3(dateTime[1]);
321751
321758
  };
321752
321759
  }
@@ -321771,13 +321778,13 @@ var require_formats2 = __commonJS((exports) => {
321771
321778
  }
321772
321779
  var NOT_URI_FRAGMENT = /\/|:/;
321773
321780
  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;
321774
- function uri(str) {
321775
- return NOT_URI_FRAGMENT.test(str) && URI.test(str);
321781
+ function uri(str3) {
321782
+ return NOT_URI_FRAGMENT.test(str3) && URI.test(str3);
321776
321783
  }
321777
321784
  var BYTE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gm;
321778
- function byte(str) {
321785
+ function byte(str3) {
321779
321786
  BYTE.lastIndex = 0;
321780
- return BYTE.test(str);
321787
+ return BYTE.test(str3);
321781
321788
  }
321782
321789
  var MIN_INT32 = -(2 ** 31);
321783
321790
  var MAX_INT32 = 2 ** 31 - 1;
@@ -321791,11 +321798,11 @@ var require_formats2 = __commonJS((exports) => {
321791
321798
  return true;
321792
321799
  }
321793
321800
  var Z_ANCHOR = /[^\\]\\Z/;
321794
- function regex(str) {
321795
- if (Z_ANCHOR.test(str))
321801
+ function regex(str3) {
321802
+ if (Z_ANCHOR.test(str3))
321796
321803
  return false;
321797
321804
  try {
321798
- new RegExp(str);
321805
+ new RegExp(str3);
321799
321806
  return true;
321800
321807
  } catch (e2) {
321801
321808
  return false;
@@ -321872,7 +321879,7 @@ var require_code3 = __commonJS((exports) => {
321872
321879
  }
321873
321880
  exports._ = _3;
321874
321881
  var plus = new _Code("+");
321875
- function str(strs, ...args) {
321882
+ function str3(strs, ...args) {
321876
321883
  const expr = [safeStringify(strs[0])];
321877
321884
  let i2 = 0;
321878
321885
  while (i2 < args.length) {
@@ -321883,7 +321890,7 @@ var require_code3 = __commonJS((exports) => {
321883
321890
  optimize(expr);
321884
321891
  return new _Code(expr);
321885
321892
  }
321886
- exports.str = str;
321893
+ exports.str = str3;
321887
321894
  function addCodeArg(code, arg) {
321888
321895
  if (arg instanceof _Code)
321889
321896
  code.push(...arg._items);
@@ -321926,7 +321933,7 @@ var require_code3 = __commonJS((exports) => {
321926
321933
  return;
321927
321934
  }
321928
321935
  function strConcat(c1, c22) {
321929
- return c22.emptyStr() ? c1 : c1.emptyStr() ? c22 : str`${c1}${c22}`;
321936
+ return c22.emptyStr() ? c1 : c1.emptyStr() ? c22 : str3`${c1}${c22}`;
321930
321937
  }
321931
321938
  exports.strConcat = strConcat;
321932
321939
  function interpolate(x4) {
@@ -322876,22 +322883,22 @@ var require_util12 = __commonJS((exports) => {
322876
322883
  return (0, codegen_1._)`${topSchemaRef}${schemaPath}${(0, codegen_1.getProperty)(keyword)}`;
322877
322884
  }
322878
322885
  exports.schemaRefOrVal = schemaRefOrVal;
322879
- function unescapeFragment(str) {
322880
- return unescapeJsonPointer(decodeURIComponent(str));
322886
+ function unescapeFragment(str3) {
322887
+ return unescapeJsonPointer(decodeURIComponent(str3));
322881
322888
  }
322882
322889
  exports.unescapeFragment = unescapeFragment;
322883
- function escapeFragment(str) {
322884
- return encodeURIComponent(escapeJsonPointer(str));
322890
+ function escapeFragment(str3) {
322891
+ return encodeURIComponent(escapeJsonPointer(str3));
322885
322892
  }
322886
322893
  exports.escapeFragment = escapeFragment;
322887
- function escapeJsonPointer(str) {
322888
- if (typeof str == "number")
322889
- return `${str}`;
322890
- return str.replace(/~/g, "~0").replace(/\//g, "~1");
322894
+ function escapeJsonPointer(str3) {
322895
+ if (typeof str3 == "number")
322896
+ return `${str3}`;
322897
+ return str3.replace(/~/g, "~0").replace(/\//g, "~1");
322891
322898
  }
322892
322899
  exports.escapeJsonPointer = escapeJsonPointer;
322893
- function unescapeJsonPointer(str) {
322894
- return str.replace(/~1/g, "/").replace(/~0/g, "~");
322900
+ function unescapeJsonPointer(str3) {
322901
+ return str3.replace(/~1/g, "/").replace(/~0/g, "~");
322895
322902
  }
322896
322903
  exports.unescapeJsonPointer = unescapeJsonPointer;
322897
322904
  function eachItem(xs2, f5) {
@@ -324725,7 +324732,7 @@ var require_core3 = __commonJS((exports) => {
324725
324732
  var util_1 = require_util12();
324726
324733
  var $dataRefSchema = require_data2();
324727
324734
  var uri_1 = require_uri3();
324728
- var defaultRegExp = (str, flags) => new RegExp(str, flags);
324735
+ var defaultRegExp = (str3, flags) => new RegExp(str3, flags);
324729
324736
  defaultRegExp.code = "new RegExp";
324730
324737
  var META_IGNORE_OPTIONS = ["removeAdditional", "useDefaults", "coerceTypes"];
324731
324738
  var EXT_SCOPE_NAMES = new Set([
@@ -325487,16 +325494,16 @@ var require_multipleOf2 = __commonJS((exports) => {
325487
325494
  // ../../node_modules/ajv-formats/node_modules/ajv/dist/runtime/ucs2length.js
325488
325495
  var require_ucs2length2 = __commonJS((exports) => {
325489
325496
  Object.defineProperty(exports, "__esModule", { value: true });
325490
- function ucs2length(str) {
325491
- const len = str.length;
325497
+ function ucs2length(str3) {
325498
+ const len = str3.length;
325492
325499
  let length = 0;
325493
325500
  let pos = 0;
325494
325501
  let value;
325495
325502
  while (pos < len) {
325496
325503
  length++;
325497
- value = str.charCodeAt(pos++);
325504
+ value = str3.charCodeAt(pos++);
325498
325505
  if (value >= 55296 && value <= 56319 && pos < len) {
325499
- value = str.charCodeAt(pos);
325506
+ value = str3.charCodeAt(pos);
325500
325507
  if ((value & 64512) === 56320)
325501
325508
  pos++;
325502
325509
  }
@@ -346494,6 +346501,9 @@ async function attachDuckLake(connection, dbName, ducklakeConfig) {
346494
346501
  readOnly: true
346495
346502
  });
346496
346503
  }
346504
+ async function attachDuckLakeReadOnly(connection, dbName, ducklakeConfig) {
346505
+ await attachDuckLake(connection, dbName, ducklakeConfig);
346506
+ }
346497
346507
  async function attachDuckLakeReadWrite(connection, dbName, ducklakeConfig) {
346498
346508
  await attachDuckLakeWithMode(connection, dbName, ducklakeConfig, {
346499
346509
  readOnly: false
@@ -355750,7 +355760,7 @@ The agent orchestrates these. Each query and chart step hands off to a focused s
355750
355760
  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.
355751
355761
  3. SCAFFOLD the package (template below).
355752
355762
  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\`.
355753
- 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.
355763
+ 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\`).
355754
355764
  6. EMBED (optional). Read \`reference/embedding.md\`.
355755
355765
  7. PREVIEW with the local authoring loop (below).
355756
355766
  8. VERIFY before you call it done (see "What 'done' means" below). This step is not optional.
@@ -355770,6 +355780,9 @@ A data app you can defend has all of these. Build to this list, not to the scaff
355770
355780
  - **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.
355771
355781
  - **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.
355772
355782
  - **Vendored libraries.** Chart and helper libraries live in \`public/\`, loaded locally (step 5).
355783
+ - **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.)
355784
+ - **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\`.)
355785
+ - **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\`.)
355773
355786
  - **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.
355774
355787
 
355775
355788
  ### Verify before you call it done
@@ -355778,6 +355791,7 @@ You are building for someone who cannot tell a correct dashboard from a broken o
355778
355791
 
355779
355792
  - **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.
355780
355793
  - **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\`).
355794
+ - **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.
355781
355795
  - **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.
355782
355796
 
355783
355797
  ## Minimal scaffold
@@ -355882,7 +355896,7 @@ Publishing an app is publishing its package: get the package into publishable sh
355882
355896
 
355883
355897
  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.
355884
355898
 
355885
- Available: archetypes, depth-patterns, design, embedding, lazy-load, runtime, token-contract, verification-harness, visual-craft.`
355899
+ Available: archetypes, depth-patterns, design, embedding, lazy-load, overlays, performance, runtime, token-contract, verification-harness, visual-craft.`
355886
355900
  },
355887
355901
  {
355888
355902
  name: "malloy-html-data-apps/archetypes",
@@ -356064,7 +356078,7 @@ Write down that you did this and why. A reader who later sees hand-built drill q
356064
356078
 
356065
356079
  ## Drill-down: the panel side
356066
356080
 
356067
- An entity drawer has six parts. Skipping any one produces a bug that only shows up in use, not in a screenshot.
356081
+ 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.
356068
356082
 
356069
356083
  \`\`\`js
356070
356084
  const drill = { entity: null, charts: [], returnFocus: null };
@@ -356094,7 +356108,7 @@ function closeDrill() {
356094
356108
  document.removeEventListener("keydown", onTab);
356095
356109
  drill.charts.forEach((c) => c.destroy()); // 4. tear charts down
356096
356110
  drill.charts = [];
356097
- drill.returnFocus?.focus(); // 5. give focus back
356111
+ drill.returnFocus?.focus({ preventScroll: true }); // 5. give focus back, without scrolling
356098
356112
  }
356099
356113
  \`\`\`
356100
356114
 
@@ -356107,6 +356121,13 @@ What each part prevents:
356107
356121
  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.
356108
356122
  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.
356109
356123
 
356124
+ The four that separate a working drawer from a polished one:
356125
+
356126
+ 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.
356127
+ 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.
356128
+ 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.
356129
+ 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.
356130
+
356110
356131
  ## Cross-filtering (shared scope)
356111
356132
 
356112
356133
  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.
@@ -356336,6 +356357,15 @@ Omit \`height\` and the frame auto-sizes. The embedded page measures its real co
356336
356357
 
356337
356358
  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.
356338
356359
 
356360
+ ## Inside the frame there is no viewport
356361
+
356362
+ 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:
356363
+
356364
+ - **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.
356365
+ - **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.
356366
+
356367
+ 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\`).
356368
+
356339
356369
  ## Auth
356340
356370
 
356341
356371
  - Same-origin or same-tenant: pass no token. The browser's cookies authenticate the iframe.
@@ -356433,11 +356463,262 @@ for (let y = 0; y <= pageH; y += vh) {
356433
356463
  }
356434
356464
  await page.waitForFunction((n) => document.querySelectorAll(".tile .value").length >= n, total);
356435
356465
  \`\`\``
356466
+ },
356467
+ {
356468
+ name: "malloy-html-data-apps/overlays",
356469
+ description: "Drawers, trays and toasts in an embedded app. Reference detail for the malloy-html-data-apps skill.",
356470
+ 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."
356471
+ },
356472
+ {
356473
+ name: "malloy-html-data-apps/performance",
356474
+ description: "Performance and layout stability. Reference detail for the malloy-html-data-apps skill.",
356475
+ body: `# Performance and layout stability
356476
+
356477
+ > 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.
356478
+
356479
+ ## Measure first
356480
+
356481
+ 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.
356482
+
356483
+ | Measure | How | Bar |
356484
+ |---|---|---|
356485
+ | Bytes downloaded, by type | sum of response sizes on a cold load | the chart library is usually most of it; see below |
356486
+ | Queries issued | count of \`/query\` requests until the page settles | one per tile, no more |
356487
+ | Identical queries | same model + same query text twice on one page | 0 |
356488
+ | Layout shift (CLS) | a \`layout-shift\` \`PerformanceObserver\`, first visit and repeat visit | under 0.1 first visit, 0 on repeat |
356489
+ | Time to settled | until no query has been in flight for ~1.5s | depends on the warehouse; measure, do not promise |
356490
+
356491
+ 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.
356492
+
356493
+ ## Keep the chart library off the critical path
356494
+
356495
+ 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.
356496
+
356497
+ - **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.
356498
+ - **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.
356499
+ - **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.
356500
+
356501
+ 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\`).
356502
+
356503
+ \`\`\`js
356504
+ // charts.js
356505
+ let libP = null;
356506
+ function chartLib() {
356507
+ if (window.echarts) return Promise.resolve(window.echarts);
356508
+ libP ??= new Promise((res, rej) => {
356509
+ const s = document.createElement("script");
356510
+ s.src = new URL("./vendor/echarts.min.js", import.meta.url).href; // same URL as the preload
356511
+ s.onload = () => res(window.echarts);
356512
+ s.onerror = () => { libP = null; rej(new Error("Chart library failed to load")); };
356513
+ document.head.append(s);
356514
+ });
356515
+ return libP;
356516
+ }
356517
+
356518
+ export function mount(host, buildOption) {
356519
+ if (!window.echarts) {
356520
+ const calls = [], stub = { on: (...a) => (calls.push(a), stub) };
356521
+ const mine = (host.__mount = (host.__mount || 0) + 1); // a newer mount wins
356522
+ chartLib().then(() => {
356523
+ if (host.__mount !== mine || !host.isConnected) return;
356524
+ const inst = mount(host, buildOption);
356525
+ calls.forEach((a) => inst.on(...a));
356526
+ }).catch((e) => showError(host, e)); // the tile's own error state
356527
+ return stub;
356528
+ }
356529
+ host.__mount = (host.__mount || 0) + 1;
356530
+ echarts.getInstanceByDom(host)?.dispose(); // a remount replaces, never merges
356531
+ const inst = echarts.init(host);
356532
+ inst.setOption(buildOption());
356533
+ return inst;
356534
+ }
356535
+ \`\`\`
356536
+
356537
+ ### Trim the library to what the app draws
356538
+
356539
+ 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.
356540
+
356541
+ **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.
356542
+
356543
+ ## Ask once
356544
+
356545
+ - **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.
356546
+ - **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.
356547
+ - **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.
356548
+
356549
+ ## Hold still while loading
356550
+
356551
+ Layout shift is the most visible difference between a generated app and a polished one, and almost all of it has three causes.
356552
+
356553
+ **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:
356554
+
356555
+ - 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);
356556
+ - give a one-line block a \`min-height\` while it is \`:empty\`.
356557
+
356558
+ **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.
356559
+
356560
+ - **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.
356561
+ - **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.
356562
+
356563
+ \`\`\`js
356564
+ const KEY = "app-size:" + location.pathname;
356565
+ const sizes = (() => { try { return JSON.parse(localStorage.getItem(KEY) || "{}"); } catch { return {}; } })();
356566
+ const sizeKey = (host) => host.id && \`\${host.id}@\${Math.round(host.clientWidth / 40)}\`;
356567
+ function holdSize(host) { const k = sizeKey(host); if (k && sizes[k]) host.style.minHeight = sizes[k] + "px"; return k; }
356568
+ function settleSize(host, k) { // call right after render, same task
356569
+ host.style.minHeight = "";
356570
+ if (!k) return;
356571
+ const h = Math.round(host.getBoundingClientRect().height);
356572
+ if (h > 0 && sizes[k] !== h) { sizes[k] = h; try { localStorage.setItem(KEY, JSON.stringify(sizes)); } catch {} }
356573
+ }
356574
+ \`\`\`
356575
+
356576
+ **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.
356577
+
356578
+ \`\`\`js
356579
+ function load(host, query, render) {
356580
+ const seq = (host.__seq = (host.__seq || 0) + 1);
356581
+ if (host.__drawn) { host.style.minHeight = host.offsetHeight + "px"; host.classList.add("is-busy"); host.setAttribute("aria-busy", "true"); }
356582
+ else showSkeleton(host);
356583
+ const unbusy = () => { host.classList.remove("is-busy"); host.removeAttribute("aria-busy"); };
356584
+ return Publisher.query(MODEL, query).then((rows) => {
356585
+ if (seq !== host.__seq) return; // a newer run owns this tile
356586
+ unbusy(); host.__drawn = true;
356587
+ render(rows, host);
356588
+ host.style.minHeight = ""; // released after the new rows are in
356589
+ }, (e) => {
356590
+ if (seq !== host.__seq) return;
356591
+ unbusy(); showError(host, e); // a failed re-run must not stay dimmed and busy
356592
+ host.style.minHeight = "";
356593
+ });
356594
+ }
356595
+ \`\`\`
356596
+
356597
+ ## Answer from the last visit (opt-in)
356598
+
356599
+ 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.
356600
+
356601
+ **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.
356602
+
356603
+ **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.
356604
+
356605
+ How it stays safe:
356606
+
356607
+ - **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.
356608
+ - **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.
356609
+ - **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\`).
356610
+ - **A 401 or 403 on any query wipes the store**, and that tile shows its error.
356611
+ - **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.
356612
+ - **The store is capped.** Every filter combination is a new key; prune the oldest past a few hundred entries.
356613
+ - **Show how old the number is.** A tile that can draw yesterday's answer should carry the as-of date of its data.
356614
+
356615
+ 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.
356616
+
356617
+ \`\`\`js
356618
+ // cache.js: draw the last visit's answer once this visit has proven it may read.
356619
+ // Opt-in: only for models whose rows are the same for every reader (see above).
356620
+ const STALE = 36 * 3600e3, CAP = 300;
356621
+ const SCOPE = location.pathname.split("/").slice(0, 5).join("/"); // /environments/<env>/packages/<pkg>
356622
+ let mayRead; const live = new Promise((res) => (mayRead = res)); // resolves on the first good answer
356623
+ const inflight = new Map();
356624
+ const db = new Promise((res) => {
356625
+ try { const rq = indexedDB.open("app-cache", 1);
356626
+ rq.onupgradeneeded = () => rq.result.createObjectStore("q").createIndex("t", "t");
356627
+ rq.onsuccess = () => res(rq.result); rq.onerror = rq.onblocked = () => res(null);
356628
+ } catch { res(null); } // private mode: no persistence
356629
+ });
356630
+ const store = (mode, fn) => db.then((d) => d && new Promise((res) => {
356631
+ try { const tx = d.transaction("q", mode); const r = fn(tx.objectStore("q")); tx.oncomplete = () => res(r?.result ?? null); tx.onerror = () => res(null); }
356632
+ catch { res(null); }
356633
+ }));
356634
+ const wipe = () => store("readwrite", (s) => s.clear());
356635
+ const prune = () => store("readwrite", (s) => {
356636
+ const c = s.count(); c.onsuccess = () => {
356637
+ let extra = c.result - CAP; if (extra <= 0) return;
356638
+ s.index("t").openCursor().onsuccess = (e) => { const cur = e.target.result; if (cur && extra-- > 0) { cur.delete(); cur.continue(); } };
356639
+ };
356640
+ });
356641
+
356642
+ // Resolves with rows for the tile: the stored answer once the page may read, or the
356643
+ // network answer if that comes first. onFresh(rows, err) fires if the network answer
356644
+ // arrives after a stored one was drawn and differs, or if the query is refused.
356645
+ export function cachedQuery(model, q, opts = {}, onFresh) {
356646
+ const k = JSON.stringify([SCOPE, model, q, opts]);
356647
+ let net = inflight.get(k); // two tiles, one request
356648
+ if (!net) {
356649
+ net = Publisher.query(model, q, opts).then((rows) => {
356650
+ mayRead();
356651
+ store("readwrite", (s) => s.put({ t: Date.now(), r: rows }, k)).then(prune);
356652
+ return rows;
356653
+ }, (e) => { if (e?.status === 401 || e?.status === 403) wipe(); throw e; })
356654
+ .finally(() => inflight.delete(k));
356655
+ inflight.set(k, net);
356656
+ }
356657
+ const stored = store("readonly", (s) => s.get(k)).then((box) =>
356658
+ box && Date.now() - box.t < STALE ? live.then(() => box.r) : new Promise(() => {})); // never: wait for the network
356659
+ return Promise.race([net.then((r) => ({ r })), stored.then((r) => ({ r, stored: true }))]).then((first) => {
356660
+ if (first.stored) net.then( // drawn from the store: follow up once
356661
+ (rows) => { if (JSON.stringify(rows) !== JSON.stringify(first.r)) onFresh?.(rows, null); },
356662
+ (e) => onFresh?.(null, e));
356663
+ return first.r;
356664
+ });
356665
+ }
356666
+ \`\`\`
356667
+
356668
+ ## Start on intent (opt-in)
356669
+
356670
+ 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.
356671
+
356672
+ 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.
356673
+
356674
+ \`\`\`js
356675
+ // drawers.js: a drill row names its drawer (data-drawer="market" data-key="42").
356676
+ // DRAWERS.market(key, body, { warm }) sends the drawer's queries; warm, it stops there.
356677
+ export function drawerFor(row, { warm = false } = {}) {
356678
+ const build = DRAWERS[row.dataset.drawer];
356679
+ if (!build) return;
356680
+ if (warm) return build(row.dataset.key, null, { warm }); // queries only: nothing rendered, nothing opened
356681
+ return build(row.dataset.key, openDrawer(row), { warm }); // openDrawer: the real open (reference/overlays.md)
356682
+ }
356683
+ document.addEventListener("click", (e) => { const row = e.target.closest?.("[data-drawer]"); if (row) drawerFor(row); });
356684
+
356685
+ // Intent: an 80ms rest, by pointer or keyboard, so a sweep down a list warms nothing;
356686
+ // at most two drawers warming at once.
356687
+ let timer = 0, warmingNow = 0;
356688
+ const warmed = new WeakSet();
356689
+ const intent = (e) => {
356690
+ clearTimeout(timer);
356691
+ const row = e.target.closest?.("[data-drawer]");
356692
+ if (!row || warmed.has(row) || e.pointerType === "touch") return;
356693
+ timer = setTimeout(() => {
356694
+ if (warmingNow >= 2) return;
356695
+ warmed.add(row); warmingNow++;
356696
+ Promise.resolve(drawerFor(row, { warm: true })).catch(() => {}).finally(() => warmingNow--);
356697
+ }, 80);
356698
+ };
356699
+ document.addEventListener("pointerover", intent);
356700
+ document.addEventListener("focusin", intent);
356701
+ \`\`\`
356702
+
356703
+ What makes it safe:
356704
+
356705
+ - **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.
356706
+ - **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.
356707
+ - **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.
356708
+ - **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.
356709
+
356710
+ ## Respond before the answer arrives
356711
+
356712
+ 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).
356713
+
356714
+ ## What the page cannot fix
356715
+
356716
+ 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.`
356436
356717
  },
356437
356718
  {
356438
356719
  name: "malloy-html-data-apps/runtime",
356439
356720
  description: "HTML Data App Runtime. Reference detail for the malloy-html-data-apps skill.",
356440
- 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. |'
356721
+ 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. |'
356441
356722
  },
356442
356723
  {
356443
356724
  name: "malloy-html-data-apps/token-contract",
@@ -356803,6 +357084,59 @@ Three things bite on this path, and each makes a correct page look broken:
356803
357084
  - **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.
356804
357085
  - **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.
356805
357086
 
357087
+ ## 4. Embedded, layout stability, and drawers
357088
+
357089
+ 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.
357090
+
357091
+ **Load it embedded, not only standalone.** Two hosts, in order of effort:
357092
+
357093
+ - **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.
357094
+ - **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.
357095
+
357096
+ Then, per page, embedded and standalone:
357097
+
357098
+ \`\`\`js
357099
+ // Layout shift: install before navigation, applies to every frame.
357100
+ await context.addInitScript(() => {
357101
+ window.__cls = 0;
357102
+ new PerformanceObserver((l) => l.getEntries().forEach((e) => { if (!e.hadRecentInput) window.__cls += e.value; }))
357103
+ .observe({ type: "layout-shift", buffered: true });
357104
+ });
357105
+ // Read __cls from the APP's frame after the page settles, on a first visit and
357106
+ // again after page.reload() in the same context (the repeat visit). Target:
357107
+ // under 0.1 first, 0 repeat. Embedded, every shift on the page counts.
357108
+
357109
+ // An auto-sized frame (the console, Publisher.embed without a height) must never
357110
+ // scroll: the document is exactly as tall as its frame. Skip this for a
357111
+ // fixed-height embed, which is meant to scroll inside its frame.
357112
+ const innerScroll = await appFrame.evaluate(() => document.documentElement.scrollHeight - innerHeight);
357113
+ if (innerScroll > 0) problems.push(\`app scrolls \${innerScroll}px inside its frame\`);
357114
+
357115
+ // Ask once: identical (model, query) pairs on one page.
357116
+ const seen = new Set(); let dup = 0;
357117
+ page.on("request", (r) => { if (r.url().includes("/query")) { const k = r.url() + r.postData(); dup += seen.has(k); seen.add(k); } });
357118
+ \`\`\`
357119
+
357120
+ **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):
357121
+
357122
+ | Check | Pass |
357123
+ |---|---|
357124
+ | The clicked row's screen position, sampled every frame while the drawer opens | moves 0px |
357125
+ | Host scroll position during the open | unchanged |
357126
+ | Drawer header top | equals the top of the host's visible area (below the host's own header) |
357127
+ | Drawer bottom | equals the bottom of the visible area, within a few pixels (the band moves in steps of the observer's thresholds) |
357128
+ | Drawer body \`scrollTop\` on open, and after drilling into a second subject from inside it | 0 |
357129
+ | 12 wheel turns over the drawer | the body scrolls; the host and the app move 0px |
357130
+ | 4 wheel turns over the scrim | the host and the app move 0px |
357131
+ | Host scrolled by other means (\`scrollBy\` on the host container) while open | drawer still spans the visible area |
357132
+ | Escape | drawer gone, host where it was, focus back on the clicked row |
357133
+
357134
+ 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.
357135
+
357136
+ **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.
357137
+
357138
+ **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.
357139
+
356806
357140
  ## Gotchas (each cost a real debugging cycle)
356807
357141
 
356808
357142
  - **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.
@@ -361003,7 +361337,7 @@ For anything not covered here, call \`search_malloy_docs\` with the topic (for e
361003
361337
  {
361004
361338
  name: "malloy-reports",
361005
361339
  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.",
361006
- 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."
361340
+ 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."
361007
361341
  },
361008
361342
  {
361009
361343
  name: "malloy-reports/grid",
@@ -394475,7 +394809,7 @@ ${query}`);
394475
394809
  }
394476
394810
  }
394477
394811
  // package.json
394478
- var version2 = "0.9.12";
394812
+ var version2 = "0.9.14";
394479
394813
 
394480
394814
  // src/version.ts
394481
394815
  var SERVER_VERSION = version2;
@@ -396446,6 +396780,817 @@ class MaterializationController {
396446
396780
  }
396447
396781
  }
396448
396782
 
396783
+ // src/controller/storage_destination.controller.ts
396784
+ init_errors();
396785
+ init_logger();
396786
+
396787
+ // src/service/materialization_build_session.ts
396788
+ init_errors();
396789
+ init_logger();
396790
+ import { DuckDBConnection as DuckDBConnection4 } from "@malloydata/db-duckdb";
396791
+ import {
396792
+ FixedConnectionMap as FixedConnectionMap3,
396793
+ InMemoryURLReader as InMemoryURLReader3,
396794
+ Runtime as Runtime4
396795
+ } from "@malloydata/malloy";
396796
+ import {
396797
+ existsSync as existsSync3,
396798
+ mkdirSync as mkdirSync3,
396799
+ mkdtempSync as mkdtempSync2,
396800
+ readdirSync as readdirSync3,
396801
+ rmSync
396802
+ } from "node:fs";
396803
+ import os5 from "node:os";
396804
+ import path20 from "node:path";
396805
+
396806
+ // src/service/build_query_tag.ts
396807
+ var MAX_QUERY_TAG_LENGTH = 2000;
396808
+ var BQ_MAX_LEN = 63;
396809
+ function snowflakeQueryTagValue(metadata) {
396810
+ if (metadata === undefined || Object.keys(metadata).length === 0) {
396811
+ return;
396812
+ }
396813
+ const tag = JSON.stringify(metadata);
396814
+ return tag.length > MAX_QUERY_TAG_LENGTH ? undefined : tag;
396815
+ }
396816
+ function snowflakeSetQueryTagSQL(metadata, secretName) {
396817
+ const tag = snowflakeQueryTagValue(metadata);
396818
+ if (tag === undefined)
396819
+ return;
396820
+ const inner = `ALTER SESSION SET QUERY_TAG = '${sqlLiteral(tag, "snowflake")}'`;
396821
+ return `SELECT * FROM snowflake_query('${inner.replace(/'/g, "''")}', '${secretName.replace(/'/g, "''")}')`;
396822
+ }
396823
+ function sanitizeBigQueryValue(value) {
396824
+ return value.toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, BQ_MAX_LEN);
396825
+ }
396826
+ function sanitizeBigQueryKey(key) {
396827
+ const sanitized = sanitizeBigQueryValue(key);
396828
+ return /^[a-z]/.test(sanitized) ? sanitized : undefined;
396829
+ }
396830
+ function bigQueryQueryLabelValue(metadata) {
396831
+ if (metadata === undefined)
396832
+ return;
396833
+ const rendered = new Map;
396834
+ for (const [key, value] of Object.entries(metadata)) {
396835
+ const sanitizedKey = sanitizeBigQueryKey(key);
396836
+ if (sanitizedKey === undefined)
396837
+ continue;
396838
+ rendered.set(sanitizedKey, sanitizeBigQueryValue(value));
396839
+ }
396840
+ if (rendered.size === 0)
396841
+ return;
396842
+ return [...rendered].map(([key, value]) => `${key}:${value}`).join(",");
396843
+ }
396844
+
396845
+ // src/service/build_read_cost.ts
396846
+ var SNOWFLAKE_HISTORY_LIMIT = 1e4;
396847
+ var BIGQUERY_COST_COLUMNS = `
396848
+ total_slot_time_ms,
396849
+ json_extract_string(statistics, '$.query.totalBytesProcessed') AS bytes_processed,
396850
+ json_extract_string(statistics, '$.query.totalBytesBilled') AS bytes_billed,
396851
+ json_extract_string(statistics, '$.query.cacheHit') AS cache_hit`;
396852
+ function bigQueryReadCost(row, jobId, parentJobId) {
396853
+ return {
396854
+ engine: "bigquery",
396855
+ jobId,
396856
+ parentJobId,
396857
+ bytesScanned: num(row.bytes_processed),
396858
+ bytesBilled: num(row.bytes_billed),
396859
+ slotTimeMs: num(row.total_slot_time_ms),
396860
+ executionTimeMs: null,
396861
+ cacheHit: bool(row.cache_hit)
396862
+ };
396863
+ }
396864
+ function snowflakeDatabaseQualifier(database) {
396865
+ return typeof database === "string" && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(database) ? database : "SNOWFLAKE";
396866
+ }
396867
+ function snowflakeCostSQL(queryTag, database) {
396868
+ const qualifier = `${snowflakeDatabaseQualifier(database)}.INFORMATION_SCHEMA`;
396869
+ return `
396870
+ SELECT QUERY_ID AS "job_id",
396871
+ QUERY_TEXT AS "query_text",
396872
+ BYTES_SCANNED AS "bytes_scanned",
396873
+ EXECUTION_TIME AS "execution_time_ms",
396874
+ ROWS_PRODUCED AS "rows_produced"
396875
+ FROM TABLE(${qualifier}.QUERY_HISTORY_BY_SESSION(
396876
+ RESULT_LIMIT => ${SNOWFLAKE_HISTORY_LIMIT}))
396877
+ WHERE QUERY_TAG = '${sqlLiteral(queryTag, "snowflake")}'
396878
+ AND EXECUTION_STATUS = 'SUCCESS'`;
396879
+ }
396880
+ function pickSnowflakeReadRow(rows, buildSQL) {
396881
+ const matches = rows.filter((row) => str(col(row, "query_text"))?.trim() === buildSQL.trim());
396882
+ return matches.length === 1 ? matches[0] : null;
396883
+ }
396884
+ function snowflakeReadCost(row) {
396885
+ const scanned = num(col(row, "bytes_scanned"));
396886
+ const produced = num(col(row, "rows_produced"));
396887
+ return {
396888
+ engine: "snowflake",
396889
+ jobId: str(col(row, "job_id")),
396890
+ parentJobId: null,
396891
+ bytesScanned: scanned,
396892
+ bytesBilled: null,
396893
+ slotTimeMs: null,
396894
+ executionTimeMs: num(col(row, "execution_time_ms")),
396895
+ cacheHit: scanned === null || produced === null ? null : scanned === 0 && produced > 0
396896
+ };
396897
+ }
396898
+ function col(row, key) {
396899
+ if (key in row)
396900
+ return row[key];
396901
+ const upper = key.toUpperCase();
396902
+ if (upper in row)
396903
+ return row[upper];
396904
+ const hit = Object.keys(row).find((k2) => k2.toLowerCase() === key);
396905
+ return hit === undefined ? undefined : row[hit];
396906
+ }
396907
+ function num(value) {
396908
+ if (value === null || value === undefined)
396909
+ return null;
396910
+ const n = Number(value);
396911
+ return Number.isFinite(n) ? n : null;
396912
+ }
396913
+ function str(value) {
396914
+ return typeof value === "string" && value.length > 0 ? value : null;
396915
+ }
396916
+ function bool(value) {
396917
+ if (typeof value === "boolean")
396918
+ return value;
396919
+ if (typeof value === "string") {
396920
+ if (value === "true")
396921
+ return true;
396922
+ if (value === "false")
396923
+ return false;
396924
+ }
396925
+ return null;
396926
+ }
396927
+
396928
+ // src/service/materialization_build_session.ts
396929
+ var sharedGateSession;
396930
+ var PASSTHROUGH_SOURCE_TYPES = [
396931
+ "bigquery",
396932
+ "snowflake",
396933
+ "postgres"
396934
+ ];
396935
+ var STORAGE_DESTINATION_TYPES = ["ducklake", "duckdb"];
396936
+ var STORAGE_TARGET_DIALECT = "duckdb";
396937
+ function wrapPassthrough(sourceType, handle, innerSQL) {
396938
+ const sql = `'${escapeSQL(innerSQL)}'`;
396939
+ const h4 = `'${escapeSQL(handle)}'`;
396940
+ switch (sourceType) {
396941
+ case "bigquery":
396942
+ return `SELECT * FROM bigquery_query(${h4}, ${sql})`;
396943
+ case "postgres":
396944
+ return `SELECT * FROM postgres_query(${h4}, ${sql})`;
396945
+ case "snowflake":
396946
+ return `SELECT * FROM snowflake_query(${sql}, ${h4})`;
396947
+ default: {
396948
+ const exhaustive = sourceType;
396949
+ throw new BadRequestError(`Unsupported passthrough source type: ${String(exhaustive)}`);
396950
+ }
396951
+ }
396952
+ }
396953
+ function orderByPartitionColumns(selectSQL, partitionColumns, dialect) {
396954
+ if (partitionColumns.length === 0) {
396955
+ return selectSQL;
396956
+ }
396957
+ const order = partitionColumns.map((name) => quoteIdentifier(name, dialect)).join(", ");
396958
+ const inner = selectSQL.replace(/[\s;]+$/, "");
396959
+ return `SELECT * FROM (
396960
+ ${inner}
396961
+ ) AS partitioned_build ORDER BY ${order}`;
396962
+ }
396963
+ async function tagSnowflakeSession(session, sourceType, handle, queryMetadata) {
396964
+ if (sourceType !== "snowflake")
396965
+ return;
396966
+ const sql = snowflakeSetQueryTagSQL(queryMetadata, handle);
396967
+ if (sql === undefined)
396968
+ return;
396969
+ try {
396970
+ await session.runSQL(sql);
396971
+ } catch (error2) {
396972
+ logger.warn("Could not tag the Snowflake build session", {
396973
+ error: error2 instanceof Error ? error2.message : String(error2)
396974
+ });
396975
+ recordAttributionSkipped("tag_failed");
396976
+ }
396977
+ }
396978
+ async function canSplitBigQueryRead(session, handle) {
396979
+ try {
396980
+ await session.runSQL(`SELECT job_id FROM bigquery_jobs('${escapeSQL(handle)}', maxResults := 1)`);
396981
+ return true;
396982
+ } catch (error2) {
396983
+ logger.warn("Cannot list BigQuery jobs for this connection, so this build's " + "warehouse read will not be labelled or costed", {
396984
+ error: error2 instanceof Error ? error2.message : String(error2)
396985
+ });
396986
+ recordAttributionSkipped("job_listing_unavailable");
396987
+ return false;
396988
+ }
396989
+ }
396990
+
396991
+ class BilledReadNotCapturedError extends Error {
396992
+ constructor(message, options) {
396993
+ super(message, options);
396994
+ this.name = "BilledReadNotCapturedError";
396995
+ }
396996
+ }
396997
+ var BIGQUERY_LOOKUP_ATTEMPTS = 3;
396998
+ var BIGQUERY_LOOKUP_BACKOFF_MS = 250;
396999
+ async function withBigQueryLookupRetry(work, parentJobId) {
397000
+ let lastError;
397001
+ for (let attempt = 1;attempt <= BIGQUERY_LOOKUP_ATTEMPTS; attempt++) {
397002
+ try {
397003
+ return await work();
397004
+ } catch (error2) {
397005
+ lastError = error2;
397006
+ if (attempt < BIGQUERY_LOOKUP_ATTEMPTS) {
397007
+ logger.warn("Could not read the BigQuery job record for a build's read; retrying", {
397008
+ attempt,
397009
+ of: BIGQUERY_LOOKUP_ATTEMPTS,
397010
+ parentJobId,
397011
+ error: error2 instanceof Error ? error2.message : String(error2)
397012
+ });
397013
+ await new Promise((resolve7) => setTimeout(resolve7, BIGQUERY_LOOKUP_BACKOFF_MS * attempt));
397014
+ }
397015
+ }
397016
+ }
397017
+ 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 });
397018
+ }
397019
+ async function issuePassthroughRead(session, sourceType, handle, buildSQL, queryMetadata) {
397020
+ const label = sourceType === "bigquery" ? bigQueryQueryLabelValue(queryMetadata) : undefined;
397021
+ if (label === undefined || !await canSplitBigQueryRead(session, handle)) {
397022
+ return {
397023
+ selectSQL: wrapPassthrough(sourceType, handle, buildSQL),
397024
+ jobId: null,
397025
+ cost: null
397026
+ };
397027
+ }
397028
+ const script = `SET @@query_label = "${label}";
397029
+ ${buildSQL};`;
397030
+ const executed = await session.runSQL(`SELECT * FROM bigquery_execute('${escapeSQL(handle)}', '${escapeSQL(script)}')`);
397031
+ const parentJobId = firstColumn(executed, "job_id");
397032
+ if (parentJobId === null) {
397033
+ throw new BilledReadNotCapturedError("bigquery_execute returned no job_id for the build's read, so its " + "result table cannot be located");
397034
+ }
397035
+ const located = await withBigQueryLookupRetry(async () => {
397036
+ const child = await session.runSQL(`
397037
+ SELECT job_id,
397038
+ json_extract_string(configuration, '$.query.destinationTable.projectId') AS project,
397039
+ json_extract_string(configuration, '$.query.destinationTable.datasetId') AS dataset,
397040
+ json_extract_string(configuration, '$.query.destinationTable.tableId') AS table,
397041
+ ${BIGQUERY_COST_COLUMNS}
397042
+ FROM (SELECT * FROM bigquery_jobs('${escapeSQL(handle)}',
397043
+ parentJobId := '${escapeSQL(parentJobId)}')
397044
+ WHERE job_type = 'QUERY')
397045
+ ORDER BY creation_time DESC`);
397046
+ const row = resultRows(child).find((r3) => str2(r3.dataset) && str2(r3.table));
397047
+ if (row === undefined) {
397048
+ throw new Error(`no child job with a result table under parent ${parentJobId}`);
397049
+ }
397050
+ return row;
397051
+ }, parentJobId);
397052
+ const path21 = [
397053
+ str2(located.project) ?? handle,
397054
+ str2(located.dataset),
397055
+ str2(located.table)
397056
+ ].join(".");
397057
+ const jobId = str2(located.job_id);
397058
+ return {
397059
+ selectSQL: `SELECT * FROM bigquery_scan('${escapeSQL(path21)}')`,
397060
+ jobId,
397061
+ cost: bigQueryReadCost(located, jobId, parentJobId)
397062
+ };
397063
+ }
397064
+ async function snowflakeReadCostAfterBuild(session, sourceType, handle, buildSQL, queryMetadata, database) {
397065
+ if (sourceType !== "snowflake")
397066
+ return null;
397067
+ const tag = snowflakeQueryTagValue(queryMetadata);
397068
+ if (tag === undefined)
397069
+ return null;
397070
+ try {
397071
+ const costResult = await session.runSQL(passthroughSnowflake(snowflakeCostSQL(tag, database), handle));
397072
+ const candidates = resultRows(costResult);
397073
+ const row = pickSnowflakeReadRow(candidates, buildSQL);
397074
+ if (row === null) {
397075
+ recordAttributionSkipped(candidates.length === 0 ? "read_row_not_found" : "read_row_ambiguous");
397076
+ return null;
397077
+ }
397078
+ return snowflakeReadCost(row);
397079
+ } catch (error2) {
397080
+ logger.warn("Could not read back what the Snowflake build read cost", {
397081
+ error: error2 instanceof Error ? error2.message : String(error2)
397082
+ });
397083
+ recordAttributionSkipped("cost_query_failed");
397084
+ return null;
397085
+ }
397086
+ }
397087
+ function passthroughSnowflake(innerSQL, handle) {
397088
+ return `SELECT * FROM snowflake_query('${escapeSQL(innerSQL)}', '${escapeSQL(handle)}')`;
397089
+ }
397090
+ function resultRows(result) {
397091
+ if (Array.isArray(result))
397092
+ return result;
397093
+ const rows = result?.rows;
397094
+ return Array.isArray(rows) ? rows : [];
397095
+ }
397096
+ function firstColumn(result, column) {
397097
+ const rows = resultRows(result);
397098
+ return rows.length > 0 ? str2(rows[0][column]) : null;
397099
+ }
397100
+ function str2(value) {
397101
+ return typeof value === "string" && value.length > 0 ? value : null;
397102
+ }
397103
+ async function clearSnowflakeSessionTag(session, sourceType, handle) {
397104
+ if (sourceType !== "snowflake" || handle === undefined)
397105
+ return;
397106
+ try {
397107
+ await session.runSQL(passthroughSnowflake("ALTER SESSION UNSET QUERY_TAG", handle));
397108
+ } catch (error2) {
397109
+ logger.warn("Could not clear the Snowflake build session's query tag", {
397110
+ error: error2 instanceof Error ? error2.message : String(error2)
397111
+ });
397112
+ }
397113
+ }
397114
+ function passthroughSourceType(sourceConnection) {
397115
+ const type = sourceConnection.type;
397116
+ if (PASSTHROUGH_SOURCE_TYPES.includes(type ?? "")) {
397117
+ return type;
397118
+ }
397119
+ 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.`);
397120
+ }
397121
+ function createIsolatedBuildSession(sessionName) {
397122
+ const workDir = mkdtempSync2(path20.join(os5.tmpdir(), "malloy-build-"));
397123
+ let session;
397124
+ try {
397125
+ session = new DuckDBConnection4(sessionName, ":memory:", workDir, {
397126
+ rowLimit: Number.MAX_SAFE_INTEGER
397127
+ });
397128
+ } catch (err) {
397129
+ rmSync(workDir, { recursive: true, force: true });
397130
+ throw err;
397131
+ }
397132
+ const dispose = async () => {
397133
+ const nodeConnection = session.connection;
397134
+ try {
397135
+ await session.close();
397136
+ } catch (err) {
397137
+ logger.warn("Failed to close build session (leaked session)", {
397138
+ sessionName,
397139
+ error: err instanceof Error ? err.message : String(err)
397140
+ });
397141
+ }
397142
+ try {
397143
+ nodeConnection?.disconnectSync?.();
397144
+ } catch (err) {
397145
+ logger.warn("Failed to disconnect build session (leaked connection)", {
397146
+ sessionName,
397147
+ error: err instanceof Error ? err.message : String(err)
397148
+ });
397149
+ }
397150
+ try {
397151
+ rmSync(workDir, { recursive: true, force: true });
397152
+ } catch (err) {
397153
+ logger.warn("Failed to remove build session working directory", {
397154
+ sessionName,
397155
+ workDir,
397156
+ error: err instanceof Error ? err.message : String(err)
397157
+ });
397158
+ }
397159
+ };
397160
+ return { session, dispose, workDir };
397161
+ }
397162
+ async function pinSessionToUTC(session) {
397163
+ await session.runSQL("SET TimeZone='UTC'");
397164
+ }
397165
+ async function buildSourceIntoStorage(params) {
397166
+ const {
397167
+ destinationName,
397168
+ destinationConnection,
397169
+ sourceConnection,
397170
+ buildSQL,
397171
+ physicalTableName,
397172
+ environmentPath,
397173
+ queryMetadata
397174
+ } = params;
397175
+ const federate = params.deps?.federate ?? federateSourceForPassthrough;
397176
+ const read = params.deps?.read ?? issuePassthroughRead;
397177
+ assertSupportedDestination(destinationName, destinationConnection);
397178
+ const sourceType = passthroughSourceType(sourceConnection);
397179
+ const { session, dispose, workDir } = createIsolatedBuildSession(`build_${destinationName}`);
397180
+ let federatedHandle;
397181
+ let federatedClose;
397182
+ try {
397183
+ await applySessionResourceLimits(session, { tempDirectory: workDir });
397184
+ await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
397185
+ if (destinationConnection.type === "ducklake") {
397186
+ await session.runSQL("SET ducklake_default_data_inlining_row_limit=0");
397187
+ }
397188
+ await pinSessionToUTC(session);
397189
+ const federated = await federate(session, sourceType, sourceFederationConfig(sourceConnection));
397190
+ federatedClose = federated.close;
397191
+ await tagSnowflakeSession(session, sourceType, federated.handle, queryMetadata);
397192
+ const target = quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT);
397193
+ if (params.incremental) {
397194
+ const partitioned = (params.partitionColumns?.length ?? 0) > 0;
397195
+ const flushThreshold = getPartitionedWriteFlushThreshold();
397196
+ const boundDelta = partitioned && flushThreshold !== undefined;
397197
+ if (boundDelta) {
397198
+ await session.runSQL(`SET partitioned_write_flush_threshold = ${flushThreshold}`);
397199
+ await session.runSQL("SET threads = 1");
397200
+ }
397201
+ let refreshed;
397202
+ try {
397203
+ refreshed = await params.incremental.plan({
397204
+ session,
397205
+ sourceType,
397206
+ handle: federated.handle,
397207
+ quotedTablePath: target
397208
+ });
397209
+ } finally {
397210
+ if (boundDelta) {
397211
+ try {
397212
+ await session.runSQL("RESET threads");
397213
+ await session.runSQL("RESET partitioned_write_flush_threshold");
397214
+ } catch {}
397215
+ }
397216
+ }
397217
+ if (refreshed) {
397218
+ return {
397219
+ storageDestinationName: destinationName,
397220
+ schema: await describeTable(session, target),
397221
+ readCost: refreshed.readCost,
397222
+ refresh: refreshed
397223
+ };
397224
+ }
397225
+ }
397226
+ const passthrough = await read(session, sourceType, federated.handle, buildSQL, queryMetadata);
397227
+ const schema = await createTableAndDescribe(session, target, passthrough.selectSQL, params.partitionColumns ?? [], { sourceType });
397228
+ const seededThrough = await params.incremental?.afterSeed({
397229
+ session,
397230
+ quotedTablePath: target
397231
+ });
397232
+ return {
397233
+ seededThrough,
397234
+ storageDestinationName: destinationName,
397235
+ schema,
397236
+ readCost: passthrough.cost ?? await snowflakeReadCostAfterBuild(session, sourceType, federated.handle, buildSQL, queryMetadata, sourceConnection.snowflakeConnection?.database)
397237
+ };
397238
+ } finally {
397239
+ await clearSnowflakeSessionTag(session, sourceType, federatedHandle);
397240
+ await dispose();
397241
+ if (federatedClose) {
397242
+ await federatedClose().catch((e2) => logger.warn(`Failed to close the SSH proxy a storage build federated through: ${String(e2)}`));
397243
+ }
397244
+ }
397245
+ }
397246
+ async function buildDownstreamIntoStorage(params) {
397247
+ const {
397248
+ destinationName,
397249
+ destinationConnection,
397250
+ transientModel,
397251
+ downstreamName,
397252
+ virtualMap,
397253
+ physicalTableName,
397254
+ environmentPath
397255
+ } = params;
397256
+ assertSupportedDestination(destinationName, destinationConnection);
397257
+ const { session, dispose, workDir } = createIsolatedBuildSession(`build_${destinationName}`);
397258
+ try {
397259
+ await applySessionResourceLimits(session, { tempDirectory: workDir });
397260
+ await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
397261
+ if (destinationConnection.type === "ducklake") {
397262
+ await session.runSQL("SET ducklake_default_data_inlining_row_limit=0");
397263
+ }
397264
+ await pinSessionToUTC(session);
397265
+ const root = "file:///chained-build/";
397266
+ const url3 = `${root}m.malloy`;
397267
+ const runtime = new Runtime4({
397268
+ urlReader: new InMemoryURLReader3(new Map([[url3, transientModel]])),
397269
+ connections: new FixedConnectionMap3(new Map([[destinationName, session]]), destinationName)
397270
+ });
397271
+ let model;
397272
+ try {
397273
+ model = await runtime.loadModel(new URL(url3), { importBaseURL: new URL(root) }).getModel();
397274
+ } catch (err) {
397275
+ throw new MaterializationEligibilityError({
397276
+ message: `Chained build model did not compile over the rebound parents: ${errMessage(err)}`
397277
+ });
397278
+ }
397279
+ const plan = model.getBuildPlan();
397280
+ let downstream;
397281
+ for (const ps2 of Object.values(plan.sources)) {
397282
+ if (ps2.name === downstreamName) {
397283
+ downstream = ps2;
397284
+ break;
397285
+ }
397286
+ }
397287
+ if (!downstream) {
397288
+ throw new MaterializationEligibilityError({
397289
+ message: `Chained build model did not yield a persist source named ` + `'${downstreamName}' (the downstream references something the ` + `rebound parents don't provide).`
397290
+ });
397291
+ }
397292
+ const sql = projectToPublicColumns(downstream, downstream.getSQL({ virtualMap }));
397293
+ const target = quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT);
397294
+ const schema = await createTableAndDescribe(session, target, sql, params.partitionColumns ?? []);
397295
+ return {
397296
+ storageDestinationName: destinationName,
397297
+ schema,
397298
+ readCost: null,
397299
+ upstreamReuse: "reused"
397300
+ };
397301
+ } finally {
397302
+ await dispose();
397303
+ }
397304
+ }
397305
+ async function assertStorageServeShapeCompiles(params) {
397306
+ const { destinationName, sourceName, virtualHandle, physicalTableName } = params;
397307
+ const binding = {
397308
+ sourceName,
397309
+ destinationName,
397310
+ virtualHandle,
397311
+ tablePath: `${destinationName}.${physicalTableName}`,
397312
+ schema: params.schema.filter((c3) => c3.name && c3.type).map((c3) => ({ name: c3.name, type: c3.type }))
397313
+ };
397314
+ sharedGateSession ??= createIsolatedBuildSession("gate_shared").session;
397315
+ await applySessionResourceLimits(sharedGateSession);
397316
+ await assertServesInDuckDB(sourceName, binding, new FixedConnectionMap3(new Map([[destinationName, sharedGateSession]]), destinationName));
397317
+ }
397318
+ function dropStorageTableSql(destinationName, physicalTableName) {
397319
+ return `DROP TABLE IF EXISTS ${quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT)}`;
397320
+ }
397321
+ async function dropStorageTable(params) {
397322
+ const {
397323
+ destinationName,
397324
+ destinationConnection,
397325
+ physicalTableName,
397326
+ environmentPath
397327
+ } = params;
397328
+ assertSupportedDestination(destinationName, destinationConnection);
397329
+ const { session, dispose, workDir } = createIsolatedBuildSession(`gc_${destinationName}`);
397330
+ try {
397331
+ await applySessionResourceLimits(session, { tempDirectory: workDir });
397332
+ await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
397333
+ await session.runSQL(dropStorageTableSql(destinationName, physicalTableName));
397334
+ } finally {
397335
+ await dispose();
397336
+ }
397337
+ }
397338
+ async function listStorageTables(params) {
397339
+ const {
397340
+ destinationName,
397341
+ destinationConnection,
397342
+ schemaName,
397343
+ environmentPath
397344
+ } = params;
397345
+ assertSupportedDestination(destinationName, destinationConnection);
397346
+ const { session, dispose, workDir } = createIsolatedBuildSession(`list_${destinationName}`);
397347
+ try {
397348
+ await applySessionResourceLimits(session, { tempDirectory: workDir });
397349
+ if (destinationConnection.type === "ducklake") {
397350
+ const cfg = requireDuckLakeConfig(destinationName, destinationConnection);
397351
+ await attachDuckLakeReadOnly(session, destinationName, cfg);
397352
+ } else {
397353
+ const destinationRoot = storageDestinationRoot(environmentPath);
397354
+ const fileName = `${destinationName}.duckdb`;
397355
+ if (!existsSync3(destinationRoot) || !readdirSync3(destinationRoot).includes(fileName)) {
397356
+ return [];
397357
+ }
397358
+ const dbPath = path20.join(destinationRoot, fileName);
397359
+ await session.runSQL(`ATTACH '${escapeSQL(dbPath)}' AS ${quoteIdentifier(destinationName, STORAGE_TARGET_DIALECT)} (READ_ONLY)`);
397360
+ }
397361
+ 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`));
397362
+ return rows.map((row) => String(row.table_name));
397363
+ } finally {
397364
+ await dispose();
397365
+ }
397366
+ }
397367
+ async function cleanupStorageFiles(params) {
397368
+ const {
397369
+ destinationName,
397370
+ destinationConnection,
397371
+ snapshotsOlderThan,
397372
+ filesOlderThan
397373
+ } = params;
397374
+ if (destinationConnection.type !== "ducklake") {
397375
+ throw new BadRequestError(`Storage destination '${destinationName}' is type ` + `'${destinationConnection.type ?? ""}'; file cleanup applies only to ` + `'ducklake' destinations.`);
397376
+ }
397377
+ const cfg = requireDuckLakeConfig(destinationName, destinationConnection);
397378
+ const { session, dispose, workDir } = createIsolatedBuildSession(`cleanup_${destinationName}`);
397379
+ try {
397380
+ await applySessionResourceLimits(session, { tempDirectory: workDir });
397381
+ await attachDuckLakeReadWrite(session, destinationName, cfg);
397382
+ const catalog = escapeSQL(destinationName);
397383
+ const expired = resultRows(await session.runSQL(`CALL ducklake_expire_snapshots('${catalog}', older_than => ` + `TIMESTAMPTZ '${snapshotsOlderThan.toISOString()}')`));
397384
+ const deleted = resultRows(await session.runSQL(`CALL ducklake_cleanup_old_files('${catalog}', older_than => ` + `TIMESTAMPTZ '${filesOlderThan.toISOString()}')`));
397385
+ return { expiredSnapshots: expired.length, deletedFiles: deleted.length };
397386
+ } finally {
397387
+ await dispose();
397388
+ }
397389
+ }
397390
+ function requireDuckLakeConfig(destinationName, destinationConnection) {
397391
+ const cfg = destinationConnection.ducklakeConnection;
397392
+ if (!cfg) {
397393
+ throw new BadRequestError(`Storage destination '${destinationName}' is type 'ducklake' but ` + `has no ducklakeConnection config.`);
397394
+ }
397395
+ return cfg;
397396
+ }
397397
+ function destinationDatabasePath(environmentPath, destinationName) {
397398
+ return path20.join(storageDestinationRoot(environmentPath), `${destinationName}.duckdb`);
397399
+ }
397400
+ async function attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath) {
397401
+ if (destinationConnection.type === "ducklake") {
397402
+ const cfg = requireDuckLakeConfig(destinationName, destinationConnection);
397403
+ await attachDuckLakeReadWrite(session, destinationName, cfg);
397404
+ return;
397405
+ }
397406
+ mkdirSync3(storageDestinationRoot(environmentPath), { recursive: true });
397407
+ const dbPath = destinationDatabasePath(environmentPath, destinationName);
397408
+ await session.runSQL(`ATTACH '${escapeSQL(dbPath)}' AS ${quoteIdentifier(destinationName, STORAGE_TARGET_DIALECT)}`);
397409
+ }
397410
+ function assertSupportedDestination(destinationName, destinationConnection) {
397411
+ const type = destinationConnection.type ?? "";
397412
+ if (!STORAGE_DESTINATION_TYPES.includes(type)) {
397413
+ throw new BadRequestError(`Storage destination '${destinationName}' is type '${type}', but ` + `'storage=' destinations must be one of ` + `${STORAGE_DESTINATION_TYPES.join(", ")}.`);
397414
+ }
397415
+ }
397416
+ function sourceFederationConfig(sourceConnection) {
397417
+ return {
397418
+ name: sourceConnection.name ?? "src",
397419
+ bigqueryConnection: sourceConnection.bigqueryConnection,
397420
+ snowflakeConnection: sourceConnection.snowflakeConnection,
397421
+ postgresConnection: sourceConnection.postgresConnection,
397422
+ proxy: sourceConnection.proxy
397423
+ };
397424
+ }
397425
+ async function createTableAndDescribe(session, quotedTablePath, selectSQL, partitionColumns = [], options = {}) {
397426
+ if (partitionColumns.length === 0) {
397427
+ await session.runSQL(`CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL})`);
397428
+ return await describeOrDrop(session, quotedTablePath);
397429
+ }
397430
+ const columns = partitionColumns.map((name) => quoteIdentifier(name, STORAGE_TARGET_DIALECT)).join(", ");
397431
+ const flushThreshold = getPartitionedWriteFlushThreshold();
397432
+ const bounded = options.sourceType !== undefined && flushThreshold !== undefined;
397433
+ if (bounded) {
397434
+ await session.runSQL(`SET partitioned_write_flush_threshold = ${flushThreshold}`);
397435
+ await session.runSQL("SET threads = 1");
397436
+ }
397437
+ let schema;
397438
+ await session.runSQL("BEGIN TRANSACTION");
397439
+ try {
397440
+ await session.runSQL(`CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL}) WITH NO DATA`);
397441
+ await session.runSQL(`ALTER TABLE ${quotedTablePath} SET PARTITIONED BY (${columns})`);
397442
+ const inserted = bounded ? orderByPartitionColumns(selectSQL, partitionColumns, STORAGE_TARGET_DIALECT) : selectSQL;
397443
+ await session.runSQL(`INSERT INTO ${quotedTablePath} (${inserted})`);
397444
+ schema = await describeTable(session, quotedTablePath);
397445
+ await session.runSQL("COMMIT");
397446
+ } catch (buildErr) {
397447
+ try {
397448
+ await session.runSQL("ROLLBACK");
397449
+ } catch (rollbackErr) {
397450
+ logger.warn("Failed to roll back a partitioned storage build; the served " + "generation may have been replaced", {
397451
+ table: quotedTablePath,
397452
+ error: errMessage(rollbackErr),
397453
+ cause: errMessage(buildErr)
397454
+ });
397455
+ }
397456
+ throw buildErr;
397457
+ } finally {
397458
+ if (bounded) {
397459
+ try {
397460
+ await session.runSQL("RESET threads");
397461
+ } catch {}
397462
+ }
397463
+ }
397464
+ return schema;
397465
+ }
397466
+ async function describeOrDrop(session, quotedTablePath) {
397467
+ try {
397468
+ return await describeTable(session, quotedTablePath);
397469
+ } catch (describeErr) {
397470
+ await dropStranded(session, quotedTablePath, describeErr);
397471
+ throw describeErr;
397472
+ }
397473
+ }
397474
+ async function dropStranded(session, quotedTablePath, cause) {
397475
+ try {
397476
+ await session.runSQL(`DROP TABLE IF EXISTS ${quotedTablePath}`);
397477
+ } catch (dropErr) {
397478
+ logger.warn("Failed to drop a storage table stranded by a failed build " + "(physical leak)", {
397479
+ table: quotedTablePath,
397480
+ error: errMessage(dropErr),
397481
+ cause: errMessage(cause)
397482
+ });
397483
+ }
397484
+ }
397485
+ async function describeTable(session, quotedTablePath) {
397486
+ const result = await session.runSQL(`DESCRIBE ${quotedTablePath}`);
397487
+ const rows = Array.isArray(result) ? result : result.rows ?? [];
397488
+ const columns = [];
397489
+ for (const row of rows) {
397490
+ const name = row.column_name;
397491
+ const type = row.column_type;
397492
+ if (typeof name === "string" && typeof type === "string") {
397493
+ columns.push({ name, type });
397494
+ }
397495
+ }
397496
+ return columns;
397497
+ }
397498
+
397499
+ // src/controller/storage_destination.controller.ts
397500
+ var IDENTIFIER = /^[a-zA-Z0-9_-]+$/;
397501
+
397502
+ class StorageDestinationController {
397503
+ environmentStore;
397504
+ constructor(environmentStore) {
397505
+ this.environmentStore = environmentStore;
397506
+ }
397507
+ async listTables(environmentName, destinationName, schemaName) {
397508
+ assertIdentifier("schema", schemaName);
397509
+ const { environment, destination } = await this.resolve(environmentName, destinationName);
397510
+ const names = await listStorageTables({
397511
+ destinationName,
397512
+ destinationConnection: destination,
397513
+ schemaName,
397514
+ environmentPath: environment.getEnvironmentPath()
397515
+ });
397516
+ return names.map((name) => ({ name }));
397517
+ }
397518
+ async dropTable(environmentName, destinationName, schemaName, tableName) {
397519
+ assertIdentifier("schema", schemaName);
397520
+ assertIdentifier("table", tableName);
397521
+ const { environment, destination } = await this.resolve(environmentName, destinationName);
397522
+ try {
397523
+ await dropStorageTable({
397524
+ destinationName,
397525
+ destinationConnection: destination,
397526
+ physicalTableName: schemaName === "main" ? tableName : `${schemaName}.${tableName}`,
397527
+ environmentPath: environment.getEnvironmentPath()
397528
+ });
397529
+ } catch (error2) {
397530
+ recordDropTables("failure", "storage");
397531
+ throw error2;
397532
+ }
397533
+ recordDropTables("success", "storage");
397534
+ logger.info("Dropped a storage destination table", {
397535
+ environmentName,
397536
+ destinationName,
397537
+ schemaName,
397538
+ tableName
397539
+ });
397540
+ }
397541
+ async createFileCleanup(environmentName, destinationName, body) {
397542
+ const snapshotsOlderThan = requireTimestamp(body, "snapshotsOlderThan");
397543
+ const filesOlderThan = requireTimestamp(body, "filesOlderThan");
397544
+ const { destination } = await this.resolve(environmentName, destinationName);
397545
+ const result = await cleanupStorageFiles({
397546
+ destinationName,
397547
+ destinationConnection: destination,
397548
+ snapshotsOlderThan,
397549
+ filesOlderThan
397550
+ });
397551
+ logger.info("Cleaned up a storage destination's retired files", {
397552
+ environmentName,
397553
+ destinationName,
397554
+ snapshotsOlderThan: snapshotsOlderThan.toISOString(),
397555
+ filesOlderThan: filesOlderThan.toISOString(),
397556
+ ...result
397557
+ });
397558
+ return {
397559
+ snapshotsOlderThan: snapshotsOlderThan.toISOString(),
397560
+ filesOlderThan: filesOlderThan.toISOString(),
397561
+ ...result
397562
+ };
397563
+ }
397564
+ async resolve(environmentName, destinationName) {
397565
+ assertIdentifier("storage destination", destinationName);
397566
+ const environment = await this.environmentStore.getEnvironment(environmentName, false);
397567
+ try {
397568
+ return {
397569
+ environment,
397570
+ destination: environment.getStorageDestination(destinationName)
397571
+ };
397572
+ } catch (error2) {
397573
+ if (error2 instanceof DestinationNotFoundError) {
397574
+ throw new StorageDestinationNotFoundError(`Storage destination '${destinationName}' is not configured on ` + `environment '${environmentName}'.`);
397575
+ }
397576
+ throw error2;
397577
+ }
397578
+ }
397579
+ }
397580
+ function assertIdentifier(what, value) {
397581
+ if (!IDENTIFIER.test(value)) {
397582
+ throw new BadRequestError(`Invalid ${what} name '${value}': expected letters, digits, '_' or '-'.`);
397583
+ }
397584
+ }
397585
+ function requireTimestamp(body, field) {
397586
+ const raw = body?.[field];
397587
+ const parsed = typeof raw === "string" ? new Date(raw) : undefined;
397588
+ if (!parsed || Number.isNaN(parsed.getTime())) {
397589
+ throw new BadRequestError(`'${field}' is required and must be an RFC 3339 date-time.`);
397590
+ }
397591
+ return parsed;
397592
+ }
397593
+
396449
397594
  // src/controller/theme.controller.ts
396450
397595
  init_errors();
396451
397596
  class ThemeController {
@@ -396605,11 +397750,11 @@ function normalizeObjectSchema(schema) {
396605
397750
  }
396606
397751
  return;
396607
397752
  }
396608
- function getDotPath(path20) {
396609
- if (path20.length === 0) {
397753
+ function getDotPath(path21) {
397754
+ if (path21.length === 0) {
396610
397755
  return "object root";
396611
397756
  }
396612
- return path20.reduce((acc, seg, index) => {
397757
+ return path21.reduce((acc, seg, index) => {
396613
397758
  if (index === 0) {
396614
397759
  return String(seg);
396615
397760
  }
@@ -401410,8 +402555,8 @@ function initializeMcpServer(environmentStore) {
401410
402555
 
401411
402556
  // src/mcp_config.ts
401412
402557
  import * as fs17 from "fs";
401413
- import * as os5 from "os";
401414
- import * as path20 from "path";
402558
+ import * as os6 from "os";
402559
+ import * as path21 from "path";
401415
402560
  init_logger();
401416
402561
  var MCP_CONFIG_FILENAME = ".mcp.json";
401417
402562
  function malloyServer(endpoint) {
@@ -401436,11 +402581,11 @@ function mcpEndpoint(host, port) {
401436
402581
  return `http://${host}:${port}/mcp`;
401437
402582
  }
401438
402583
  function findGitWorkTreeRoot(dir) {
401439
- let current = path20.resolve(dir);
402584
+ let current = path21.resolve(dir);
401440
402585
  for (;; ) {
401441
- if (fs17.existsSync(path20.join(current, ".git")))
402586
+ if (fs17.existsSync(path21.join(current, ".git")))
401442
402587
  return current;
401443
- const parent = path20.dirname(current);
402588
+ const parent = path21.dirname(current);
401444
402589
  if (parent === current)
401445
402590
  return;
401446
402591
  current = parent;
@@ -401463,7 +402608,7 @@ function mcpConfigEnabled() {
401463
402608
  }
401464
402609
  function ensureMcpConfig(options) {
401465
402610
  const { dir, endpoint, requestedPort, boundPort, homeDir } = options;
401466
- const file = path20.join(dir, MCP_CONFIG_FILENAME);
402611
+ const file = path21.join(dir, MCP_CONFIG_FILENAME);
401467
402612
  try {
401468
402613
  const existing = (() => {
401469
402614
  try {
@@ -401490,18 +402635,18 @@ function ensureMcpConfig(options) {
401490
402635
  try {
401491
402636
  return fs17.realpathSync(p2);
401492
402637
  } catch {
401493
- return path20.resolve(p2);
402638
+ return path21.resolve(p2);
401494
402639
  }
401495
402640
  };
401496
- if (realish(dir) === realish(homeDir ?? os5.homedir())) {
402641
+ if (realish(dir) === realish(homeDir ?? os6.homedir())) {
401497
402642
  return { action: "skipped-home", dir, endpoint, staleConfig };
401498
402643
  }
401499
- if (path20.resolve(dir) === path20.parse(path20.resolve(dir)).root) {
402644
+ if (path21.resolve(dir) === path21.parse(path21.resolve(dir)).root) {
401500
402645
  return { action: "skipped-root", dir, endpoint, staleConfig };
401501
402646
  }
401502
402647
  const gitRoot = findGitWorkTreeRoot(dir);
401503
402648
  if (gitRoot !== undefined) {
401504
- const rootCandidate = path20.join(gitRoot, MCP_CONFIG_FILENAME);
402649
+ const rootCandidate = path21.join(gitRoot, MCP_CONFIG_FILENAME);
401505
402650
  return {
401506
402651
  action: "skipped-git",
401507
402652
  dir,
@@ -402593,654 +403738,6 @@ function reportDeltaApplied(params) {
402593
403738
  logger.info("Applied an incremental delta", params);
402594
403739
  }
402595
403740
 
402596
- // src/service/materialization_build_session.ts
402597
- init_errors();
402598
- init_logger();
402599
- import { DuckDBConnection as DuckDBConnection4 } from "@malloydata/db-duckdb";
402600
- import {
402601
- FixedConnectionMap as FixedConnectionMap3,
402602
- InMemoryURLReader as InMemoryURLReader3,
402603
- Runtime as Runtime4
402604
- } from "@malloydata/malloy";
402605
- import { mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, rmSync } from "node:fs";
402606
- import os6 from "node:os";
402607
- import path21 from "node:path";
402608
-
402609
- // src/service/build_query_tag.ts
402610
- var MAX_QUERY_TAG_LENGTH = 2000;
402611
- var BQ_MAX_LEN = 63;
402612
- function snowflakeQueryTagValue(metadata) {
402613
- if (metadata === undefined || Object.keys(metadata).length === 0) {
402614
- return;
402615
- }
402616
- const tag = JSON.stringify(metadata);
402617
- return tag.length > MAX_QUERY_TAG_LENGTH ? undefined : tag;
402618
- }
402619
- function snowflakeSetQueryTagSQL(metadata, secretName) {
402620
- const tag = snowflakeQueryTagValue(metadata);
402621
- if (tag === undefined)
402622
- return;
402623
- const inner = `ALTER SESSION SET QUERY_TAG = '${sqlLiteral(tag, "snowflake")}'`;
402624
- return `SELECT * FROM snowflake_query('${inner.replace(/'/g, "''")}', '${secretName.replace(/'/g, "''")}')`;
402625
- }
402626
- function sanitizeBigQueryValue(value) {
402627
- return value.toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, BQ_MAX_LEN);
402628
- }
402629
- function sanitizeBigQueryKey(key) {
402630
- const sanitized = sanitizeBigQueryValue(key);
402631
- return /^[a-z]/.test(sanitized) ? sanitized : undefined;
402632
- }
402633
- function bigQueryQueryLabelValue(metadata) {
402634
- if (metadata === undefined)
402635
- return;
402636
- const rendered = new Map;
402637
- for (const [key, value] of Object.entries(metadata)) {
402638
- const sanitizedKey = sanitizeBigQueryKey(key);
402639
- if (sanitizedKey === undefined)
402640
- continue;
402641
- rendered.set(sanitizedKey, sanitizeBigQueryValue(value));
402642
- }
402643
- if (rendered.size === 0)
402644
- return;
402645
- return [...rendered].map(([key, value]) => `${key}:${value}`).join(",");
402646
- }
402647
-
402648
- // src/service/build_read_cost.ts
402649
- var SNOWFLAKE_HISTORY_LIMIT = 1e4;
402650
- var BIGQUERY_COST_COLUMNS = `
402651
- total_slot_time_ms,
402652
- json_extract_string(statistics, '$.query.totalBytesProcessed') AS bytes_processed,
402653
- json_extract_string(statistics, '$.query.totalBytesBilled') AS bytes_billed,
402654
- json_extract_string(statistics, '$.query.cacheHit') AS cache_hit`;
402655
- function bigQueryReadCost(row, jobId, parentJobId) {
402656
- return {
402657
- engine: "bigquery",
402658
- jobId,
402659
- parentJobId,
402660
- bytesScanned: num(row.bytes_processed),
402661
- bytesBilled: num(row.bytes_billed),
402662
- slotTimeMs: num(row.total_slot_time_ms),
402663
- executionTimeMs: null,
402664
- cacheHit: bool(row.cache_hit)
402665
- };
402666
- }
402667
- function snowflakeDatabaseQualifier(database) {
402668
- return typeof database === "string" && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(database) ? database : "SNOWFLAKE";
402669
- }
402670
- function snowflakeCostSQL(queryTag, database) {
402671
- const qualifier = `${snowflakeDatabaseQualifier(database)}.INFORMATION_SCHEMA`;
402672
- return `
402673
- SELECT QUERY_ID AS "job_id",
402674
- QUERY_TEXT AS "query_text",
402675
- BYTES_SCANNED AS "bytes_scanned",
402676
- EXECUTION_TIME AS "execution_time_ms",
402677
- ROWS_PRODUCED AS "rows_produced"
402678
- FROM TABLE(${qualifier}.QUERY_HISTORY_BY_SESSION(
402679
- RESULT_LIMIT => ${SNOWFLAKE_HISTORY_LIMIT}))
402680
- WHERE QUERY_TAG = '${sqlLiteral(queryTag, "snowflake")}'
402681
- AND EXECUTION_STATUS = 'SUCCESS'`;
402682
- }
402683
- function pickSnowflakeReadRow(rows, buildSQL) {
402684
- const matches = rows.filter((row) => str(col(row, "query_text"))?.trim() === buildSQL.trim());
402685
- return matches.length === 1 ? matches[0] : null;
402686
- }
402687
- function snowflakeReadCost(row) {
402688
- const scanned = num(col(row, "bytes_scanned"));
402689
- const produced = num(col(row, "rows_produced"));
402690
- return {
402691
- engine: "snowflake",
402692
- jobId: str(col(row, "job_id")),
402693
- parentJobId: null,
402694
- bytesScanned: scanned,
402695
- bytesBilled: null,
402696
- slotTimeMs: null,
402697
- executionTimeMs: num(col(row, "execution_time_ms")),
402698
- cacheHit: scanned === null || produced === null ? null : scanned === 0 && produced > 0
402699
- };
402700
- }
402701
- function col(row, key) {
402702
- if (key in row)
402703
- return row[key];
402704
- const upper = key.toUpperCase();
402705
- if (upper in row)
402706
- return row[upper];
402707
- const hit = Object.keys(row).find((k2) => k2.toLowerCase() === key);
402708
- return hit === undefined ? undefined : row[hit];
402709
- }
402710
- function num(value) {
402711
- if (value === null || value === undefined)
402712
- return null;
402713
- const n = Number(value);
402714
- return Number.isFinite(n) ? n : null;
402715
- }
402716
- function str(value) {
402717
- return typeof value === "string" && value.length > 0 ? value : null;
402718
- }
402719
- function bool(value) {
402720
- if (typeof value === "boolean")
402721
- return value;
402722
- if (typeof value === "string") {
402723
- if (value === "true")
402724
- return true;
402725
- if (value === "false")
402726
- return false;
402727
- }
402728
- return null;
402729
- }
402730
-
402731
- // src/service/materialization_build_session.ts
402732
- var sharedGateSession;
402733
- var PASSTHROUGH_SOURCE_TYPES = [
402734
- "bigquery",
402735
- "snowflake",
402736
- "postgres"
402737
- ];
402738
- var STORAGE_DESTINATION_TYPES = ["ducklake", "duckdb"];
402739
- var STORAGE_TARGET_DIALECT = "duckdb";
402740
- function wrapPassthrough(sourceType, handle, innerSQL) {
402741
- const sql = `'${escapeSQL(innerSQL)}'`;
402742
- const h4 = `'${escapeSQL(handle)}'`;
402743
- switch (sourceType) {
402744
- case "bigquery":
402745
- return `SELECT * FROM bigquery_query(${h4}, ${sql})`;
402746
- case "postgres":
402747
- return `SELECT * FROM postgres_query(${h4}, ${sql})`;
402748
- case "snowflake":
402749
- return `SELECT * FROM snowflake_query(${sql}, ${h4})`;
402750
- default: {
402751
- const exhaustive = sourceType;
402752
- throw new BadRequestError(`Unsupported passthrough source type: ${String(exhaustive)}`);
402753
- }
402754
- }
402755
- }
402756
- function orderByPartitionColumns(selectSQL, partitionColumns, dialect) {
402757
- if (partitionColumns.length === 0) {
402758
- return selectSQL;
402759
- }
402760
- const order = partitionColumns.map((name) => quoteIdentifier(name, dialect)).join(", ");
402761
- const inner = selectSQL.replace(/[\s;]+$/, "");
402762
- return `SELECT * FROM (
402763
- ${inner}
402764
- ) AS partitioned_build ORDER BY ${order}`;
402765
- }
402766
- async function tagSnowflakeSession(session, sourceType, handle, queryMetadata) {
402767
- if (sourceType !== "snowflake")
402768
- return;
402769
- const sql = snowflakeSetQueryTagSQL(queryMetadata, handle);
402770
- if (sql === undefined)
402771
- return;
402772
- try {
402773
- await session.runSQL(sql);
402774
- } catch (error2) {
402775
- logger.warn("Could not tag the Snowflake build session", {
402776
- error: error2 instanceof Error ? error2.message : String(error2)
402777
- });
402778
- recordAttributionSkipped("tag_failed");
402779
- }
402780
- }
402781
- async function canSplitBigQueryRead(session, handle) {
402782
- try {
402783
- await session.runSQL(`SELECT job_id FROM bigquery_jobs('${escapeSQL(handle)}', maxResults := 1)`);
402784
- return true;
402785
- } catch (error2) {
402786
- logger.warn("Cannot list BigQuery jobs for this connection, so this build's " + "warehouse read will not be labelled or costed", {
402787
- error: error2 instanceof Error ? error2.message : String(error2)
402788
- });
402789
- recordAttributionSkipped("job_listing_unavailable");
402790
- return false;
402791
- }
402792
- }
402793
-
402794
- class BilledReadNotCapturedError extends Error {
402795
- constructor(message, options) {
402796
- super(message, options);
402797
- this.name = "BilledReadNotCapturedError";
402798
- }
402799
- }
402800
- var BIGQUERY_LOOKUP_ATTEMPTS = 3;
402801
- var BIGQUERY_LOOKUP_BACKOFF_MS = 250;
402802
- async function withBigQueryLookupRetry(work, parentJobId) {
402803
- let lastError;
402804
- for (let attempt = 1;attempt <= BIGQUERY_LOOKUP_ATTEMPTS; attempt++) {
402805
- try {
402806
- return await work();
402807
- } catch (error2) {
402808
- lastError = error2;
402809
- if (attempt < BIGQUERY_LOOKUP_ATTEMPTS) {
402810
- logger.warn("Could not read the BigQuery job record for a build's read; retrying", {
402811
- attempt,
402812
- of: BIGQUERY_LOOKUP_ATTEMPTS,
402813
- parentJobId,
402814
- error: error2 instanceof Error ? error2.message : String(error2)
402815
- });
402816
- await new Promise((resolve8) => setTimeout(resolve8, BIGQUERY_LOOKUP_BACKOFF_MS * attempt));
402817
- }
402818
- }
402819
- }
402820
- 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 });
402821
- }
402822
- async function issuePassthroughRead(session, sourceType, handle, buildSQL, queryMetadata) {
402823
- const label = sourceType === "bigquery" ? bigQueryQueryLabelValue(queryMetadata) : undefined;
402824
- if (label === undefined || !await canSplitBigQueryRead(session, handle)) {
402825
- return {
402826
- selectSQL: wrapPassthrough(sourceType, handle, buildSQL),
402827
- jobId: null,
402828
- cost: null
402829
- };
402830
- }
402831
- const script = `SET @@query_label = "${label}";
402832
- ${buildSQL};`;
402833
- const executed = await session.runSQL(`SELECT * FROM bigquery_execute('${escapeSQL(handle)}', '${escapeSQL(script)}')`);
402834
- const parentJobId = firstColumn(executed, "job_id");
402835
- if (parentJobId === null) {
402836
- throw new BilledReadNotCapturedError("bigquery_execute returned no job_id for the build's read, so its " + "result table cannot be located");
402837
- }
402838
- const located = await withBigQueryLookupRetry(async () => {
402839
- const child = await session.runSQL(`
402840
- SELECT job_id,
402841
- json_extract_string(configuration, '$.query.destinationTable.projectId') AS project,
402842
- json_extract_string(configuration, '$.query.destinationTable.datasetId') AS dataset,
402843
- json_extract_string(configuration, '$.query.destinationTable.tableId') AS table,
402844
- ${BIGQUERY_COST_COLUMNS}
402845
- FROM (SELECT * FROM bigquery_jobs('${escapeSQL(handle)}',
402846
- parentJobId := '${escapeSQL(parentJobId)}')
402847
- WHERE job_type = 'QUERY')
402848
- ORDER BY creation_time DESC`);
402849
- const row = resultRows(child).find((r3) => str2(r3.dataset) && str2(r3.table));
402850
- if (row === undefined) {
402851
- throw new Error(`no child job with a result table under parent ${parentJobId}`);
402852
- }
402853
- return row;
402854
- }, parentJobId);
402855
- const path22 = [
402856
- str2(located.project) ?? handle,
402857
- str2(located.dataset),
402858
- str2(located.table)
402859
- ].join(".");
402860
- const jobId = str2(located.job_id);
402861
- return {
402862
- selectSQL: `SELECT * FROM bigquery_scan('${escapeSQL(path22)}')`,
402863
- jobId,
402864
- cost: bigQueryReadCost(located, jobId, parentJobId)
402865
- };
402866
- }
402867
- async function snowflakeReadCostAfterBuild(session, sourceType, handle, buildSQL, queryMetadata, database) {
402868
- if (sourceType !== "snowflake")
402869
- return null;
402870
- const tag = snowflakeQueryTagValue(queryMetadata);
402871
- if (tag === undefined)
402872
- return null;
402873
- try {
402874
- const costResult = await session.runSQL(passthroughSnowflake(snowflakeCostSQL(tag, database), handle));
402875
- const candidates = resultRows(costResult);
402876
- const row = pickSnowflakeReadRow(candidates, buildSQL);
402877
- if (row === null) {
402878
- recordAttributionSkipped(candidates.length === 0 ? "read_row_not_found" : "read_row_ambiguous");
402879
- return null;
402880
- }
402881
- return snowflakeReadCost(row);
402882
- } catch (error2) {
402883
- logger.warn("Could not read back what the Snowflake build read cost", {
402884
- error: error2 instanceof Error ? error2.message : String(error2)
402885
- });
402886
- recordAttributionSkipped("cost_query_failed");
402887
- return null;
402888
- }
402889
- }
402890
- function passthroughSnowflake(innerSQL, handle) {
402891
- return `SELECT * FROM snowflake_query('${escapeSQL(innerSQL)}', '${escapeSQL(handle)}')`;
402892
- }
402893
- function resultRows(result) {
402894
- if (Array.isArray(result))
402895
- return result;
402896
- const rows = result?.rows;
402897
- return Array.isArray(rows) ? rows : [];
402898
- }
402899
- function firstColumn(result, column) {
402900
- const rows = resultRows(result);
402901
- return rows.length > 0 ? str2(rows[0][column]) : null;
402902
- }
402903
- function str2(value) {
402904
- return typeof value === "string" && value.length > 0 ? value : null;
402905
- }
402906
- async function clearSnowflakeSessionTag(session, sourceType, handle) {
402907
- if (sourceType !== "snowflake" || handle === undefined)
402908
- return;
402909
- try {
402910
- await session.runSQL(passthroughSnowflake("ALTER SESSION UNSET QUERY_TAG", handle));
402911
- } catch (error2) {
402912
- logger.warn("Could not clear the Snowflake build session's query tag", {
402913
- error: error2 instanceof Error ? error2.message : String(error2)
402914
- });
402915
- }
402916
- }
402917
- function passthroughSourceType(sourceConnection) {
402918
- const type = sourceConnection.type;
402919
- if (PASSTHROUGH_SOURCE_TYPES.includes(type ?? "")) {
402920
- return type;
402921
- }
402922
- 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.`);
402923
- }
402924
- function createIsolatedBuildSession(sessionName) {
402925
- const workDir = mkdtempSync2(path21.join(os6.tmpdir(), "malloy-build-"));
402926
- let session;
402927
- try {
402928
- session = new DuckDBConnection4(sessionName, ":memory:", workDir, {
402929
- rowLimit: Number.MAX_SAFE_INTEGER
402930
- });
402931
- } catch (err) {
402932
- rmSync(workDir, { recursive: true, force: true });
402933
- throw err;
402934
- }
402935
- const dispose = async () => {
402936
- const nodeConnection = session.connection;
402937
- try {
402938
- await session.close();
402939
- } catch (err) {
402940
- logger.warn("Failed to close build session (leaked session)", {
402941
- sessionName,
402942
- error: err instanceof Error ? err.message : String(err)
402943
- });
402944
- }
402945
- try {
402946
- nodeConnection?.disconnectSync?.();
402947
- } catch (err) {
402948
- logger.warn("Failed to disconnect build session (leaked connection)", {
402949
- sessionName,
402950
- error: err instanceof Error ? err.message : String(err)
402951
- });
402952
- }
402953
- try {
402954
- rmSync(workDir, { recursive: true, force: true });
402955
- } catch (err) {
402956
- logger.warn("Failed to remove build session working directory", {
402957
- sessionName,
402958
- workDir,
402959
- error: err instanceof Error ? err.message : String(err)
402960
- });
402961
- }
402962
- };
402963
- return { session, dispose, workDir };
402964
- }
402965
- async function pinSessionToUTC(session) {
402966
- await session.runSQL("SET TimeZone='UTC'");
402967
- }
402968
- async function buildSourceIntoStorage(params) {
402969
- const {
402970
- destinationName,
402971
- destinationConnection,
402972
- sourceConnection,
402973
- buildSQL,
402974
- physicalTableName,
402975
- environmentPath,
402976
- queryMetadata
402977
- } = params;
402978
- const federate = params.deps?.federate ?? federateSourceForPassthrough;
402979
- const read = params.deps?.read ?? issuePassthroughRead;
402980
- assertSupportedDestination(destinationName, destinationConnection);
402981
- const sourceType = passthroughSourceType(sourceConnection);
402982
- const { session, dispose, workDir } = createIsolatedBuildSession(`build_${destinationName}`);
402983
- let federatedHandle;
402984
- let federatedClose;
402985
- try {
402986
- await applySessionResourceLimits(session, { tempDirectory: workDir });
402987
- await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
402988
- if (destinationConnection.type === "ducklake") {
402989
- await session.runSQL("SET ducklake_default_data_inlining_row_limit=0");
402990
- }
402991
- await pinSessionToUTC(session);
402992
- const federated = await federate(session, sourceType, sourceFederationConfig(sourceConnection));
402993
- federatedClose = federated.close;
402994
- await tagSnowflakeSession(session, sourceType, federated.handle, queryMetadata);
402995
- const target = quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT);
402996
- if (params.incremental) {
402997
- const partitioned = (params.partitionColumns?.length ?? 0) > 0;
402998
- const flushThreshold = getPartitionedWriteFlushThreshold();
402999
- const boundDelta = partitioned && flushThreshold !== undefined;
403000
- if (boundDelta) {
403001
- await session.runSQL(`SET partitioned_write_flush_threshold = ${flushThreshold}`);
403002
- await session.runSQL("SET threads = 1");
403003
- }
403004
- let refreshed;
403005
- try {
403006
- refreshed = await params.incremental.plan({
403007
- session,
403008
- sourceType,
403009
- handle: federated.handle,
403010
- quotedTablePath: target
403011
- });
403012
- } finally {
403013
- if (boundDelta) {
403014
- try {
403015
- await session.runSQL("RESET threads");
403016
- await session.runSQL("RESET partitioned_write_flush_threshold");
403017
- } catch {}
403018
- }
403019
- }
403020
- if (refreshed) {
403021
- return {
403022
- storageDestinationName: destinationName,
403023
- schema: await describeTable(session, target),
403024
- readCost: refreshed.readCost,
403025
- refresh: refreshed
403026
- };
403027
- }
403028
- }
403029
- const passthrough = await read(session, sourceType, federated.handle, buildSQL, queryMetadata);
403030
- const schema = await createTableAndDescribe(session, target, passthrough.selectSQL, params.partitionColumns ?? [], { sourceType });
403031
- const seededThrough = await params.incremental?.afterSeed({
403032
- session,
403033
- quotedTablePath: target
403034
- });
403035
- return {
403036
- seededThrough,
403037
- storageDestinationName: destinationName,
403038
- schema,
403039
- readCost: passthrough.cost ?? await snowflakeReadCostAfterBuild(session, sourceType, federated.handle, buildSQL, queryMetadata, sourceConnection.snowflakeConnection?.database)
403040
- };
403041
- } finally {
403042
- await clearSnowflakeSessionTag(session, sourceType, federatedHandle);
403043
- await dispose();
403044
- if (federatedClose) {
403045
- await federatedClose().catch((e2) => logger.warn(`Failed to close the SSH proxy a storage build federated through: ${String(e2)}`));
403046
- }
403047
- }
403048
- }
403049
- async function buildDownstreamIntoStorage(params) {
403050
- const {
403051
- destinationName,
403052
- destinationConnection,
403053
- transientModel,
403054
- downstreamName,
403055
- virtualMap,
403056
- physicalTableName,
403057
- environmentPath
403058
- } = params;
403059
- assertSupportedDestination(destinationName, destinationConnection);
403060
- const { session, dispose, workDir } = createIsolatedBuildSession(`build_${destinationName}`);
403061
- try {
403062
- await applySessionResourceLimits(session, { tempDirectory: workDir });
403063
- await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
403064
- if (destinationConnection.type === "ducklake") {
403065
- await session.runSQL("SET ducklake_default_data_inlining_row_limit=0");
403066
- }
403067
- await pinSessionToUTC(session);
403068
- const root = "file:///chained-build/";
403069
- const url3 = `${root}m.malloy`;
403070
- const runtime = new Runtime4({
403071
- urlReader: new InMemoryURLReader3(new Map([[url3, transientModel]])),
403072
- connections: new FixedConnectionMap3(new Map([[destinationName, session]]), destinationName)
403073
- });
403074
- let model;
403075
- try {
403076
- model = await runtime.loadModel(new URL(url3), { importBaseURL: new URL(root) }).getModel();
403077
- } catch (err) {
403078
- throw new MaterializationEligibilityError({
403079
- message: `Chained build model did not compile over the rebound parents: ${errMessage(err)}`
403080
- });
403081
- }
403082
- const plan = model.getBuildPlan();
403083
- let downstream;
403084
- for (const ps2 of Object.values(plan.sources)) {
403085
- if (ps2.name === downstreamName) {
403086
- downstream = ps2;
403087
- break;
403088
- }
403089
- }
403090
- if (!downstream) {
403091
- throw new MaterializationEligibilityError({
403092
- message: `Chained build model did not yield a persist source named ` + `'${downstreamName}' (the downstream references something the ` + `rebound parents don't provide).`
403093
- });
403094
- }
403095
- const sql = projectToPublicColumns(downstream, downstream.getSQL({ virtualMap }));
403096
- const target = quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT);
403097
- const schema = await createTableAndDescribe(session, target, sql, params.partitionColumns ?? []);
403098
- return {
403099
- storageDestinationName: destinationName,
403100
- schema,
403101
- readCost: null,
403102
- upstreamReuse: "reused"
403103
- };
403104
- } finally {
403105
- await dispose();
403106
- }
403107
- }
403108
- async function assertStorageServeShapeCompiles(params) {
403109
- const { destinationName, sourceName, virtualHandle, physicalTableName } = params;
403110
- const binding = {
403111
- sourceName,
403112
- destinationName,
403113
- virtualHandle,
403114
- tablePath: `${destinationName}.${physicalTableName}`,
403115
- schema: params.schema.filter((c3) => c3.name && c3.type).map((c3) => ({ name: c3.name, type: c3.type }))
403116
- };
403117
- sharedGateSession ??= createIsolatedBuildSession("gate_shared").session;
403118
- await applySessionResourceLimits(sharedGateSession);
403119
- await assertServesInDuckDB(sourceName, binding, new FixedConnectionMap3(new Map([[destinationName, sharedGateSession]]), destinationName));
403120
- }
403121
- function dropStorageTableSql(destinationName, physicalTableName) {
403122
- return `DROP TABLE IF EXISTS ${quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT)}`;
403123
- }
403124
- async function dropStorageTable(params) {
403125
- const {
403126
- destinationName,
403127
- destinationConnection,
403128
- physicalTableName,
403129
- environmentPath
403130
- } = params;
403131
- assertSupportedDestination(destinationName, destinationConnection);
403132
- const { session, dispose, workDir } = createIsolatedBuildSession(`gc_${destinationName}`);
403133
- try {
403134
- await applySessionResourceLimits(session, { tempDirectory: workDir });
403135
- await attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath);
403136
- await session.runSQL(dropStorageTableSql(destinationName, physicalTableName));
403137
- } finally {
403138
- await dispose();
403139
- }
403140
- }
403141
- async function attachDestinationReadWrite(session, destinationName, destinationConnection, environmentPath) {
403142
- if (destinationConnection.type === "ducklake") {
403143
- const cfg = destinationConnection.ducklakeConnection;
403144
- if (!cfg) {
403145
- throw new BadRequestError(`Storage destination '${destinationName}' is type 'ducklake' but ` + `has no ducklakeConnection config.`);
403146
- }
403147
- await attachDuckLakeReadWrite(session, destinationName, cfg);
403148
- return;
403149
- }
403150
- const destinationRoot = storageDestinationRoot(environmentPath);
403151
- mkdirSync3(destinationRoot, { recursive: true });
403152
- const dbPath = path21.join(destinationRoot, `${destinationName}.duckdb`);
403153
- await session.runSQL(`ATTACH '${escapeSQL(dbPath)}' AS ${quoteIdentifier(destinationName, STORAGE_TARGET_DIALECT)}`);
403154
- }
403155
- function assertSupportedDestination(destinationName, destinationConnection) {
403156
- const type = destinationConnection.type ?? "";
403157
- if (!STORAGE_DESTINATION_TYPES.includes(type)) {
403158
- throw new BadRequestError(`Storage destination '${destinationName}' is type '${type}', but ` + `'storage=' destinations must be one of ` + `${STORAGE_DESTINATION_TYPES.join(", ")}.`);
403159
- }
403160
- }
403161
- function sourceFederationConfig(sourceConnection) {
403162
- return {
403163
- name: sourceConnection.name ?? "src",
403164
- bigqueryConnection: sourceConnection.bigqueryConnection,
403165
- snowflakeConnection: sourceConnection.snowflakeConnection,
403166
- postgresConnection: sourceConnection.postgresConnection,
403167
- proxy: sourceConnection.proxy
403168
- };
403169
- }
403170
- async function createTableAndDescribe(session, quotedTablePath, selectSQL, partitionColumns = [], options = {}) {
403171
- if (partitionColumns.length === 0) {
403172
- await session.runSQL(`CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL})`);
403173
- return await describeOrDrop(session, quotedTablePath);
403174
- }
403175
- const columns = partitionColumns.map((name) => quoteIdentifier(name, STORAGE_TARGET_DIALECT)).join(", ");
403176
- const flushThreshold = getPartitionedWriteFlushThreshold();
403177
- const bounded = options.sourceType !== undefined && flushThreshold !== undefined;
403178
- if (bounded) {
403179
- await session.runSQL(`SET partitioned_write_flush_threshold = ${flushThreshold}`);
403180
- await session.runSQL("SET threads = 1");
403181
- }
403182
- let schema;
403183
- await session.runSQL("BEGIN TRANSACTION");
403184
- try {
403185
- await session.runSQL(`CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL}) WITH NO DATA`);
403186
- await session.runSQL(`ALTER TABLE ${quotedTablePath} SET PARTITIONED BY (${columns})`);
403187
- const inserted = bounded ? orderByPartitionColumns(selectSQL, partitionColumns, STORAGE_TARGET_DIALECT) : selectSQL;
403188
- await session.runSQL(`INSERT INTO ${quotedTablePath} (${inserted})`);
403189
- schema = await describeTable(session, quotedTablePath);
403190
- await session.runSQL("COMMIT");
403191
- } catch (buildErr) {
403192
- try {
403193
- await session.runSQL("ROLLBACK");
403194
- } catch (rollbackErr) {
403195
- logger.warn("Failed to roll back a partitioned storage build; the served " + "generation may have been replaced", {
403196
- table: quotedTablePath,
403197
- error: errMessage(rollbackErr),
403198
- cause: errMessage(buildErr)
403199
- });
403200
- }
403201
- throw buildErr;
403202
- } finally {
403203
- if (bounded) {
403204
- try {
403205
- await session.runSQL("RESET threads");
403206
- } catch {}
403207
- }
403208
- }
403209
- return schema;
403210
- }
403211
- async function describeOrDrop(session, quotedTablePath) {
403212
- try {
403213
- return await describeTable(session, quotedTablePath);
403214
- } catch (describeErr) {
403215
- await dropStranded(session, quotedTablePath, describeErr);
403216
- throw describeErr;
403217
- }
403218
- }
403219
- async function dropStranded(session, quotedTablePath, cause) {
403220
- try {
403221
- await session.runSQL(`DROP TABLE IF EXISTS ${quotedTablePath}`);
403222
- } catch (dropErr) {
403223
- logger.warn("Failed to drop a storage table stranded by a failed build " + "(physical leak)", {
403224
- table: quotedTablePath,
403225
- error: errMessage(dropErr),
403226
- cause: errMessage(cause)
403227
- });
403228
- }
403229
- }
403230
- async function describeTable(session, quotedTablePath) {
403231
- const result = await session.runSQL(`DESCRIBE ${quotedTablePath}`);
403232
- const rows = Array.isArray(result) ? result : result.rows ?? [];
403233
- const columns = [];
403234
- for (const row of rows) {
403235
- const name = row.column_name;
403236
- const type = row.column_type;
403237
- if (typeof name === "string" && typeof type === "string") {
403238
- columns.push({ name, type });
403239
- }
403240
- }
403241
- return columns;
403242
- }
403243
-
403244
403741
  // src/service/incremental_storage.ts
403245
403742
  function storageDeltaTarget(params) {
403246
403743
  const {
@@ -406447,6 +406944,7 @@ var databaseController = new DatabaseController(environmentStore);
406447
406944
  var queryController = new QueryController(environmentStore);
406448
406945
  var compileController = new CompileController(environmentStore);
406449
406946
  var materializationService = new MaterializationService(environmentStore);
406947
+ var storageDestinationController = new StorageDestinationController(environmentStore);
406450
406948
  var materializationController = new MaterializationController(materializationService);
406451
406949
  function startMaterializationSchedulerFromEnv(store, service) {
406452
406950
  const config2 = getMaterializationSchedulerConfig();
@@ -407328,6 +407826,31 @@ app.delete(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/m
407328
407826
  res.status(status).json(json2);
407329
407827
  }
407330
407828
  });
407829
+ app.get(`${API_PREFIX2}/environments/:environmentName/storageDestinations/:destinationName/schemas/:schemaName/tables`, async (req, res) => {
407830
+ try {
407831
+ res.status(200).json(await storageDestinationController.listTables(req.params.environmentName, req.params.destinationName, req.params.schemaName));
407832
+ } catch (error2) {
407833
+ const { json: json2, status } = internalErrorToHttpError(error2);
407834
+ res.status(status).json(json2);
407835
+ }
407836
+ });
407837
+ app.delete(`${API_PREFIX2}/environments/:environmentName/storageDestinations/:destinationName/schemas/:schemaName/tables/:tableName`, async (req, res) => {
407838
+ try {
407839
+ await storageDestinationController.dropTable(req.params.environmentName, req.params.destinationName, req.params.schemaName, req.params.tableName);
407840
+ res.status(204).send();
407841
+ } catch (error2) {
407842
+ const { json: json2, status } = internalErrorToHttpError(error2);
407843
+ res.status(status).json(json2);
407844
+ }
407845
+ });
407846
+ app.post(`${API_PREFIX2}/environments/:environmentName/storageDestinations/:destinationName/fileCleanups`, async (req, res) => {
407847
+ try {
407848
+ res.status(201).json(await storageDestinationController.createFileCleanup(req.params.environmentName, req.params.destinationName, req.body ?? {}));
407849
+ } catch (error2) {
407850
+ const { json: json2, status } = internalErrorToHttpError(error2);
407851
+ res.status(status).json(json2);
407852
+ }
407853
+ });
407331
407854
  registerLegacyRoutes(app, {
407332
407855
  environmentStore,
407333
407856
  connectionController,