@malloy-publisher/server 0.10.1 → 0.10.3

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 (26) hide show
  1. package/dist/app/api-doc.yaml +14 -4
  2. package/dist/app/assets/{EnvironmentPage-BU9PTvlW.js → EnvironmentPage-B9Q9ZYYM.js} +1 -1
  3. package/dist/app/assets/{HomePage-Cf8DI47T.js → HomePage-CVbXvH7c.js} +1 -1
  4. package/dist/app/assets/{MainPage-BlwWg0iB.js → MainPage-D1Fj4Wy1.js} +1 -1
  5. package/dist/app/assets/{ModelPage-DA0bb3CE.js → ModelPage-DmYocpWT.js} +2 -2
  6. package/dist/app/assets/{PackagePage-BMsYUtRD.js → PackagePage-4TwX_ajC.js} +1 -1
  7. package/dist/app/assets/RenderedResult-B_sW7neX.es-CFeRQyYD.js +179 -0
  8. package/dist/app/assets/{RouteError-DxCqVKbo.js → RouteError-BYb6Nj48.js} +1 -1
  9. package/dist/app/assets/ThemeEditorPage-CYe_2MfI.js +1 -0
  10. package/dist/app/assets/{ToggleButtonGroup-DgxU1gMN.js → ToggleButtonGroup-b325mGmL.js} +1 -1
  11. package/dist/app/assets/documentRoutes-D_C8snHV.js +1 -0
  12. package/dist/app/assets/{editPageMounted-CT7ODDUS.js → editPageMounted-1X99LP7v.js} +2 -2
  13. package/dist/app/assets/{index-D8RY5NbL.js → index-3fArIYYK.js} +1 -1
  14. package/dist/app/assets/index-CGKV9Dxi.js +483 -0
  15. package/dist/app/assets/{index-Cji4XUjs.js → index-DEj7hnfZ.js} +1 -1
  16. package/dist/app/assets/{index-BiBw5Wni.js → index-DWhQwvJR.js} +1 -1
  17. package/dist/app/assets/{index.es-B10IQDf8.js → index.es-CMsNTB-h.js} +21 -21
  18. package/dist/app/index.html +1 -1
  19. package/dist/instrumentation.mjs +1 -1
  20. package/dist/server.mjs +244 -95
  21. package/package.json +1 -1
  22. package/dist/app/assets/RenderedResult-vf6tu8tf.es-CSqnbEun.js +0 -171
  23. package/dist/app/assets/ThemeEditorPage-380DQSoR.js +0 -1
  24. package/dist/app/assets/documentRoutes-Bqj5f6iK.js +0 -1
  25. package/dist/app/assets/index-DYqHxLwV.js +0 -481
  26. package/dist/sshcrypto-cqb52edz.node +0 -0
package/dist/server.mjs CHANGED
@@ -164832,7 +164832,7 @@ var init_telemetry = __esm(() => {
164832
164832
  });
164833
164833
 
164834
164834
  // package.json
164835
- var version = "0.10.1";
164835
+ var version = "0.10.3";
164836
164836
  var init_package = () => {};
164837
164837
 
164838
164838
  // src/version.ts
@@ -263961,6 +263961,45 @@ var init_introspection_sql = __esm(() => {
263961
263961
  });
263962
263962
 
263963
263963
  // src/service/gcs_s3_utils.ts
263964
+ function parseS3Endpoint(endpoint) {
263965
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(endpoint);
263966
+ const protocol = scheme?.[1].toLowerCase();
263967
+ if (protocol !== undefined && protocol !== "http" && protocol !== "https") {
263968
+ throw new Error(`S3 endpoint scheme must be http:// or https://, got ${protocol}://`);
263969
+ }
263970
+ if (/\s/.test(endpoint)) {
263971
+ throw new Error("S3 endpoint must not contain whitespace");
263972
+ }
263973
+ if (/[?#]/.test(endpoint)) {
263974
+ throw new Error("S3 endpoint must not contain a query string or fragment");
263975
+ }
263976
+ let end = endpoint.length;
263977
+ while (end > 0 && endpoint[end - 1] === "/")
263978
+ end--;
263979
+ const hostAndPath = endpoint.slice(scheme ? scheme[0].length : 0, end);
263980
+ if (!hostAndPath) {
263981
+ throw new Error("S3 endpoint must include a host");
263982
+ }
263983
+ if (hostAndPath.startsWith("/")) {
263984
+ throw new Error("S3 endpoint must include a host before any path");
263985
+ }
263986
+ if (hostAndPath.split("/", 1)[0].includes("@")) {
263987
+ throw new Error("S3 endpoint must not contain userinfo");
263988
+ }
263989
+ const useSsl = protocol !== "http";
263990
+ return {
263991
+ hostAndPath,
263992
+ useSsl,
263993
+ url: `${useSsl ? "https" : "http"}://${hostAndPath}`
263994
+ };
263995
+ }
263996
+ function checkS3Endpoint(endpoint, provider) {
263997
+ const parsed = parseS3Endpoint(endpoint);
263998
+ if (!parsed.useSsl && provider === "credential_chain") {
263999
+ throw new Error("S3 endpoint must use https:// with provider credential_chain: its requests carry the host's credentials");
264000
+ }
264001
+ return parsed;
264002
+ }
263964
264003
  function gcsConnectionToCredentials(gcsConnection) {
263965
264004
  return {
263966
264005
  type: "gcs",
@@ -264025,6 +264064,13 @@ function resolveCloudStorageCredentials(attachedDb, origin2 = "connection") {
264025
264064
  }
264026
264065
  const s3 = attachedDb.s3Connection;
264027
264066
  validateS3ProviderShape(s3, attachedDb.name, origin2);
264067
+ if (typeof s3.endpoint === "string" && s3.endpoint !== "") {
264068
+ try {
264069
+ checkS3Endpoint(s3.endpoint, s3.provider);
264070
+ } catch (error) {
264071
+ throw new Error(`${error.message} (${attachedDb.name})`);
264072
+ }
264073
+ }
264028
264074
  if (s3.provider !== "credential_chain" && (!s3.accessKeyId || !s3.secretAccessKey)) {
264029
264075
  throw new Error(`S3 accessKeyId and secretAccessKey are required for: ${attachedDb.name}`);
264030
264076
  }
@@ -264043,7 +264089,7 @@ function createCloudStorageClient(credentials) {
264043
264089
  const isGCS = credentials.type === "gcs";
264044
264090
  const useHostCredentials = !isGCS && credentials.provider === "credential_chain";
264045
264091
  const client = new import_client_s3.S3Client({
264046
- endpoint: isGCS ? "https://storage.googleapis.com" : credentials.endpoint,
264092
+ endpoint: isGCS ? "https://storage.googleapis.com" : credentials.endpoint ? checkS3Endpoint(credentials.endpoint, credentials.provider).url : undefined,
264047
264093
  region: isGCS ? "auto" : credentials.region || "us-east-1",
264048
264094
  ...useHostCredentials ? {} : {
264049
264095
  credentials: {
@@ -268914,11 +268960,6 @@ var require_utils93 = __commonJS((exports, module) => {
268914
268960
  };
268915
268961
  });
268916
268962
 
268917
- // ../../node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
268918
- var require_sshcrypto = __commonJS((exports, module) => {
268919
- module.exports = __require("./sshcrypto-cqb52edz.node");
268920
- });
268921
-
268922
268963
  // ../../node_modules/ssh2/lib/protocol/crypto/poly1305.js
268923
268964
  var require_poly1305 = __commonJS((exports, module) => {
268924
268965
  var __dirname = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto", __filename = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto/poly1305.js";
@@ -269405,7 +269446,7 @@ var require_crypto = __commonJS((exports, module) => {
269405
269446
  var ChaChaPolyDecipher;
269406
269447
  var GenericDecipher;
269407
269448
  try {
269408
- binding = require_sshcrypto();
269449
+ binding = (()=>{throw new Error("Cannot require module "+"./crypto/build/Release/sshcrypto.node");})();
269409
269450
  ({
269410
269451
  AESGCMCipher,
269411
269452
  ChaChaPolyCipher,
@@ -295454,57 +295495,40 @@ function buildCloudStorageSecretSQL(secretName, credentials) {
295454
295495
  if (!credentials.region) {
295455
295496
  logger.warn("S3 connection has no region; defaulting to us-east-1. A bucket in " + "another region will not be found.", { secretName });
295456
295497
  }
295498
+ let clauses;
295457
295499
  if (credentials.provider === "credential_chain") {
295458
295500
  const chain = escapeSQL(credentials.chain?.trim() || DEFAULT_S3_CREDENTIAL_CHAIN);
295459
- const clauses = [
295501
+ clauses = [
295460
295502
  "TYPE s3",
295461
295503
  "PROVIDER credential_chain",
295462
295504
  `CHAIN '${chain}'`,
295463
295505
  "REFRESH 'auto'",
295464
295506
  `REGION '${region}'`
295465
295507
  ];
295466
- if (credentials.endpoint) {
295467
- clauses.push(`ENDPOINT '${escapeSQL(credentials.endpoint)}'`, "URL_STYLE 'path'");
295508
+ } else {
295509
+ clauses = [
295510
+ "TYPE s3",
295511
+ `KEY_ID '${escapedKeyId}'`,
295512
+ `SECRET '${escapedSecret}'`,
295513
+ `REGION '${region}'`
295514
+ ];
295515
+ }
295516
+ if (credentials.endpoint) {
295517
+ const { hostAndPath, useSsl } = checkS3Endpoint(credentials.endpoint, credentials.provider);
295518
+ clauses.push(`ENDPOINT '${escapeSQL(hostAndPath)}'`, "URL_STYLE 'path'");
295519
+ if (!useSsl) {
295520
+ clauses.push("USE_SSL false");
295468
295521
  }
295469
- createSecretCommand = `
295522
+ }
295523
+ if (credentials.provider !== "credential_chain" && credentials.sessionToken) {
295524
+ clauses.push(`SESSION_TOKEN '${escapeSQL(credentials.sessionToken)}'`);
295525
+ }
295526
+ createSecretCommand = `
295470
295527
  CREATE OR REPLACE SECRET ${secretName} (
295471
295528
  ${clauses.join(`,
295472
295529
  `)}
295473
295530
  );
295474
295531
  `;
295475
- } else if (credentials.endpoint) {
295476
- const escapedEndpoint = escapeSQL(credentials.endpoint);
295477
- createSecretCommand = `
295478
- CREATE OR REPLACE SECRET ${secretName} (
295479
- TYPE s3,
295480
- KEY_ID '${escapedKeyId}',
295481
- SECRET '${escapedSecret}',
295482
- REGION '${region}',
295483
- ENDPOINT '${escapedEndpoint}',
295484
- URL_STYLE 'path'
295485
- );
295486
- `;
295487
- } else if (credentials.sessionToken) {
295488
- const escapedToken = escapeSQL(credentials.sessionToken);
295489
- createSecretCommand = `
295490
- CREATE OR REPLACE SECRET ${secretName} (
295491
- TYPE s3,
295492
- KEY_ID '${escapedKeyId}',
295493
- SECRET '${escapedSecret}',
295494
- REGION '${region}',
295495
- SESSION_TOKEN '${escapedToken}'
295496
- );
295497
- `;
295498
- } else {
295499
- createSecretCommand = `
295500
- CREATE OR REPLACE SECRET ${secretName} (
295501
- TYPE s3,
295502
- KEY_ID '${escapedKeyId}',
295503
- SECRET '${escapedSecret}',
295504
- REGION '${region}'
295505
- );
295506
- `;
295507
- }
295508
295532
  }
295509
295533
  return createSecretCommand;
295510
295534
  }
@@ -296696,6 +296720,47 @@ var init_connection_public_view = __esm(() => {
296696
296720
  ];
296697
296721
  });
296698
296722
 
296723
+ // src/service/single_select.ts
296724
+ import { DuckDBInstance } from "@duckdb/node-api";
296725
+ async function parserInstance() {
296726
+ if (!parser) {
296727
+ parser = DuckDBInstance.create(":memory:", {
296728
+ threads: "1",
296729
+ enable_external_access: "false"
296730
+ }).catch((error) => {
296731
+ parser = undefined;
296732
+ throw error;
296733
+ });
296734
+ }
296735
+ return parser;
296736
+ }
296737
+ async function classifySelect(sql) {
296738
+ const connection = await (await parserInstance()).connect();
296739
+ try {
296740
+ const prepared = await connection.prepare("SELECT json_serialize_sql($1::VARCHAR) AS j");
296741
+ prepared.bindVarchar(1, sql);
296742
+ const rows = await (await prepared.run()).getRowObjectsJson();
296743
+ const parsed = JSON.parse(String(rows[0]?.j));
296744
+ if (parsed.error) {
296745
+ return { refused: parsed.error_message ?? "it could not be parsed" };
296746
+ }
296747
+ return { selects: parsed.statements?.length ?? 0 };
296748
+ } finally {
296749
+ connection.closeSync();
296750
+ }
296751
+ }
296752
+ async function assertSingleSelect(connectionName, sql) {
296753
+ const verdict = await classifySelect(sql);
296754
+ if ("selects" in verdict && verdict.selects === 1)
296755
+ return;
296756
+ const why = "refused" in verdict ? "it is not a SELECT" : verdict.selects === 0 ? "it holds no statement" : `it holds ${verdict.selects} statements`;
296757
+ throw new BadRequestError(`'${connectionName}' is a managed database: raw SQL on it runs only as a single SELECT, and ${why}.`);
296758
+ }
296759
+ var parser;
296760
+ var init_single_select = __esm(() => {
296761
+ init_errors();
296762
+ });
296763
+
296699
296764
  // src/service/connection_service.ts
296700
296765
  async function runEnvironmentConnectionUpdate(environment, fn) {
296701
296766
  if (environment.runConnectionUpdateExclusive) {
@@ -305590,6 +305655,15 @@ class ConnectionController {
305590
305655
  }
305591
305656
  return environment.getApiConnection(connectionName);
305592
305657
  }
305658
+ isManagedConnection(environment, connectionName) {
305659
+ try {
305660
+ return this.getApiConnectionForLookup(environment, connectionName).managed === true;
305661
+ } catch (error) {
305662
+ if (error instanceof ConnectionNotFoundError)
305663
+ return false;
305664
+ throw error;
305665
+ }
305666
+ }
305593
305667
  async connectionQueryMetadata(environmentName, connectionName) {
305594
305668
  try {
305595
305669
  const environment = await this.environmentStore.getEnvironment(environmentName, false);
@@ -305712,6 +305786,10 @@ class ConnectionController {
305712
305786
  return withDatabaseAccessClassified(() => listTablesForSchema(connection, schemaName, malloyConnection, tableNames));
305713
305787
  }
305714
305788
  async getConnectionSqlSource(environmentName, connectionName, sqlStatement, packageName) {
305789
+ const environment = await this.environmentStore.getEnvironment(environmentName, false);
305790
+ if (this.isManagedConnection(environment, connectionName)) {
305791
+ await assertSingleSelect(connectionName, sqlStatement);
305792
+ }
305715
305793
  const malloyConnection = await this.getMalloyConnection(environmentName, connectionName, packageName);
305716
305794
  try {
305717
305795
  const schema = await malloyConnection.fetchSelectSchema({
@@ -305734,7 +305812,7 @@ class ConnectionController {
305734
305812
  const connection = this.getApiConnectionForLookup(environment, connectionName);
305735
305813
  if (connection.type === "ducklake") {
305736
305814
  if (tablePath.split(".").length === 1) {
305737
- tablePath = `${connectionName}.${schemaName}.${tablePath}`;
305815
+ tablePath = schemaName === tablePath ? `${connectionName}.${tablePath}` : `${connectionName}.${schemaName}.${tablePath}`;
305738
305816
  } else if (tablePath.split(".").length === 2 && !tablePath.startsWith(connectionName)) {
305739
305817
  tablePath = `${connectionName}.${tablePath}`;
305740
305818
  }
@@ -305772,6 +305850,9 @@ class ConnectionController {
305772
305850
  }
305773
305851
  const environmentForAdmission = await this.environmentStore.getEnvironment(environmentName, false);
305774
305852
  environmentForAdmission.assertCanAdmitQuery();
305853
+ if (this.isManagedConnection(environmentForAdmission, connectionName)) {
305854
+ await assertSingleSelect(connectionName, sqlStatement);
305855
+ }
305775
305856
  const malloyConnection = await this.getMalloyConnection(environmentName, connectionName, packageName);
305776
305857
  let runSQLOptions = {};
305777
305858
  if (options) {
@@ -305857,6 +305938,9 @@ class ConnectionController {
305857
305938
  }
305858
305939
  const environmentForAdmission = await this.environmentStore.getEnvironment(environmentName, false);
305859
305940
  environmentForAdmission.assertCanAdmitQuery();
305941
+ if (this.isManagedConnection(environmentForAdmission, connectionName)) {
305942
+ throw new BadRequestError(`'${connectionName}' is a managed database: raw SQL on it runs only as a single SELECT, so it cannot create a temporary table.`);
305943
+ }
305860
305944
  const malloyConnection = await this.getMalloyConnection(environmentName, connectionName, packageName);
305861
305945
  return await runWithQueryTimeout(async (signal) => {
305862
305946
  try {
@@ -305969,6 +306053,7 @@ var init_connection_controller = __esm(() => {
305969
306053
  init_connection();
305970
306054
  init_connection_public_view();
305971
306055
  init_connection_config();
306056
+ init_single_select();
305972
306057
  init_connection_service();
305973
306058
  init_db_utils();
305974
306059
  init_query_metadata();
@@ -314967,8 +315052,8 @@ var require_lunr = __commonJS((exports, module) => {
314967
315052
  };
314968
315053
  lunr.Index.prototype.search = function(queryString) {
314969
315054
  return this.query(function(query) {
314970
- var parser = new lunr.QueryParser(queryString, query);
314971
- parser.parse();
315055
+ var parser2 = new lunr.QueryParser(queryString, query);
315056
+ parser2.parse();
314972
315057
  });
314973
315058
  };
314974
315059
  lunr.Index.prototype.query = function(fn) {
@@ -315559,8 +315644,8 @@ var require_lunr = __commonJS((exports, module) => {
315559
315644
  this.query.clause(completedClause);
315560
315645
  this.currentClause = {};
315561
315646
  };
315562
- lunr.QueryParser.parseClause = function(parser) {
315563
- var lexeme = parser.peekLexeme();
315647
+ lunr.QueryParser.parseClause = function(parser2) {
315648
+ var lexeme = parser2.peekLexeme();
315564
315649
  if (lexeme == undefined) {
315565
315650
  return;
315566
315651
  }
@@ -315579,23 +315664,23 @@ var require_lunr = __commonJS((exports, module) => {
315579
315664
  throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
315580
315665
  }
315581
315666
  };
315582
- lunr.QueryParser.parsePresence = function(parser) {
315583
- var lexeme = parser.consumeLexeme();
315667
+ lunr.QueryParser.parsePresence = function(parser2) {
315668
+ var lexeme = parser2.consumeLexeme();
315584
315669
  if (lexeme == undefined) {
315585
315670
  return;
315586
315671
  }
315587
315672
  switch (lexeme.str) {
315588
315673
  case "-":
315589
- parser.currentClause.presence = lunr.Query.presence.PROHIBITED;
315674
+ parser2.currentClause.presence = lunr.Query.presence.PROHIBITED;
315590
315675
  break;
315591
315676
  case "+":
315592
- parser.currentClause.presence = lunr.Query.presence.REQUIRED;
315677
+ parser2.currentClause.presence = lunr.Query.presence.REQUIRED;
315593
315678
  break;
315594
315679
  default:
315595
315680
  var errorMessage = "unrecognised presence operator'" + lexeme.str + "'";
315596
315681
  throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
315597
315682
  }
315598
- var nextLexeme = parser.peekLexeme();
315683
+ var nextLexeme = parser2.peekLexeme();
315599
315684
  if (nextLexeme == undefined) {
315600
315685
  var errorMessage = "expecting term or field, found nothing";
315601
315686
  throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
@@ -315610,19 +315695,19 @@ var require_lunr = __commonJS((exports, module) => {
315610
315695
  throw new lunr.QueryParseError(errorMessage, nextLexeme.start, nextLexeme.end);
315611
315696
  }
315612
315697
  };
315613
- lunr.QueryParser.parseField = function(parser) {
315614
- var lexeme = parser.consumeLexeme();
315698
+ lunr.QueryParser.parseField = function(parser2) {
315699
+ var lexeme = parser2.consumeLexeme();
315615
315700
  if (lexeme == undefined) {
315616
315701
  return;
315617
315702
  }
315618
- if (parser.query.allFields.indexOf(lexeme.str) == -1) {
315619
- var possibleFields = parser.query.allFields.map(function(f3) {
315703
+ if (parser2.query.allFields.indexOf(lexeme.str) == -1) {
315704
+ var possibleFields = parser2.query.allFields.map(function(f3) {
315620
315705
  return "'" + f3 + "'";
315621
315706
  }).join(", "), errorMessage = "unrecognised field '" + lexeme.str + "', possible fields: " + possibleFields;
315622
315707
  throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
315623
315708
  }
315624
- parser.currentClause.fields = [lexeme.str];
315625
- var nextLexeme = parser.peekLexeme();
315709
+ parser2.currentClause.fields = [lexeme.str];
315710
+ var nextLexeme = parser2.peekLexeme();
315626
315711
  if (nextLexeme == undefined) {
315627
315712
  var errorMessage = "expecting term, found nothing";
315628
315713
  throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
@@ -315635,41 +315720,41 @@ var require_lunr = __commonJS((exports, module) => {
315635
315720
  throw new lunr.QueryParseError(errorMessage, nextLexeme.start, nextLexeme.end);
315636
315721
  }
315637
315722
  };
315638
- lunr.QueryParser.parseTerm = function(parser) {
315639
- var lexeme = parser.consumeLexeme();
315723
+ lunr.QueryParser.parseTerm = function(parser2) {
315724
+ var lexeme = parser2.consumeLexeme();
315640
315725
  if (lexeme == undefined) {
315641
315726
  return;
315642
315727
  }
315643
- parser.currentClause.term = lexeme.str.toLowerCase();
315728
+ parser2.currentClause.term = lexeme.str.toLowerCase();
315644
315729
  if (lexeme.str.indexOf("*") != -1) {
315645
- parser.currentClause.usePipeline = false;
315730
+ parser2.currentClause.usePipeline = false;
315646
315731
  }
315647
- var nextLexeme = parser.peekLexeme();
315732
+ var nextLexeme = parser2.peekLexeme();
315648
315733
  if (nextLexeme == undefined) {
315649
- parser.nextClause();
315734
+ parser2.nextClause();
315650
315735
  return;
315651
315736
  }
315652
315737
  switch (nextLexeme.type) {
315653
315738
  case lunr.QueryLexer.TERM:
315654
- parser.nextClause();
315739
+ parser2.nextClause();
315655
315740
  return lunr.QueryParser.parseTerm;
315656
315741
  case lunr.QueryLexer.FIELD:
315657
- parser.nextClause();
315742
+ parser2.nextClause();
315658
315743
  return lunr.QueryParser.parseField;
315659
315744
  case lunr.QueryLexer.EDIT_DISTANCE:
315660
315745
  return lunr.QueryParser.parseEditDistance;
315661
315746
  case lunr.QueryLexer.BOOST:
315662
315747
  return lunr.QueryParser.parseBoost;
315663
315748
  case lunr.QueryLexer.PRESENCE:
315664
- parser.nextClause();
315749
+ parser2.nextClause();
315665
315750
  return lunr.QueryParser.parsePresence;
315666
315751
  default:
315667
315752
  var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'";
315668
315753
  throw new lunr.QueryParseError(errorMessage, nextLexeme.start, nextLexeme.end);
315669
315754
  }
315670
315755
  };
315671
- lunr.QueryParser.parseEditDistance = function(parser) {
315672
- var lexeme = parser.consumeLexeme();
315756
+ lunr.QueryParser.parseEditDistance = function(parser2) {
315757
+ var lexeme = parser2.consumeLexeme();
315673
315758
  if (lexeme == undefined) {
315674
315759
  return;
315675
315760
  }
@@ -315678,33 +315763,33 @@ var require_lunr = __commonJS((exports, module) => {
315678
315763
  var errorMessage = "edit distance must be numeric";
315679
315764
  throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
315680
315765
  }
315681
- parser.currentClause.editDistance = editDistance;
315682
- var nextLexeme = parser.peekLexeme();
315766
+ parser2.currentClause.editDistance = editDistance;
315767
+ var nextLexeme = parser2.peekLexeme();
315683
315768
  if (nextLexeme == undefined) {
315684
- parser.nextClause();
315769
+ parser2.nextClause();
315685
315770
  return;
315686
315771
  }
315687
315772
  switch (nextLexeme.type) {
315688
315773
  case lunr.QueryLexer.TERM:
315689
- parser.nextClause();
315774
+ parser2.nextClause();
315690
315775
  return lunr.QueryParser.parseTerm;
315691
315776
  case lunr.QueryLexer.FIELD:
315692
- parser.nextClause();
315777
+ parser2.nextClause();
315693
315778
  return lunr.QueryParser.parseField;
315694
315779
  case lunr.QueryLexer.EDIT_DISTANCE:
315695
315780
  return lunr.QueryParser.parseEditDistance;
315696
315781
  case lunr.QueryLexer.BOOST:
315697
315782
  return lunr.QueryParser.parseBoost;
315698
315783
  case lunr.QueryLexer.PRESENCE:
315699
- parser.nextClause();
315784
+ parser2.nextClause();
315700
315785
  return lunr.QueryParser.parsePresence;
315701
315786
  default:
315702
315787
  var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'";
315703
315788
  throw new lunr.QueryParseError(errorMessage, nextLexeme.start, nextLexeme.end);
315704
315789
  }
315705
315790
  };
315706
- lunr.QueryParser.parseBoost = function(parser) {
315707
- var lexeme = parser.consumeLexeme();
315791
+ lunr.QueryParser.parseBoost = function(parser2) {
315792
+ var lexeme = parser2.consumeLexeme();
315708
315793
  if (lexeme == undefined) {
315709
315794
  return;
315710
315795
  }
@@ -315713,25 +315798,25 @@ var require_lunr = __commonJS((exports, module) => {
315713
315798
  var errorMessage = "boost must be numeric";
315714
315799
  throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
315715
315800
  }
315716
- parser.currentClause.boost = boost;
315717
- var nextLexeme = parser.peekLexeme();
315801
+ parser2.currentClause.boost = boost;
315802
+ var nextLexeme = parser2.peekLexeme();
315718
315803
  if (nextLexeme == undefined) {
315719
- parser.nextClause();
315804
+ parser2.nextClause();
315720
315805
  return;
315721
315806
  }
315722
315807
  switch (nextLexeme.type) {
315723
315808
  case lunr.QueryLexer.TERM:
315724
- parser.nextClause();
315809
+ parser2.nextClause();
315725
315810
  return lunr.QueryParser.parseTerm;
315726
315811
  case lunr.QueryLexer.FIELD:
315727
- parser.nextClause();
315812
+ parser2.nextClause();
315728
315813
  return lunr.QueryParser.parseField;
315729
315814
  case lunr.QueryLexer.EDIT_DISTANCE:
315730
315815
  return lunr.QueryParser.parseEditDistance;
315731
315816
  case lunr.QueryLexer.BOOST:
315732
315817
  return lunr.QueryParser.parseBoost;
315733
315818
  case lunr.QueryLexer.PRESENCE:
315734
- parser.nextClause();
315819
+ parser2.nextClause();
315735
315820
  return lunr.QueryParser.parsePresence;
315736
315821
  default:
315737
315822
  var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'";
@@ -315878,11 +315963,11 @@ function lexProseRanges(text) {
315878
315963
  });
315879
315964
  const lexerErrors = counter2();
315880
315965
  const parserErrors = counter2();
315881
- const { tokenStream, parser } = factory2(text, {
315966
+ const { tokenStream, parser: parser2 } = factory2(text, {
315882
315967
  lexerErrorListener: lexerErrors,
315883
315968
  parserErrorListener: parserErrors
315884
315969
  });
315885
- parser.malloyDocument();
315970
+ parser2.malloyDocument();
315886
315971
  const vocabulary = tokenStream.tokenSource?.vocabulary;
315887
315972
  if (lexerErrors.errors > 0 || parserErrors.errors > 0 || typeof vocabulary?.getSymbolicName !== "function")
315888
315973
  return;
@@ -338154,7 +338239,7 @@ For anything not covered here, call \`search_malloy_docs\` with the topic (for e
338154
338239
  {
338155
338240
  name: "malloy-reports",
338156
338241
  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.",
338157
- 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` and its binding. On a grid its width, row break and height can go on its entry instead, `\"overview -> kpis\" { colspan=12 rowspan=2 }`, which wins over the view's tags; `rowspan=N` is rows of 80px, and `row=N col=N` place the tile at a grid cell (a report with any `row` is placed: fixed rows, tiles where they say, empty cells allowed).\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`, `break` and `rowspan`, 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."
338242
+ 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` and its binding. On a grid its width, row break and height can go on its entry instead, `\"overview -> kpis\" { colspan=12 rowspan=2 }`, which wins over the view's tags; `rowspan=N` is rows of 80px, and `row=N col=N` place the tile at a grid cell (a report with any `row` is placed: fixed rows, tiles where they say, empty cells allowed).\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`, `break` and `rowspan`, 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.\n- **A click always opens a menu that says what it will do**, even with one entry, each entry naming the field (as its column header reads) and value: `Filter to Region: EU` (`to=self`), `Open <report title> (Region: EU)` (each `to=<path>`; the file name when the report has no title), then `Show rows (Region: EU, Month: 2025-12)` (every value of the row). A tag adds entries; it never replaces the rows.\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- **Every value offers `Show rows`**, tagged or not: the tile's source, filtered by the tile's own `where:` (its control bindings included) and every dimension of the clicked row. That works for every one-stage tile: `\"source -> view\"`, a refined view, a tile naming a `query:`, an inline query, a view grouped by a joined field, a null value. **A view of more than one stage (`{ … } -> { … }`) opens no rows**, and its cells are not marked: a later stage's dimension is an earlier output the source cannot be filtered by. Keep a table tile meant to show its rows to one stage, and put the second stage in its own tile. A source with parameters opens no rows either. Neither does an unnamed time truncation inside a view (`group_by: created_at.month`), because the result loses which truncation it was: name it (`group_by: order_month is created_at.month`) in a view meant to show its rows.\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."
338158
338243
  },
338159
338244
  {
338160
338245
  name: "malloy-reports/grid",
@@ -342702,8 +342787,8 @@ var require_luxon = __commonJS((exports) => {
342702
342787
  }
342703
342788
  }
342704
342789
  function explainFromTokens(locale, input, format) {
342705
- const parser = new TokenParser(locale, format);
342706
- return parser.explainFromTokens(input);
342790
+ const parser2 = new TokenParser(locale, format);
342791
+ return parser2.explainFromTokens(input);
342707
342792
  }
342708
342793
  function parseFromTokens(locale, input, format) {
342709
342794
  const {
@@ -357425,7 +357510,7 @@ var init_health = __esm(() => {
357425
357510
 
357426
357511
  // src/storage/duckdb/DuckDBConnection.ts
357427
357512
  import {
357428
- DuckDBInstance
357513
+ DuckDBInstance as DuckDBInstance2
357429
357514
  } from "@duckdb/node-api";
357430
357515
  import * as path15 from "path";
357431
357516
  function duckDBInstanceResourceOptions() {
@@ -357451,7 +357536,7 @@ class DuckDBConnection3 {
357451
357536
  }
357452
357537
  async initialize() {
357453
357538
  try {
357454
- this.instance = await DuckDBInstance.create(this.dbPath, duckDBInstanceResourceOptions());
357539
+ this.instance = await DuckDBInstance2.create(this.dbPath, duckDBInstanceResourceOptions());
357455
357540
  this.connection = await this.instance.connect();
357456
357541
  await this.connection.run("SELECT 42 as answer");
357457
357542
  } catch (err) {
@@ -360103,6 +360188,62 @@ var init_json_utils = __esm(() => {
360103
360188
  MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
360104
360189
  });
360105
360190
 
360191
+ // src/service/drill_metadata.ts
360192
+ import { API } from "@malloydata/malloy";
360193
+ function correctDrillMetadata(result) {
360194
+ const structs = result._queryResult?.structs;
360195
+ const root = structs?.[structs.length - 1];
360196
+ if (root === undefined)
360197
+ return false;
360198
+ if (root.resultMetadata?.sourceField === LAST_STAGE)
360199
+ root.resultMetadata.drillable = false;
360200
+ correctFields(root.fields ?? []);
360201
+ return true;
360202
+ }
360203
+ function wrapWithCorrectDrillMetadata(result) {
360204
+ const corrected = correctDrillMetadata(result);
360205
+ const wrapped = API.util.wrapResult(result);
360206
+ if (!corrected)
360207
+ return wrapped;
360208
+ wrapped.annotations = [
360209
+ ...wrapped.annotations ?? [],
360210
+ { value: `#(malloy) ${DRILL_METADATA_CORRECTED}
360211
+ ` }
360212
+ ];
360213
+ return wrapped;
360214
+ }
360215
+ function correctFields(fields) {
360216
+ for (const field of fields) {
360217
+ if (field.fields)
360218
+ correctFields(field.fields);
360219
+ const meta = field.resultMetadata;
360220
+ if (meta?.fieldKind !== "dimension")
360221
+ continue;
360222
+ const code = drillCode(field, meta);
360223
+ if (code === null)
360224
+ delete meta.fieldKind;
360225
+ else if (code !== undefined)
360226
+ meta.sourceExpression = code;
360227
+ }
360228
+ }
360229
+ function drillCode(field, meta) {
360230
+ const { sourceField, sourceExpression, drillExpression } = meta;
360231
+ if (sourceExpression)
360232
+ return sourceField?.includes(".") ? sourceField : undefined;
360233
+ if (field.timeframe === undefined)
360234
+ return;
360235
+ const reference = drillExpression?.field_reference;
360236
+ if (drillExpression?.kind === "time_truncation" && reference !== undefined && drillExpression.truncation !== undefined)
360237
+ return [
360238
+ ...reference.path ?? [],
360239
+ reference.name,
360240
+ drillExpression.truncation
360241
+ ].join(".");
360242
+ return null;
360243
+ }
360244
+ var DRILL_METADATA_CORRECTED = "drill_metadata_corrected", LAST_STAGE = "~computeLastStage~";
360245
+ var init_drill_metadata = () => {};
360246
+
360106
360247
  // src/service/statement_usage.ts
360107
360248
  function emitStatementUsage(usage) {
360108
360249
  try {
@@ -360155,7 +360296,6 @@ var init_statement_usage = __esm(() => {
360155
360296
 
360156
360297
  // src/service/query_execution.ts
360157
360298
  import {
360158
- API,
360159
360299
  MalloyError as MalloyError4
360160
360300
  } from "@malloydata/malloy";
360161
360301
  async function prepareAndRun(runnable, options, run) {
@@ -360187,7 +360327,7 @@ async function prepareAndRunUntraced(runnable, options, run) {
360187
360327
  }
360188
360328
  function serializeWithinCaps(result, caps) {
360189
360329
  assertWithinModelRowLimit(result.totalRows, caps.maxRows, caps.source);
360190
- const wrapped = API.util.wrapResult(result);
360330
+ const wrapped = wrapWithCorrectDrillMetadata(result);
360191
360331
  const serialized = stringifyQueryResponse(caps.compact ? result.data.value : wrapped, result.totalRows, caps.maxBytes, caps.source, caps.compact ? bigIntReplacer : undefined);
360192
360332
  assertWithinModelByteLimit(serialized, caps.maxBytes, caps.source);
360193
360333
  return { wrapped, serialized };
@@ -360238,6 +360378,7 @@ var init_query_execution = __esm(() => {
360238
360378
  init_errors();
360239
360379
  init_logger();
360240
360380
  init_json_utils();
360381
+ init_drill_metadata();
360241
360382
  init_model_limits();
360242
360383
  init_statement_usage();
360243
360384
  });
@@ -376935,6 +377076,13 @@ function catalogAdminConfigFromEnv(env) {
376935
377076
  if (!keyId || !secret) {
376936
377077
  throw new Error("PUBLISHER_ADMIN_STORAGE_PROVIDER=s3 needs PUBLISHER_ADMIN_STORAGE_KEY_ID and PUBLISHER_ADMIN_STORAGE_SECRET.");
376937
377078
  }
377079
+ if (env.PUBLISHER_ADMIN_STORAGE_ENDPOINT) {
377080
+ try {
377081
+ parseS3Endpoint(env.PUBLISHER_ADMIN_STORAGE_ENDPOINT);
377082
+ } catch (error) {
377083
+ throw new Error(`PUBLISHER_ADMIN_STORAGE_ENDPOINT: ${error.message}`);
377084
+ }
377085
+ }
376938
377086
  storage = {
376939
377087
  s3Connection: {
376940
377088
  provider: "config",
@@ -377457,6 +377605,7 @@ var init_catalog_admin = __esm(() => {
377457
377605
  init_logger();
377458
377606
  init_connection();
377459
377607
  init_materialization_build_session();
377608
+ init_gcs_s3_utils();
377460
377609
  init_pg_helpers();
377461
377610
  init_quoting();
377462
377611
  init_statement_usage();