@malloy-publisher/server 0.0.237 → 0.0.238

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.mjs CHANGED
@@ -156044,6 +156044,8 @@ function internalErrorToHttpError(error) {
156044
156044
  return httpError(413, error.message);
156045
156045
  } else if (error instanceof QueryTimeoutError) {
156046
156046
  return httpError(504, error.message);
156047
+ } else if (error instanceof NotImplementedError) {
156048
+ return httpError(501, error.message);
156047
156049
  } else {
156048
156050
  return httpError(500, error.message);
156049
156051
  }
@@ -156057,7 +156059,7 @@ function httpError(code, message) {
156057
156059
  }
156058
156060
  };
156059
156061
  }
156060
- var NotImplementedError, BadRequestError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, ConnectionNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, MaterializationEligibilityError, FrozenConfigError, AccessDeniedError, NotQueryableError, MaterializationNotFoundError, MaterializationConflictError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
156062
+ var NotImplementedError, BadRequestError, InvalidArgumentError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, ConnectionNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, MaterializationEligibilityError, FrozenConfigError, AccessDeniedError, NotQueryableError, MaterializationNotFoundError, MaterializationConflictError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
156061
156063
  var init_errors = __esm(() => {
156062
156064
  init_constants();
156063
156065
  NotImplementedError = class NotImplementedError extends Error {
@@ -156070,6 +156072,8 @@ var init_errors = __esm(() => {
156070
156072
  super(message);
156071
156073
  }
156072
156074
  };
156075
+ InvalidArgumentError = class InvalidArgumentError extends BadRequestError {
156076
+ };
156073
156077
  EnvironmentNotFoundError = class EnvironmentNotFoundError extends Error {
156074
156078
  constructor(message) {
156075
156079
  super(message);
@@ -241890,7 +241894,7 @@ var require_luxon = __commonJS((exports) => {
241890
241894
  }
241891
241895
  }
241892
241896
 
241893
- class InvalidArgumentError extends LuxonError {
241897
+ class InvalidArgumentError2 extends LuxonError {
241894
241898
  }
241895
241899
 
241896
241900
  class ZoneIsAbstractError extends LuxonError {
@@ -243233,10 +243237,10 @@ var require_luxon = __commonJS((exports) => {
243233
243237
  if (settings == null) {
243234
243238
  return null;
243235
243239
  } else if (typeof settings !== "object") {
243236
- throw new InvalidArgumentError("Week settings must be an object");
243240
+ throw new InvalidArgumentError2("Week settings must be an object");
243237
243241
  } else {
243238
243242
  if (!integerBetween(settings.firstDay, 1, 7) || !integerBetween(settings.minimalDays, 1, 7) || !Array.isArray(settings.weekend) || settings.weekend.some((v) => !integerBetween(v, 1, 7))) {
243239
- throw new InvalidArgumentError("Invalid week settings");
243243
+ throw new InvalidArgumentError2("Invalid week settings");
243240
243244
  }
243241
243245
  return {
243242
243246
  firstDay: settings.firstDay,
@@ -243367,7 +243371,7 @@ var require_luxon = __commonJS((exports) => {
243367
243371
  function asNumber2(value) {
243368
243372
  const numericValue = Number(value);
243369
243373
  if (typeof value === "boolean" || value === "" || !Number.isFinite(numericValue))
243370
- throw new InvalidArgumentError(`Invalid unit value ${value}`);
243374
+ throw new InvalidArgumentError2(`Invalid unit value ${value}`);
243371
243375
  return numericValue;
243372
243376
  }
243373
243377
  function normalizeObject(obj, normalizer) {
@@ -244213,7 +244217,7 @@ var require_luxon = __commonJS((exports) => {
244213
244217
  }
244214
244218
  static fromObject(obj, opts = {}) {
244215
244219
  if (obj == null || typeof obj !== "object") {
244216
- throw new InvalidArgumentError(`Duration.fromObject: argument expected to be an object, got ${obj === null ? "null" : typeof obj}`);
244220
+ throw new InvalidArgumentError2(`Duration.fromObject: argument expected to be an object, got ${obj === null ? "null" : typeof obj}`);
244217
244221
  }
244218
244222
  return new Duration({
244219
244223
  values: normalizeObject(obj, Duration.normalizeUnit),
@@ -244230,7 +244234,7 @@ var require_luxon = __commonJS((exports) => {
244230
244234
  } else if (typeof durationLike === "object") {
244231
244235
  return Duration.fromObject(durationLike);
244232
244236
  } else {
244233
- throw new InvalidArgumentError(`Unknown duration argument ${durationLike} of type ${typeof durationLike}`);
244237
+ throw new InvalidArgumentError2(`Unknown duration argument ${durationLike} of type ${typeof durationLike}`);
244234
244238
  }
244235
244239
  }
244236
244240
  static fromISO(text, opts) {
@@ -244251,7 +244255,7 @@ var require_luxon = __commonJS((exports) => {
244251
244255
  }
244252
244256
  static invalid(reason, explanation = null) {
244253
244257
  if (!reason) {
244254
- throw new InvalidArgumentError("need to specify a reason the Duration is invalid");
244258
+ throw new InvalidArgumentError2("need to specify a reason the Duration is invalid");
244255
244259
  }
244256
244260
  const invalid = reason instanceof Invalid ? reason : new Invalid(reason, explanation);
244257
244261
  if (Settings.throwOnInvalid) {
@@ -244616,7 +244620,7 @@ var require_luxon = __commonJS((exports) => {
244616
244620
  }
244617
244621
  static invalid(reason, explanation = null) {
244618
244622
  if (!reason) {
244619
- throw new InvalidArgumentError("need to specify a reason the Interval is invalid");
244623
+ throw new InvalidArgumentError2("need to specify a reason the Interval is invalid");
244620
244624
  }
244621
244625
  const invalid = reason instanceof Invalid ? reason : new Invalid(reason, explanation);
244622
244626
  if (Settings.throwOnInvalid) {
@@ -245887,7 +245891,7 @@ var require_luxon = __commonJS((exports) => {
245887
245891
  }
245888
245892
  static fromMillis(milliseconds, options = {}) {
245889
245893
  if (!isNumber2(milliseconds)) {
245890
- throw new InvalidArgumentError(`fromMillis requires a numerical input, but received a ${typeof milliseconds} with value ${milliseconds}`);
245894
+ throw new InvalidArgumentError2(`fromMillis requires a numerical input, but received a ${typeof milliseconds} with value ${milliseconds}`);
245891
245895
  } else if (milliseconds < -MAX_DATE || milliseconds > MAX_DATE) {
245892
245896
  return DateTime.invalid("Timestamp out of range");
245893
245897
  } else {
@@ -245900,7 +245904,7 @@ var require_luxon = __commonJS((exports) => {
245900
245904
  }
245901
245905
  static fromSeconds(seconds, options = {}) {
245902
245906
  if (!isNumber2(seconds)) {
245903
- throw new InvalidArgumentError("fromSeconds requires a numerical input");
245907
+ throw new InvalidArgumentError2("fromSeconds requires a numerical input");
245904
245908
  } else {
245905
245909
  return new DateTime({
245906
245910
  ts: seconds * 1000,
@@ -245985,7 +245989,7 @@ var require_luxon = __commonJS((exports) => {
245985
245989
  }
245986
245990
  static fromFormat(text, fmt, opts = {}) {
245987
245991
  if (isUndefined2(text) || isUndefined2(fmt)) {
245988
- throw new InvalidArgumentError("fromFormat requires an input string and a format");
245992
+ throw new InvalidArgumentError2("fromFormat requires an input string and a format");
245989
245993
  }
245990
245994
  const {
245991
245995
  locale = null,
@@ -246010,7 +246014,7 @@ var require_luxon = __commonJS((exports) => {
246010
246014
  }
246011
246015
  static invalid(reason, explanation = null) {
246012
246016
  if (!reason) {
246013
- throw new InvalidArgumentError("need to specify a reason the DateTime is invalid");
246017
+ throw new InvalidArgumentError2("need to specify a reason the DateTime is invalid");
246014
246018
  }
246015
246019
  const invalid = reason instanceof Invalid ? reason : new Invalid(reason, explanation);
246016
246020
  if (Settings.throwOnInvalid) {
@@ -246570,13 +246574,13 @@ var require_luxon = __commonJS((exports) => {
246570
246574
  }
246571
246575
  static min(...dateTimes) {
246572
246576
  if (!dateTimes.every(DateTime.isDateTime)) {
246573
- throw new InvalidArgumentError("min requires all arguments be DateTimes");
246577
+ throw new InvalidArgumentError2("min requires all arguments be DateTimes");
246574
246578
  }
246575
246579
  return bestBy(dateTimes, (i) => i.valueOf(), Math.min);
246576
246580
  }
246577
246581
  static max(...dateTimes) {
246578
246582
  if (!dateTimes.every(DateTime.isDateTime)) {
246579
- throw new InvalidArgumentError("max requires all arguments be DateTimes");
246583
+ throw new InvalidArgumentError2("max requires all arguments be DateTimes");
246580
246584
  }
246581
246585
  return bestBy(dateTimes, (i) => i.valueOf(), Math.max);
246582
246586
  }
@@ -246607,7 +246611,7 @@ var require_luxon = __commonJS((exports) => {
246607
246611
  }
246608
246612
  static fromFormatParser(text, formatParser, opts = {}) {
246609
246613
  if (isUndefined2(text) || isUndefined2(formatParser)) {
246610
- throw new InvalidArgumentError("fromFormatParser requires an input string and a format parser");
246614
+ throw new InvalidArgumentError2("fromFormatParser requires an input string and a format parser");
246611
246615
  }
246612
246616
  const {
246613
246617
  locale = null,
@@ -246618,7 +246622,7 @@ var require_luxon = __commonJS((exports) => {
246618
246622
  defaultToEN: true
246619
246623
  });
246620
246624
  if (!localeToUse.equals(formatParser.locale)) {
246621
- throw new InvalidArgumentError(`fromFormatParser called with a locale of ${localeToUse}, ` + `but the format parser was created for ${formatParser.locale}`);
246625
+ throw new InvalidArgumentError2(`fromFormatParser called with a locale of ${localeToUse}, ` + `but the format parser was created for ${formatParser.locale}`);
246622
246626
  }
246623
246627
  const {
246624
246628
  result,
@@ -246707,7 +246711,7 @@ var require_luxon = __commonJS((exports) => {
246707
246711
  } else if (dateTimeish && typeof dateTimeish === "object") {
246708
246712
  return DateTime.fromObject(dateTimeish);
246709
246713
  } else {
246710
- throw new InvalidArgumentError(`Unknown datetime argument: ${dateTimeish}, of type ${typeof dateTimeish}`);
246714
+ throw new InvalidArgumentError2(`Unknown datetime argument: ${dateTimeish}, of type ${typeof dateTimeish}`);
246711
246715
  }
246712
246716
  }
246713
246717
  var VERSION3 = "3.7.2";
@@ -261369,6 +261373,7 @@ var getEmbeddingConfig = () => {
261369
261373
  }
261370
261374
  return { apiKey, model, baseUrl, dimensions };
261371
261375
  };
261376
+ var schemaEmbeddingEnabled = () => parseBoolEnv("EMBEDDING_INDEX_CONNECTION_SCHEMA") ?? false;
261372
261377
  var DEFAULT_SCHEDULER_INTERVAL_MS = 60000;
261373
261378
  var MIN_SCHEDULER_INTERVAL_MS = 1000;
261374
261379
  var DEFAULT_SCHEDULER_MAX_FIRES_PER_TICK = 10;
@@ -261763,7 +261768,7 @@ function ensureCapTelemetry() {
261763
261768
  const meter2 = publisherMeter();
261764
261769
  if (!capExceededCounter) {
261765
261770
  capExceededCounter = meter2.createCounter("publisher_query_cap_exceeded_total", {
261766
- description: "Queries rejected with 413 because the row or byte cap was exceeded. Labels: cap_type ('rows'|'bytes'), source ('connection_sql'|'model_query'|'notebook_cell')."
261771
+ description: "413s for an oversized response. cap_type: rows|bytes exceeded that cap; unserializable could not be turned into JSON at all, so no cap need have been exceeded. source: connection_sql|model_query|notebook_cell."
261767
261772
  });
261768
261773
  }
261769
261774
  if (!configGaugesInstalled) {
@@ -267478,6 +267483,7 @@ class ConnectionService {
267478
267483
  }
267479
267484
 
267480
267485
  // src/service/db_utils.ts
267486
+ init_errors();
267481
267487
  init_logger();
267482
267488
  var import_bigquery = __toESM(require_src121(), 1);
267483
267489
  import { ClientSecretCredential } from "@azure/identity";
@@ -267486,6 +267492,50 @@ import { ContainerClient } from "@azure/storage-blob";
267486
267492
  // src/service/gcs_s3_utils.ts
267487
267493
  init_logger();
267488
267494
  var import_client_s3 = __toESM(require_dist_cjs75(), 1);
267495
+
267496
+ // src/service/introspection_sql.ts
267497
+ init_logger();
267498
+ var BACKSLASH_ESCAPE_DIALECTS = new Set(["databricks", "mysql", "snowflake"]);
267499
+ var UNSUPPORTED_LITERAL_DIALECTS = new Map([
267500
+ [
267501
+ "bigquery",
267502
+ "GoogleSQL does not accept '' as an escaped quote, so this function cannot build a correct BigQuery literal. BigQuery introspection goes through the @google-cloud/bigquery client instead of building SQL."
267503
+ ]
267504
+ ]);
267505
+ var DOUBLED_ESCAPE_DIALECTS = new Set([
267506
+ "ducklake",
267507
+ "duckdb",
267508
+ "motherduck",
267509
+ "postgres",
267510
+ "publisher",
267511
+ "trino"
267512
+ ]);
267513
+ function sqlLiteral(value, connectionType) {
267514
+ const dialect = (connectionType ?? "").toLowerCase();
267515
+ const unsupported = UNSUPPORTED_LITERAL_DIALECTS.get(dialect);
267516
+ if (unsupported) {
267517
+ throw new Error(`Cannot build a SQL literal for "${dialect}". ${unsupported}`);
267518
+ }
267519
+ const backslash = BACKSLASH_ESCAPE_DIALECTS.has(dialect);
267520
+ if (dialect && !backslash && !DOUBLED_ESCAPE_DIALECTS.has(dialect)) {
267521
+ throw new Error(`Unclassified SQL dialect "${connectionType}": add it to BACKSLASH_ESCAPE_DIALECTS or DOUBLED_ESCAPE_DIALECTS in introspection_sql.ts before building SQL for it, or to UNSUPPORTED_LITERAL_DIALECTS if, like BigQuery, it does not accept '' as an escaped quote.`);
267522
+ }
267523
+ const escaped = backslash ? value.replace(/\\/g, "\\\\") : value;
267524
+ return escaped.replace(/'/g, "''");
267525
+ }
267526
+ var INTROSPECTION_ROW_LIMIT = 1e5;
267527
+ async function runIntrospectionSQL(malloyConnection, sql) {
267528
+ const result = await malloyConnection.runSQL(sql, {
267529
+ rowLimit: INTROSPECTION_ROW_LIMIT
267530
+ });
267531
+ const rowCount = Array.isArray(result) ? result.length : result?.rows?.length ?? 0;
267532
+ if (rowCount === INTROSPECTION_ROW_LIMIT) {
267533
+ logger.warn("Schema introspection hit the row cap; the result may be truncated", { rowLimit: INTROSPECTION_ROW_LIMIT });
267534
+ }
267535
+ return result;
267536
+ }
267537
+
267538
+ // src/service/gcs_s3_utils.ts
267489
267539
  function gcsConnectionToCredentials(gcsConnection) {
267490
267540
  return {
267491
267541
  type: "gcs",
@@ -267604,22 +267654,22 @@ async function getTableSchema(malloyConnection, credentials, bucketName, fileKey
267604
267654
  let describeQuery;
267605
267655
  switch (fileType) {
267606
267656
  case "csv":
267607
- describeQuery = `DESCRIBE SELECT * FROM read_csv('${uri}', auto_detect=true) LIMIT 1`;
267657
+ describeQuery = `DESCRIBE SELECT * FROM read_csv('${sqlLiteral(uri, "duckdb")}', auto_detect=true) LIMIT 1`;
267608
267658
  break;
267609
267659
  case "parquet":
267610
- describeQuery = `DESCRIBE SELECT * FROM read_parquet('${uri}') LIMIT 1`;
267660
+ describeQuery = `DESCRIBE SELECT * FROM read_parquet('${sqlLiteral(uri, "duckdb")}') LIMIT 1`;
267611
267661
  break;
267612
267662
  case "json":
267613
- describeQuery = `DESCRIBE SELECT * FROM read_json('${uri}', auto_detect=true) LIMIT 1`;
267663
+ describeQuery = `DESCRIBE SELECT * FROM read_json('${sqlLiteral(uri, "duckdb")}', auto_detect=true) LIMIT 1`;
267614
267664
  break;
267615
267665
  case "jsonl":
267616
- describeQuery = `DESCRIBE SELECT * FROM read_json('${uri}', format='newline_delimited', auto_detect=true) LIMIT 1`;
267666
+ describeQuery = `DESCRIBE SELECT * FROM read_json('${sqlLiteral(uri, "duckdb")}', format='newline_delimited', auto_detect=true) LIMIT 1`;
267617
267667
  break;
267618
267668
  default:
267619
267669
  logger.warn(`Unsupported file type for ${fileKey}`);
267620
267670
  return { resource: uri, columns: [] };
267621
267671
  }
267622
- const result = await malloyConnection.runSQL(describeQuery);
267672
+ const result = await runIntrospectionSQL(malloyConnection, describeQuery);
267623
267673
  const rows = standardizeRunSQLResult(result);
267624
267674
  const columns = rows.map((row) => {
267625
267675
  const typedRow = row;
@@ -267727,12 +267777,19 @@ async function listCloudDirectorySchemas(credentials) {
267727
267777
  }
267728
267778
 
267729
267779
  // src/service/db_utils.ts
267730
- function sqlInFilter(columnName, values) {
267780
+ function sqlInFilter(columnName, values, connectionType) {
267731
267781
  if (!values || values.length === 0)
267732
267782
  return "";
267733
- const escaped = values.map((v) => `'${v.replace(/'/g, "''")}'`);
267783
+ const escaped = values.map((v) => `'${sqlLiteral(v, connectionType)}'`);
267734
267784
  return `AND ${columnName} IN (${escaped.join(", ")})`;
267735
267785
  }
267786
+ var SAFE_SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
267787
+ function assertSafeSqlIdentifier(value, what) {
267788
+ if (!SAFE_SQL_IDENTIFIER.test(value)) {
267789
+ throw new InvalidArgumentError(`Invalid ${what} "${value}": expected a plain identifier (letters, digits, underscore or dollar, not starting with a digit).`);
267790
+ }
267791
+ return value;
267792
+ }
267736
267793
  function groupColumnRowsIntoTables(rows, buildResource) {
267737
267794
  const tableMap = new Map;
267738
267795
  for (const row of rows) {
@@ -267820,7 +267877,7 @@ async function getSchemasForPostgres(connection, malloyConnection) {
267820
267877
  throw new Error("Postgres connection is required");
267821
267878
  }
267822
267879
  try {
267823
- const result = await malloyConnection.runSQL("SELECT row_to_json(t) as row FROM (SELECT schema_name FROM information_schema.schemata ORDER BY schema_name) t");
267880
+ const result = await runIntrospectionSQL(malloyConnection, "SELECT row_to_json(t) as row FROM (SELECT schema_name FROM information_schema.schemata ORDER BY schema_name) t");
267824
267881
  const rows = standardizeRunSQLResult2(result);
267825
267882
  return rows.map((row) => {
267826
267883
  const typedRow = row;
@@ -267857,13 +267914,13 @@ async function getSchemasForSnowflake(connection, malloyConnection) {
267857
267914
  const schema = connection.snowflakeConnection.schema;
267858
267915
  const filters = [];
267859
267916
  if (database) {
267860
- filters.push(`CATALOG_NAME = '${database}'`);
267917
+ filters.push(`CATALOG_NAME = '${sqlLiteral(database, connection.type)}'`);
267861
267918
  }
267862
267919
  if (schema) {
267863
- filters.push(`SCHEMA_NAME = '${schema}'`);
267920
+ filters.push(`SCHEMA_NAME = '${sqlLiteral(schema, connection.type)}'`);
267864
267921
  }
267865
267922
  const whereClause = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
267866
- const result = await malloyConnection.runSQL(`SELECT CATALOG_NAME, SCHEMA_NAME, SCHEMA_OWNER FROM ${database ? `${database}.` : ""}INFORMATION_SCHEMA.SCHEMATA ${whereClause} ORDER BY SCHEMA_NAME`);
267923
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT CATALOG_NAME, SCHEMA_NAME, SCHEMA_OWNER FROM ${database ? `${database}.` : ""}INFORMATION_SCHEMA.SCHEMATA ${whereClause} ORDER BY SCHEMA_NAME`);
267867
267924
  const rows = standardizeRunSQLResult2(result);
267868
267925
  return rows.map((row) => {
267869
267926
  const typedRow = row;
@@ -267890,7 +267947,7 @@ async function getSchemasForTrino(connection, malloyConnection) {
267890
267947
  let allRows = [];
267891
267948
  if (connection.trinoConnection.catalog) {
267892
267949
  const catalog = connection.trinoConnection.catalog;
267893
- const result = await malloyConnection.runSQL(`SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`);
267950
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT schema_name FROM ${assertSafeSqlIdentifier(catalog, "catalog name")}.information_schema.schemata ORDER BY schema_name`);
267894
267951
  const rows = standardizeRunSQLResult2(result);
267895
267952
  allRows = rows.map((row) => {
267896
267953
  const r = row;
@@ -267900,14 +267957,14 @@ async function getSchemasForTrino(connection, malloyConnection) {
267900
267957
  };
267901
267958
  });
267902
267959
  } else {
267903
- const catalogsResult = await malloyConnection.runSQL(`SHOW CATALOGS`);
267960
+ const catalogsResult = await runIntrospectionSQL(malloyConnection, `SHOW CATALOGS`);
267904
267961
  const catalogNames = standardizeRunSQLResult2(catalogsResult).map((row) => {
267905
267962
  const r = row;
267906
267963
  return String(r.Catalog ?? r.catalog ?? "");
267907
267964
  });
267908
267965
  for (const catalog of catalogNames) {
267909
267966
  try {
267910
- const result = await malloyConnection.runSQL(`SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`);
267967
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT schema_name FROM ${assertSafeSqlIdentifier(catalog, "catalog name")}.information_schema.schemata ORDER BY schema_name`);
267911
267968
  const rows = standardizeRunSQLResult2(result);
267912
267969
  for (const row of rows) {
267913
267970
  const r = row;
@@ -267930,6 +267987,8 @@ async function getSchemasForTrino(connection, malloyConnection) {
267930
267987
  };
267931
267988
  });
267932
267989
  } catch (error) {
267990
+ if (error instanceof BadRequestError)
267991
+ throw error;
267933
267992
  logger.error(`Error getting schemas for Trino connection ${connection.name}`, { error });
267934
267993
  throw new Error(`Failed to get schemas for Trino connection ${connection.name}: ${error.message}`);
267935
267994
  }
@@ -267943,7 +268002,7 @@ async function getSchemasForDatabricks(connection, malloyConnection) {
267943
268002
  let allRows = [];
267944
268003
  if (connection.databricksConnection.defaultCatalog) {
267945
268004
  const catalog = connection.databricksConnection.defaultCatalog;
267946
- const result = await malloyConnection.runSQL(`SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`);
268005
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT schema_name FROM ${assertSafeSqlIdentifier(catalog, "catalog name")}.information_schema.schemata ORDER BY schema_name`);
267947
268006
  const rows = standardizeRunSQLResult2(result);
267948
268007
  allRows = rows.map((row) => {
267949
268008
  const r = row;
@@ -267953,14 +268012,14 @@ async function getSchemasForDatabricks(connection, malloyConnection) {
267953
268012
  };
267954
268013
  });
267955
268014
  } else {
267956
- const catalogsResult = await malloyConnection.runSQL(`SHOW CATALOGS`);
268015
+ const catalogsResult = await runIntrospectionSQL(malloyConnection, `SHOW CATALOGS`);
267957
268016
  const catalogNames = standardizeRunSQLResult2(catalogsResult).map((row) => {
267958
268017
  const r = row;
267959
268018
  return String(r.catalog ?? r.Catalog ?? r.catalog_name ?? "");
267960
268019
  });
267961
268020
  for (const catalog of catalogNames) {
267962
268021
  try {
267963
- const result = await malloyConnection.runSQL(`SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`);
268022
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT schema_name FROM ${assertSafeSqlIdentifier(catalog, "catalog name")}.information_schema.schemata ORDER BY schema_name`);
267964
268023
  const rows = standardizeRunSQLResult2(result);
267965
268024
  for (const row of rows) {
267966
268025
  const r = row;
@@ -267984,6 +268043,8 @@ async function getSchemasForDatabricks(connection, malloyConnection) {
267984
268043
  };
267985
268044
  });
267986
268045
  } catch (error) {
268046
+ if (error instanceof BadRequestError)
268047
+ throw error;
267987
268048
  logger.error(`Error getting schemas for Databricks connection ${connection.name}`, { error });
267988
268049
  throw new Error(`Failed to get schemas for Databricks connection ${connection.name}: ${error.message}`);
267989
268050
  }
@@ -267993,7 +268054,7 @@ async function getSchemasForDuckDB(connection, malloyConnection) {
267993
268054
  throw new Error("DuckDB connection is required");
267994
268055
  }
267995
268056
  try {
267996
- const result = await malloyConnection.runSQL("SELECT DISTINCT schema_name,catalog_name FROM information_schema.schemata ORDER BY catalog_name,schema_name", { rowLimit: 1000 });
268057
+ const result = await runIntrospectionSQL(malloyConnection, "SELECT DISTINCT schema_name,catalog_name FROM information_schema.schemata ORDER BY catalog_name,schema_name");
267997
268058
  const rows = standardizeRunSQLResult2(result);
267998
268059
  const schemas = rows.map((row) => {
267999
268060
  const typedRow = row;
@@ -268049,8 +268110,8 @@ async function getSchemasForMotherDuck(connection, malloyConnection) {
268049
268110
  }
268050
268111
  try {
268051
268112
  const database = connection.motherduckConnection.database;
268052
- const whereClause = database ? `WHERE catalog_name = '${database}'` : "";
268053
- const result = await malloyConnection.runSQL(`SELECT DISTINCT schema_name FROM information_schema.schemata ${whereClause} ORDER BY schema_name`);
268113
+ const whereClause = database ? `WHERE catalog_name = '${sqlLiteral(database, connection.type)}'` : "";
268114
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT DISTINCT schema_name FROM information_schema.schemata ${whereClause} ORDER BY schema_name`);
268054
268115
  const rows = standardizeRunSQLResult2(result);
268055
268116
  return rows.map((row) => {
268056
268117
  const typedRow = row;
@@ -268068,8 +268129,8 @@ async function getSchemasForMotherDuck(connection, malloyConnection) {
268068
268129
  }
268069
268130
  async function getSchemasForDuckLake(connection, malloyConnection) {
268070
268131
  try {
268071
- const catalogName = connection.name;
268072
- const result = await malloyConnection.runSQL(`SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${catalogName}' ORDER BY schema_name`, { rowLimit: 1000 });
268132
+ const catalogName = connection.name ?? "";
268133
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${sqlLiteral(catalogName, connection.type)}' ORDER BY schema_name`);
268073
268134
  const rows = standardizeRunSQLResult2(result);
268074
268135
  return rows.map((row) => {
268075
268136
  const typedRow = row;
@@ -268260,28 +268321,29 @@ function isDataFile2(key) {
268260
268321
  const lowerKey = key.toLowerCase();
268261
268322
  return lowerKey.endsWith(".csv") || lowerKey.endsWith(".parquet") || lowerKey.endsWith(".json") || lowerKey.endsWith(".jsonl") || lowerKey.endsWith(".ndjson");
268262
268323
  }
268324
+ var DUCKDB_DIALECT = "duckdb";
268263
268325
  async function describeRemoteFile(malloyConnection, fileUri) {
268264
268326
  const pathWithoutQuery = fileUri.split("?")[0];
268265
268327
  const fileType = getFileType2(pathWithoutQuery);
268266
268328
  let describeQuery;
268267
268329
  switch (fileType) {
268268
268330
  case "csv":
268269
- describeQuery = `DESCRIBE SELECT * FROM read_csv('${fileUri}', auto_detect=true) LIMIT 1`;
268331
+ describeQuery = `DESCRIBE SELECT * FROM read_csv('${sqlLiteral(fileUri, DUCKDB_DIALECT)}', auto_detect=true) LIMIT 1`;
268270
268332
  break;
268271
268333
  case "parquet":
268272
- describeQuery = `DESCRIBE SELECT * FROM read_parquet('${fileUri}') LIMIT 1`;
268334
+ describeQuery = `DESCRIBE SELECT * FROM read_parquet('${sqlLiteral(fileUri, DUCKDB_DIALECT)}') LIMIT 1`;
268273
268335
  break;
268274
268336
  case "json":
268275
- describeQuery = `DESCRIBE SELECT * FROM read_json('${fileUri}', auto_detect=true) LIMIT 1`;
268337
+ describeQuery = `DESCRIBE SELECT * FROM read_json('${sqlLiteral(fileUri, DUCKDB_DIALECT)}', auto_detect=true) LIMIT 1`;
268276
268338
  break;
268277
268339
  case "jsonl":
268278
- describeQuery = `DESCRIBE SELECT * FROM read_json('${fileUri}', format='newline_delimited', auto_detect=true) LIMIT 1`;
268340
+ describeQuery = `DESCRIBE SELECT * FROM read_json('${sqlLiteral(fileUri, DUCKDB_DIALECT)}', format='newline_delimited', auto_detect=true) LIMIT 1`;
268279
268341
  break;
268280
268342
  default:
268281
268343
  logger.warn(`Unsupported file type for file: ${fileUri}`);
268282
268344
  return { resource: fileUri, columns: [] };
268283
268345
  }
268284
- const result = await malloyConnection.runSQL(describeQuery);
268346
+ const result = await runIntrospectionSQL(malloyConnection, describeQuery);
268285
268347
  const rows = standardizeRunSQLResult2(result);
268286
268348
  const columns = rows.map((row) => {
268287
268349
  const typedRow = row;
@@ -268394,7 +268456,7 @@ async function listTablesForMySQL(connection, schemaName, malloyConnection, tabl
268394
268456
  throw new Error("Mysql connection is required");
268395
268457
  }
268396
268458
  try {
268397
- const result = await malloyConnection.runSQL(`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("TABLE_NAME", tableNames)} ORDER BY TABLE_NAME, ORDINAL_POSITION`);
268459
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM information_schema.columns WHERE table_schema = '${sqlLiteral(schemaName, connection.type)}' ${sqlInFilter("TABLE_NAME", tableNames, connection.type)} ORDER BY TABLE_NAME, ORDINAL_POSITION`);
268398
268460
  const rows = standardizeRunSQLResult2(result);
268399
268461
  return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
268400
268462
  } catch (error) {
@@ -268407,7 +268469,7 @@ async function listTablesForPostgres(connection, schemaName, malloyConnection, t
268407
268469
  throw new Error("Postgres connection is required");
268408
268470
  }
268409
268471
  try {
268410
- const result = await malloyConnection.runSQL(`SELECT row_to_json(t) as row FROM (SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position) t`);
268472
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT row_to_json(t) as row FROM (SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${sqlLiteral(schemaName, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position) t`);
268411
268473
  const rows = standardizeRunSQLResult2(result);
268412
268474
  return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
268413
268475
  } catch (error) {
@@ -268433,12 +268495,15 @@ async function listTablesForSnowflake(connection, schemaName, malloyConnection,
268433
268495
  if (!databaseName) {
268434
268496
  throw new Error(`Cannot resolve database for schema "${schemaName}": provide DATABASE.SCHEMA or configure a database on the connection`);
268435
268497
  }
268498
+ assertSafeSqlIdentifier(databaseName, "database name");
268436
268499
  const qualifiedSchema = `${databaseName}.${schemaOnly}`;
268437
- const result = await malloyConnection.runSQL(`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM ${databaseName}.INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '${schemaOnly}' ${sqlInFilter("TABLE_NAME", tableNames)} ORDER BY TABLE_NAME, ORDINAL_POSITION`);
268500
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM ${databaseName}.INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '${sqlLiteral(schemaOnly, connection.type)}' ${sqlInFilter("TABLE_NAME", tableNames, connection.type)} ORDER BY TABLE_NAME, ORDINAL_POSITION`);
268438
268501
  const rows = standardizeRunSQLResult2(result);
268439
268502
  return groupColumnRowsIntoTables(rows, (t) => `${qualifiedSchema}.${t}`);
268440
268503
  } catch (error) {
268441
268504
  logger.error(`Error getting tables for Snowflake schema ${schemaName} in connection ${connection.name}`, { error });
268505
+ if (error instanceof BadRequestError)
268506
+ throw error;
268442
268507
  throw new Error(`Failed to get tables for Snowflake schema ${schemaName} in connection ${connection.name}: ${error.message}`);
268443
268508
  }
268444
268509
  }
@@ -268457,7 +268522,7 @@ async function listTablesForTrino(connection, schemaName, malloyConnection, tabl
268457
268522
  } else {
268458
268523
  const dotIdx = schemaName.indexOf(".");
268459
268524
  if (dotIdx > 0) {
268460
- catalogPrefix = `${schemaName.substring(0, dotIdx)}.`;
268525
+ catalogPrefix = `${assertSafeSqlIdentifier(schemaName.substring(0, dotIdx), "catalog name")}.`;
268461
268526
  schemaOnly = schemaName.substring(dotIdx + 1);
268462
268527
  } else {
268463
268528
  catalogPrefix = "";
@@ -268465,11 +268530,13 @@ async function listTablesForTrino(connection, schemaName, malloyConnection, tabl
268465
268530
  }
268466
268531
  resourcePrefix = schemaName;
268467
268532
  }
268468
- const result = await malloyConnection.runSQL(`SELECT table_name, column_name, data_type FROM ${catalogPrefix}information_schema.columns WHERE table_schema = '${schemaOnly}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`);
268533
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT table_name, column_name, data_type FROM ${catalogPrefix}information_schema.columns WHERE table_schema = '${sqlLiteral(schemaOnly, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position`);
268469
268534
  const rows = standardizeRunSQLResult2(result);
268470
268535
  return groupColumnRowsIntoTables(rows, (t) => `${resourcePrefix}.${t}`);
268471
268536
  } catch (error) {
268472
268537
  logger.error(`Error getting tables for Trino schema ${schemaName} in connection ${connection.name}`, { error });
268538
+ if (error instanceof BadRequestError)
268539
+ throw error;
268473
268540
  throw new Error(`Failed to get tables for Trino schema ${schemaName} in connection ${connection.name}: ${error.message}`);
268474
268541
  }
268475
268542
  }
@@ -268488,7 +268555,7 @@ async function listTablesForDatabricks(connection, schemaName, malloyConnection,
268488
268555
  } else {
268489
268556
  const dotIdx = schemaName.indexOf(".");
268490
268557
  if (dotIdx > 0) {
268491
- catalogPrefix = `${schemaName.substring(0, dotIdx)}.`;
268558
+ catalogPrefix = `${assertSafeSqlIdentifier(schemaName.substring(0, dotIdx), "catalog name")}.`;
268492
268559
  schemaOnly = schemaName.substring(dotIdx + 1);
268493
268560
  } else {
268494
268561
  catalogPrefix = "";
@@ -268496,11 +268563,13 @@ async function listTablesForDatabricks(connection, schemaName, malloyConnection,
268496
268563
  }
268497
268564
  resourcePrefix = schemaName;
268498
268565
  }
268499
- const result = await malloyConnection.runSQL(`SELECT table_name, column_name, data_type FROM ${catalogPrefix}information_schema.columns WHERE table_schema = '${schemaOnly}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`);
268566
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT table_name, column_name, data_type FROM ${catalogPrefix}information_schema.columns WHERE table_schema = '${sqlLiteral(schemaOnly, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position`);
268500
268567
  const rows = standardizeRunSQLResult2(result);
268501
268568
  return groupColumnRowsIntoTables(rows, (t) => `${resourcePrefix}.${t}`);
268502
268569
  } catch (error) {
268503
268570
  logger.error(`Error getting tables for Databricks schema ${schemaName} in connection ${connection.name}`, { error });
268571
+ if (error instanceof BadRequestError)
268572
+ throw error;
268504
268573
  throw new Error(`Failed to get tables for Databricks schema ${schemaName} in connection ${connection.name}: ${error.message}`);
268505
268574
  }
268506
268575
  }
@@ -268536,12 +268605,12 @@ async function listTablesForDuckDB(connection, schemaName, malloyConnection, tab
268536
268605
  }
268537
268606
  const dotIdx = schemaName.indexOf(".");
268538
268607
  if (dotIdx < 0) {
268539
- throw new Error(`DuckDB schema name must be qualified as "catalog.schema", got "${schemaName}"`);
268608
+ throw new InvalidArgumentError(`DuckDB schema name must be qualified as "catalog.schema", got "${schemaName}". List this connection's schemas and use one of those names verbatim.`);
268540
268609
  }
268541
268610
  const catalogName = schemaName.substring(0, dotIdx);
268542
268611
  const actualSchemaName = schemaName.substring(dotIdx + 1);
268543
268612
  try {
268544
- const result = await malloyConnection.runSQL(`SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${actualSchemaName}' AND table_catalog = '${catalogName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`);
268613
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${sqlLiteral(actualSchemaName, connection.type)}' AND table_catalog = '${sqlLiteral(catalogName, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position`);
268545
268614
  const rows = standardizeRunSQLResult2(result);
268546
268615
  return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
268547
268616
  } catch (error) {
@@ -268554,7 +268623,7 @@ async function listTablesForMotherDuck(connection, schemaName, malloyConnection,
268554
268623
  throw new Error("MotherDuck connection is required");
268555
268624
  }
268556
268625
  try {
268557
- const result = await malloyConnection.runSQL(`SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`);
268626
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${sqlLiteral(schemaName, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position`);
268558
268627
  const rows = standardizeRunSQLResult2(result);
268559
268628
  return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
268560
268629
  } catch (error) {
@@ -268569,7 +268638,7 @@ async function listTablesForDuckLake(connection, schemaName, malloyConnection, t
268569
268638
  const catalogName = schemaName.split(".")[0];
268570
268639
  const actualSchemaName = schemaName.split(".")[1];
268571
268640
  try {
268572
- const result = await malloyConnection.runSQL(`SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${actualSchemaName}' AND table_catalog = '${catalogName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`);
268641
+ const result = await runIntrospectionSQL(malloyConnection, `SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${sqlLiteral(actualSchemaName, connection.type)}' AND table_catalog = '${sqlLiteral(catalogName, connection.type)}' ${sqlInFilter("table_name", tableNames, connection.type)} ORDER BY table_name, ordinal_position`);
268573
268642
  const rows = standardizeRunSQLResult2(result);
268574
268643
  return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
268575
268644
  } catch (error) {
@@ -268754,7 +268823,7 @@ class ConnectionController {
268754
268823
  const pkg = await environment.getPackage(onlyPackage);
268755
268824
  return await pkg.getMalloyConnection(connectionName);
268756
268825
  }
268757
- throw new BadRequestError(`Ambiguous "duckdb" connection lookup: environment "${environmentName}" has multiple packages. ` + `Use /environments/${environmentName}/packages/{packageName}/connections/duckdb/... to disambiguate.`);
268826
+ throw new BadRequestError(`Ambiguous "duckdb" connection lookup: environment "${environmentName}" has multiple packages, ` + `and the "duckdb" sandbox exists once per package. Name one of: ${packages.map((p) => p.name).filter(Boolean).join(", ")}. ` + `Over MCP pass it as the packageName argument; over REST use ` + `/environments/${environmentName}/packages/{packageName}/connections/duckdb/...`);
268758
268827
  } else {
268759
268828
  return await environment.getMalloyConnection(connectionName);
268760
268829
  }
@@ -269240,17 +269309,6 @@ class PackageController {
269240
269309
  var import_render_validator = __toESM(require_dist11(), 1);
269241
269310
  init_constants();
269242
269311
  init_errors();
269243
-
269244
- // src/json_utils.ts
269245
- var MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
269246
- function bigIntReplacer(_key, value) {
269247
- if (typeof value === "bigint") {
269248
- return value > MAX_SAFE_BIGINT || value < -MAX_SAFE_BIGINT ? value.toString() : Number(value);
269249
- }
269250
- return value;
269251
- }
269252
-
269253
- // src/controller/query.controller.ts
269254
269312
  init_logger();
269255
269313
  class QueryController {
269256
269314
  environmentStore;
@@ -269276,7 +269334,6 @@ class QueryController {
269276
269334
  const {
269277
269335
  result,
269278
269336
  serializedResult,
269279
- compactResult,
269280
269337
  rowLimit,
269281
269338
  rowLimitSource,
269282
269339
  queryCorrelationId
@@ -269298,10 +269355,10 @@ class QueryController {
269298
269355
  return null;
269299
269356
  }
269300
269357
  }
269301
- }), getQueryTimeoutMs());
269358
+ }, compactJson ? "compact" : "full"), getQueryTimeoutMs());
269302
269359
  const renderLogs = import_render_validator.validateRenderTags(result);
269303
269360
  return {
269304
- result: compactJson ? JSON.stringify(compactResult, bigIntReplacer) : serializedResult,
269361
+ result: serializedResult,
269305
269362
  resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}/models/${modelPath}/query`,
269306
269363
  renderLogs: renderLogs.length > 0 ? renderLogs : undefined,
269307
269364
  queryRowLimit: rowLimit,
@@ -277551,7 +277608,7 @@ async function assertServesInDuckDB(sourceName, binding, connections) {
277551
277608
  init_errors();
277552
277609
  init_constants();
277553
277610
  init_logger();
277554
- import { Annotations as Annotations3 } from "@malloydata/malloy";
277611
+ import { Annotations as Annotations2 } from "@malloydata/malloy";
277555
277612
 
277556
277613
  // src/service/materialization_eligibility.ts
277557
277614
  init_errors();
@@ -277696,7 +277753,6 @@ function walkForAuthorize(node, seen, depth) {
277696
277753
 
277697
277754
  // src/service/model.ts
277698
277755
  import {
277699
- Annotations as Annotations2,
277700
277756
  API,
277701
277757
  FixedConnectionMap,
277702
277758
  InMemoryURLReader as InMemoryURLReader2,
@@ -277846,10 +277902,32 @@ function modelAnnotations(modelDef) {
277846
277902
  }
277847
277903
  return folded ?? {};
277848
277904
  }
277905
+ function ownModelNotes(modelDef) {
277906
+ const registry = modelDef.modelAnnotations ?? {};
277907
+ const isSameDocument = (id) => id === modelDef.modelID || id.startsWith("internal://");
277908
+ const seen = new Set;
277909
+ const texts = [];
277910
+ const visit = (id) => {
277911
+ if (seen.has(id) || !isSameDocument(id))
277912
+ return;
277913
+ seen.add(id);
277914
+ const entry = registry[id];
277915
+ if (!entry)
277916
+ return;
277917
+ for (const dep of entry.inheritsFrom)
277918
+ visit(dep);
277919
+ texts.push(...ownLevelNoteTexts(entry.ownNotes));
277920
+ };
277921
+ visit(modelDef.modelID);
277922
+ return texts;
277923
+ }
277849
277924
  function annotationTexts(annote) {
277850
277925
  const texts = new Annotations(annote).texts();
277851
277926
  return texts.length > 0 ? texts : undefined;
277852
277927
  }
277928
+ function ownLevelNoteTexts(annote) {
277929
+ return [...annote?.blockNotes ?? [], ...annote?.notes ?? []].map((note) => note.text);
277930
+ }
277853
277931
 
277854
277932
  // src/service/filter.ts
277855
277933
  var VALID_FILTER_TYPES = new Set([
@@ -278065,19 +278143,24 @@ function resolveModelQueryRowLimit(userLimit, { defaultLimit, maxRows }) {
278065
278143
  function queryRowLimitSource(userLimit) {
278066
278144
  return userLimit && userLimit > 0 ? "query" : "server_default";
278067
278145
  }
278068
- function assertWithinModelResponseLimits(rowCount, serializedBytes, { maxRows, maxBytes }, source) {
278146
+ function assertWithinModelRowLimit(rowCount, maxRows, source) {
278069
278147
  if (maxRows > 0 && rowCount > maxRows) {
278070
278148
  recordQueryCapExceeded("rows", source);
278071
278149
  throw new PayloadTooLargeError(`Query returned more than ${maxRows} rows. Refine the query (add a LIMIT or more selective WHERE) or raise PUBLISHER_MAX_QUERY_ROWS.`);
278072
278150
  }
278073
- if (maxBytes > 0 && serializedBytes > maxBytes) {
278151
+ }
278152
+ function assertWithinModelByteLimit(serialized, maxBytes, source) {
278153
+ if (maxBytes <= 0)
278154
+ return;
278155
+ const serializedBytes = Buffer.byteLength(serialized, "utf8");
278156
+ if (serializedBytes > maxBytes) {
278074
278157
  recordQueryCapExceeded("bytes", source);
278075
278158
  throw new PayloadTooLargeError(`Query response exceeded ${maxBytes} bytes (was ${serializedBytes}). Project fewer columns, add a LIMIT, or raise PUBLISHER_MAX_RESPONSE_BYTES.`);
278076
278159
  }
278077
278160
  }
278078
- function stringifyQueryResponse(response, rowCount, maxBytes, source) {
278161
+ function stringifyQueryResponse(response, rowCount, maxBytes, source, replacer) {
278079
278162
  try {
278080
- return JSON.stringify(response);
278163
+ return JSON.stringify(response, replacer);
278081
278164
  } catch (error) {
278082
278165
  if (!(error instanceof RangeError) || /call stack/i.test(error.message)) {
278083
278166
  throw error;
@@ -278087,6 +278170,15 @@ function stringifyQueryResponse(response, rowCount, maxBytes, source) {
278087
278170
  }
278088
278171
  }
278089
278172
 
278173
+ // src/json_utils.ts
278174
+ var MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
278175
+ function bigIntReplacer(_key, value) {
278176
+ if (typeof value === "bigint") {
278177
+ return value > MAX_SAFE_BIGINT || value < -MAX_SAFE_BIGINT ? value.toString() : Number(value);
278178
+ }
278179
+ return value;
278180
+ }
278181
+
278090
278182
  // src/service/query_text.ts
278091
278183
  function extractRunTargetSourceName(query) {
278092
278184
  if (!query)
@@ -278150,12 +278242,12 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
278150
278242
  onParseError?.(sourceName, err);
278151
278243
  }
278152
278244
  }
278153
- const ownNotes = (struct.annotations?.blockNotes ?? []).map((note) => note.text);
278245
+ const ownNotes = ownLevelNoteTexts(struct.annotations);
278154
278246
  const ownGates = collectAuthorizeExprs(ownNotes);
278155
278247
  let inheritedGates = [];
278156
278248
  if (ownGates.length === 0) {
278157
278249
  for (let cur2 = struct.annotations?.inherits;cur2; cur2 = cur2.inherits) {
278158
- const exprs = collectAuthorizeExprs((cur2.blockNotes ?? []).map((note) => note.text));
278250
+ const exprs = collectAuthorizeExprs(ownLevelNoteTexts(cur2));
278159
278251
  if (exprs.length > 0) {
278160
278252
  inheritedGates = exprs;
278161
278253
  break;
@@ -278417,7 +278509,7 @@ class Model {
278417
278509
  }
278418
278510
  }
278419
278511
  gateExprsForOwnAnnotations(struct, modelDef) {
278420
- const ownNotes = (struct.annotations?.blockNotes ?? []).map((note) => note.text);
278512
+ const ownNotes = ownLevelNoteTexts(struct.annotations);
278421
278513
  try {
278422
278514
  const own = collectAuthorizeExprs(ownNotes);
278423
278515
  if (own.length > 0) {
@@ -278440,7 +278532,7 @@ class Model {
278440
278532
  ancestorGateExprs(struct, modelDef, seen = new Set) {
278441
278533
  let inherited = struct.annotations?.inherits;
278442
278534
  for (let depth = 0;inherited && depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
278443
- const exprs2 = collectAuthorizeExprs((inherited.blockNotes ?? []).map((note) => note.text));
278535
+ const exprs2 = collectAuthorizeExprs(ownLevelNoteTexts(inherited));
278444
278536
  if (exprs2.length > 0)
278445
278537
  return exprs2;
278446
278538
  inherited = inherited.inherits;
@@ -278455,7 +278547,7 @@ class Model {
278455
278547
  return ["false"];
278456
278548
  if (declared.kind === "none" || seen.has(declared.source))
278457
278549
  return [];
278458
- const exprs = collectAuthorizeExprs((declared.source.annotations?.blockNotes ?? []).map((note) => note.text));
278550
+ const exprs = collectAuthorizeExprs(ownLevelNoteTexts(declared.source.annotations));
278459
278551
  return exprs.length > 0 ? exprs : this.ancestorGateExprs(declared.source, modelDef, seen);
278460
278552
  }
278461
278553
  resolveDeclaredSource(struct, modelDef) {
@@ -278798,7 +278890,7 @@ class Model {
278798
278890
  logger.warn(`Invalid renderer configuration on '${target.label}': ${errors2.map((e) => e.message).join("; ")}`);
278799
278891
  for (const e of errors2) {
278800
278892
  findings.push({
278801
- target: target.label,
278893
+ subject: target.label,
278802
278894
  message: e.message,
278803
278895
  severity: "error"
278804
278896
  });
@@ -278950,7 +279042,7 @@ class Model {
278950
279042
  return { ...b, schema, refinements };
278951
279043
  }).filter((b) => b.schema.length > 0);
278952
279044
  }
278953
- async getQueryResults(sourceName, queryName, query, filterParams, bypassFilters, givens, abortSignal, queryMetadataInput) {
279045
+ async getQueryResults(sourceName, queryName, query, filterParams, bypassFilters, givens, abortSignal, queryMetadataInput, responseShape = "full") {
278954
279046
  const startTime = performance.now();
278955
279047
  if (this.compilationError) {
278956
279048
  if (this.compilationError instanceof MalloyError2 || this.compilationError instanceof ModelCompilationError) {
@@ -279167,10 +279259,10 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
279167
279259
  servedFrom = "live_fallback";
279168
279260
  executionTime = performance.now() - startTime;
279169
279261
  }
279262
+ assertWithinModelRowLimit(queryResults.totalRows, maxRows, "model_query");
279170
279263
  const wrappedResult = API.util.wrapResult(queryResults);
279171
- const serializedResult = stringifyQueryResponse(wrappedResult, queryResults.totalRows, maxBytes, "model_query");
279172
- const serializedBytes = maxBytes > 0 ? Buffer.byteLength(serializedResult, "utf8") : 0;
279173
- assertWithinModelResponseLimits(queryResults.totalRows, serializedBytes, { maxRows, maxBytes }, "model_query");
279264
+ const serializedResult = stringifyQueryResponse(responseShape === "compact" ? queryResults.data.value : wrappedResult, queryResults.totalRows, maxBytes, "model_query", responseShape === "compact" ? bigIntReplacer : undefined);
279265
+ assertWithinModelByteLimit(serializedResult, maxBytes, "model_query");
279174
279266
  this.queryExecutionHistogram.record(executionTime, {
279175
279267
  "malloy.model.path": this.modelPath,
279176
279268
  "malloy.model.query.name": queryName,
@@ -279250,8 +279342,8 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
279250
279342
  queryInfo: cell.queryInfo ? JSON.stringify(cell.queryInfo) : undefined
279251
279343
  };
279252
279344
  });
279253
- const allAnnotations = this.modelDef ? new Annotations2(modelAnnotations(this.modelDef)).texts() : [];
279254
- return {
279345
+ const allAnnotations = this.modelDef ? ownModelNotes(this.modelDef) : [];
279346
+ const notebook = {
279255
279347
  type: "notebook",
279256
279348
  packageName: this.packageName,
279257
279349
  modelPath: this.modelPath,
@@ -279262,6 +279354,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
279262
279354
  annotations: allAnnotations,
279263
279355
  notebookCells
279264
279356
  };
279357
+ return notebook;
279265
279358
  }
279266
279359
  async executeNotebookCell(cellIndex, filterParams, bypassFilters, givens, abortSignal, queryMetadataInput) {
279267
279360
  if (this.compilationError) {
@@ -279322,9 +279415,12 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
279322
279415
  });
279323
279416
  const query = (await runnableToExecute.getPreparedQuery())._query;
279324
279417
  queryName = query.as || query.name;
279418
+ if (result?._queryResult) {
279419
+ assertWithinModelRowLimit(result.totalRows, cellMaxRows, "notebook_cell");
279420
+ }
279325
279421
  queryResult = result?._queryResult && this.modelInfo && stringifyQueryResponse(API.util.wrapResult(result), result.totalRows, cellMaxBytes, "notebook_cell");
279326
279422
  if (result?._queryResult && queryResult) {
279327
- assertWithinModelResponseLimits(result.totalRows, Buffer.byteLength(queryResult, "utf8"), { maxRows: cellMaxRows, maxBytes: cellMaxBytes }, "notebook_cell");
279423
+ assertWithinModelByteLimit(queryResult, cellMaxBytes, "notebook_cell");
279328
279424
  }
279329
279425
  } catch (error) {
279330
279426
  if (error instanceof FilterValidationError) {
@@ -279788,7 +279884,7 @@ function detectDroppedPersistSources(malloyModel, recognizedNames) {
279788
279884
  continue;
279789
279885
  let isPersist = false;
279790
279886
  try {
279791
- isPersist = new Annotations3(def.annotations).parseAsTag("@").tag.has("persist");
279887
+ isPersist = new Annotations2(def.annotations).parseAsTag("@").tag.has("persist");
279792
279888
  } catch {
279793
279889
  continue;
279794
279890
  }
@@ -279915,7 +280011,7 @@ function collectSourceEligibility(sources) {
279915
280011
  }
279916
280012
 
279917
280013
  // src/service/materialization_config_validation.ts
279918
- function metadataWarnings(level, metadata, target) {
280014
+ function metadataWarnings(level, metadata, subject) {
279919
280015
  if (!metadata)
279920
280016
  return [];
279921
280017
  const budget = queryMetadataBudgetWarning(Object.keys(metadata).length);
@@ -279925,7 +280021,7 @@ function metadataWarnings(level, metadata, target) {
279925
280021
  ...budget ? [budget] : []
279926
280022
  ].map((message) => ({
279927
280023
  message: `${level}: ${message}`,
279928
- ...target ? { target } : {}
280024
+ ...subject ? { subject } : {}
279929
280025
  }));
279930
280026
  }
279931
280027
  function materializationConfigWarnings(input) {
@@ -279936,7 +280032,7 @@ function materializationConfigWarnings(input) {
279936
280032
  }
279937
280033
  const seen = new Set;
279938
280034
  return warnings.filter((warning) => {
279939
- const key = `${warning.target ?? ""}\x00${warning.message}`;
280035
+ const key = `${warning.subject ?? ""}\x00${warning.message}`;
279940
280036
  if (seen.has(key))
279941
280037
  return false;
279942
280038
  seen.add(key);
@@ -280211,7 +280307,12 @@ class Package {
280211
280307
  }
280212
280308
  const model = Model.fromSerialized(packageName, packagePath, malloyConfig, sm);
280213
280309
  for (const w of await model.validateRenderTags()) {
280214
- renderTagWarnings.push({ model: sm.modelPath, ...w });
280310
+ renderTagWarnings.push({
280311
+ model: sm.modelPath,
280312
+ subject: w.subject,
280313
+ message: w.message,
280314
+ severity: w.severity
280315
+ });
280215
280316
  }
280216
280317
  if (sm.modelPath.endsWith(MODEL_FILE_SUFFIX)) {
280217
280318
  const modelSource = await fs7.readFile(path7.join(packagePath, sm.modelPath), "utf-8");
@@ -280438,7 +280539,7 @@ class Package {
280438
280539
  droppedPersistWarnings() {
280439
280540
  return this.droppedPersistSources.map((d) => ({
280440
280541
  model: d.modelPath,
280441
- target: d.name,
280542
+ subject: d.name,
280442
280543
  message: `is annotated '#@ persist' but was not recognized as a ` + `materializable source, so nothing is materialized and it is served ` + `live. Only query/aggregate sources build; a filtered pass-through ` + `does not. Persist a query source, or invoke a parameterized source ` + `with a bound argument.`
280443
280544
  }));
280444
280545
  }
@@ -280454,7 +280555,7 @@ class Package {
280454
280555
  const message = mode === "off" ? `declares storage="${storage}" but PERSIST_STORAGE_MODE is off; ` + `the annotation is ignored and the source is served live from ` + `its own warehouse.` : `is materialized into storage "${storage}" but ` + `PERSIST_STORAGE_MODE is write-only; the serve path is not ` + `routed to the materialized table (served live).`;
280455
280556
  warnings.push({
280456
280557
  model: source.modelPath ?? "",
280457
- target: source.name,
280558
+ subject: source.name,
280458
280559
  message
280459
280560
  });
280460
280561
  }
@@ -280633,7 +280734,12 @@ class Package {
280633
280734
  const model = Model.fromSerialized(this.packageName, this.packagePath, this.malloyConfig, sm, { buildManifest });
280634
280735
  try {
280635
280736
  for (const w of await model.validateRenderTags()) {
280636
- renderTagWarnings.push({ model: sm.modelPath, ...w });
280737
+ renderTagWarnings.push({
280738
+ model: sm.modelPath,
280739
+ subject: w.subject,
280740
+ message: w.message,
280741
+ severity: w.severity
280742
+ });
280637
280743
  }
280638
280744
  nextModels.set(sm.modelPath, model);
280639
280745
  } catch (renderErr) {
@@ -286167,7 +286273,7 @@ var BACK_PRESSURE_SUGGESTIONS = [
286167
286273
  "If this persists, raise the limit or scale up the pod."
286168
286274
  ];
286169
286275
  function classifyToolError(operation, identifier, error) {
286170
- if (error instanceof EnvironmentNotFoundError || error instanceof PackageNotFoundError || error instanceof ModelNotFoundError || error instanceof NotQueryableError) {
286276
+ if (error instanceof EnvironmentNotFoundError || error instanceof PackageNotFoundError || error instanceof ModelNotFoundError || error instanceof NotQueryableError || error instanceof ConnectionNotFoundError) {
286171
286277
  return getNotFoundError(identifier);
286172
286278
  }
286173
286279
  if (error instanceof ServiceUnavailableError) {
@@ -286194,6 +286300,15 @@ function classifyToolError(operation, identifier, error) {
286194
286300
  ]
286195
286301
  };
286196
286302
  }
286303
+ if (error instanceof InvalidArgumentError) {
286304
+ return {
286305
+ message: error.message,
286306
+ suggestions: [
286307
+ "This is not transient. The same arguments will fail the same way, so change them rather than retrying.",
286308
+ "The message above names what was wrong. If it names an expected format, use that format exactly."
286309
+ ]
286310
+ };
286311
+ }
286197
286312
  if (error instanceof MalloyError5 || error instanceof ModelCompilationError || error instanceof AccessDeniedError || error instanceof BadRequestError) {
286198
286313
  return getMalloyErrorDetails(operation, identifier, error);
286199
286314
  }
@@ -286632,7 +286747,7 @@ function registerExecuteQueryTool(mcpServer, environmentStore) {
286632
286747
  rowLimit,
286633
286748
  rowLimitSource,
286634
286749
  queryCorrelationId
286635
- } = await runWithQueryTimeout((abortSignal) => query ? model.getQueryResults(undefined, undefined, query, filterParams, undefined, givens, abortSignal, queryMetadataInput) : model.getQueryResults(sourceName, queryName, undefined, filterParams, undefined, givens, abortSignal, queryMetadataInput), getQueryTimeoutMs());
286750
+ } = await runWithQueryTimeout((abortSignal) => query ? model.getQueryResults(undefined, undefined, query, filterParams, undefined, givens, abortSignal, queryMetadataInput, "compact") : model.getQueryResults(sourceName, queryName, undefined, filterParams, undefined, givens, abortSignal, queryMetadataInput, "compact"), getQueryTimeoutMs());
286636
286751
  const { validateRenderTags: validateRenderTags2 } = await Promise.resolve().then(() => __toESM(require_dist11(), 1));
286637
286752
  const renderLogs = validateRenderTags2(result);
286638
286753
  const resultUri = buildMalloyUri({
@@ -287033,6 +287148,579 @@ function registerReloadPackageTool(mcpServer, environmentStore) {
287033
287148
  }
287034
287149
  });
287035
287150
  }
287151
+
287152
+ // src/mcp/tools/search_database_schema_tool.ts
287153
+ init_logger();
287154
+
287155
+ // src/mcp/tools/schema_index.ts
287156
+ init_logger();
287157
+ var import_lunr3 = __toESM(require_lunr(), 1);
287158
+ import { createHash as createHash2 } from "crypto";
287159
+ var MAX_INDEXED_TABLES = 5000;
287160
+ var MAX_COLUMNS_IN_INDEX_TEXT = 60;
287161
+ var MAX_CACHED_SCHEMAS = 8;
287162
+ var PROVIDER_FAILURE_COOLDOWN_MS2 = 60000;
287163
+ function tableIndexText(entity) {
287164
+ const name = humanizeName(entity.tableName) || entity.tableName;
287165
+ const schema = humanizeName(entity.schemaName) || entity.schemaName;
287166
+ const columns = entity.columns.slice(0, MAX_COLUMNS_IN_INDEX_TEXT);
287167
+ const columnNames = columns.map((c) => humanizeName(c.name) || c.name).filter(Boolean).join(", ");
287168
+ const types2 = Array.from(new Set(columns.map((c) => c.type).filter((t) => Boolean(t)))).join(" ");
287169
+ return [schema, name, columnNames, types2].filter(Boolean).join(" ");
287170
+ }
287171
+ function schemaFingerprint(tables) {
287172
+ const canonical = tables.map((t) => `${t.resource}\x00${t.columns.map((c) => `${c.name}:${c.type ?? ""}`).join(",")}`).sort().join("\x01");
287173
+ return createHash2("sha256").update(canonical).digest("hex");
287174
+ }
287175
+ function sanitizeQuery(query) {
287176
+ return query.replace(/[~^:*+\-"]/g, " ").trim();
287177
+ }
287178
+ function rankLexically(tables, query, limit, cacheKey) {
287179
+ const sanitized = sanitizeQuery(query);
287180
+ if (!sanitized || tables.length === 0)
287181
+ return { hits: [], matched: 0 };
287182
+ const fingerprint = cacheKey ? schemaFingerprint(tables) : "";
287183
+ let cached2 = cacheKey ? lexicalCache.get(cacheKey) : undefined;
287184
+ if (cached2 && cached2.fingerprint !== fingerprint)
287185
+ cached2 = undefined;
287186
+ const byResource = cached2?.byResource ?? new Map(tables.map((t) => [t.resource, t]));
287187
+ const index = cached2?.index ?? import_lunr3.default(function() {
287188
+ this.ref("resource");
287189
+ this.field("text");
287190
+ this.metadataWhitelist = [];
287191
+ for (const table of tables) {
287192
+ this.add({ resource: table.resource, text: tableIndexText(table) });
287193
+ }
287194
+ });
287195
+ if (cacheKey && !cached2) {
287196
+ lexicalCache.delete(cacheKey);
287197
+ lexicalCache.set(cacheKey, { index, byResource, fingerprint });
287198
+ while (lexicalCache.size > MAX_CACHED_SCHEMAS) {
287199
+ const oldest = lexicalCache.keys().next();
287200
+ if (oldest.done)
287201
+ break;
287202
+ lexicalCache.delete(oldest.value);
287203
+ }
287204
+ } else if (cacheKey && cached2) {
287205
+ lexicalCache.delete(cacheKey);
287206
+ lexicalCache.set(cacheKey, cached2);
287207
+ }
287208
+ let hits;
287209
+ try {
287210
+ hits = index.search(sanitized);
287211
+ } catch (error) {
287212
+ logger.warn("[MCP Tool searchDatabaseSchema] lunr search failed", {
287213
+ error: error instanceof Error ? error.message : String(error)
287214
+ });
287215
+ return { hits: [], matched: 0 };
287216
+ }
287217
+ return {
287218
+ hits: hits.slice(0, limit).map((hit) => {
287219
+ const table = byResource.get(hit.ref);
287220
+ return table ? { ...table, score: hit.score } : undefined;
287221
+ }).filter((t) => t !== undefined),
287222
+ matched: hits.length
287223
+ };
287224
+ }
287225
+ function cosineSimilarity(a, b) {
287226
+ if (a.length !== b.length || a.length === 0)
287227
+ return 0;
287228
+ let dot = 0;
287229
+ let magA = 0;
287230
+ let magB = 0;
287231
+ for (let i = 0;i < a.length; i++) {
287232
+ dot += a[i] * b[i];
287233
+ magA += a[i] * a[i];
287234
+ magB += b[i] * b[i];
287235
+ }
287236
+ if (magA === 0 || magB === 0)
287237
+ return 0;
287238
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB));
287239
+ }
287240
+ var vectorCache = new Map;
287241
+ var lexicalCache = new Map;
287242
+ var cooldownUntilMs = new Map;
287243
+ function touchCacheEntry(key) {
287244
+ const entry = vectorCache.get(key);
287245
+ if (entry) {
287246
+ vectorCache.delete(key);
287247
+ vectorCache.set(key, entry);
287248
+ }
287249
+ return entry;
287250
+ }
287251
+ function storeCacheEntry(key, entry) {
287252
+ vectorCache.delete(key);
287253
+ vectorCache.set(key, entry);
287254
+ while (vectorCache.size > MAX_CACHED_SCHEMAS) {
287255
+ const oldest = vectorCache.keys().next();
287256
+ if (oldest.done)
287257
+ break;
287258
+ vectorCache.delete(oldest.value);
287259
+ }
287260
+ }
287261
+ function pruneCooldowns() {
287262
+ if (cooldownUntilMs.size <= MAX_CACHED_SCHEMAS * 4)
287263
+ return;
287264
+ const now = Date.now();
287265
+ for (const [k, until] of cooldownUntilMs) {
287266
+ if (until <= now)
287267
+ cooldownUntilMs.delete(k);
287268
+ }
287269
+ }
287270
+ function providerKeyFor(provider) {
287271
+ return `${provider.model}\x00${provider.dimensions ?? ""}`;
287272
+ }
287273
+ async function tryRankSemantically(args) {
287274
+ const { tables, query, limit, provider, cacheKey } = args;
287275
+ if (tables.length === 0) {
287276
+ return null;
287277
+ }
287278
+ if (tables.length > MAX_INDEXED_TABLES) {
287279
+ logger.warn("[MCP Tool searchDatabaseSchema] Schema exceeds the semantic index cap; ranking lexically", { tableCount: tables.length, cap: MAX_INDEXED_TABLES });
287280
+ return null;
287281
+ }
287282
+ const until = cooldownUntilMs.get(cacheKey);
287283
+ if (until !== undefined && Date.now() < until)
287284
+ return null;
287285
+ const fingerprint = schemaFingerprint(tables);
287286
+ const providerKey = providerKeyFor(provider);
287287
+ let entry = touchCacheEntry(cacheKey);
287288
+ try {
287289
+ if (!entry || entry.fingerprint !== fingerprint || entry.providerKey !== providerKey) {
287290
+ const texts = tables.map(tableIndexText);
287291
+ const vectors = await provider.embedBatch(texts, EMBEDDING_BATCH_TIMEOUT_MS);
287292
+ if (vectors.length !== tables.length) {
287293
+ throw new Error(`Embedding provider returned ${vectors.length} vectors for ${tables.length} inputs`);
287294
+ }
287295
+ entry = {
287296
+ vectors: new Map(tables.map((t, i) => [t.resource, vectors[i]])),
287297
+ fingerprint,
287298
+ providerKey
287299
+ };
287300
+ storeCacheEntry(cacheKey, entry);
287301
+ }
287302
+ const [queryVector] = await provider.embedBatch([query], EMBEDDING_QUERY_TIMEOUT_MS);
287303
+ if (!queryVector)
287304
+ throw new Error("Empty query embedding");
287305
+ const scored = [];
287306
+ for (const table of tables) {
287307
+ const vector = entry.vectors.get(table.resource);
287308
+ if (!vector)
287309
+ continue;
287310
+ const score = cosineSimilarity(queryVector, vector);
287311
+ if (score >= MIN_SIMILARITY)
287312
+ scored.push({ ...table, score });
287313
+ }
287314
+ scored.sort((a, b) => b.score - a.score);
287315
+ return { hits: scored.slice(0, limit), matched: scored.length };
287316
+ } catch (error) {
287317
+ cooldownUntilMs.set(cacheKey, Date.now() + PROVIDER_FAILURE_COOLDOWN_MS2);
287318
+ pruneCooldowns();
287319
+ logger.warn("[MCP Tool searchDatabaseSchema] Embedding failed; ranking lexically", {
287320
+ cacheKey,
287321
+ error: error instanceof Error ? error.message : String(error)
287322
+ });
287323
+ return null;
287324
+ }
287325
+ }
287326
+ async function rankTables(args) {
287327
+ const { tables, query, limit, provider, cacheKey } = args;
287328
+ if (!sanitizeQuery(query)) {
287329
+ return { hits: [], matched: 0, ranking: "lexical", emptyQuery: true };
287330
+ }
287331
+ if (provider) {
287332
+ const semantic = await tryRankSemantically({
287333
+ tables,
287334
+ query,
287335
+ limit,
287336
+ provider,
287337
+ cacheKey
287338
+ });
287339
+ if (semantic !== null)
287340
+ return { ...semantic, ranking: "semantic" };
287341
+ }
287342
+ return {
287343
+ ...rankLexically(tables, query, limit, cacheKey),
287344
+ ranking: "lexical"
287345
+ };
287346
+ }
287347
+
287348
+ // src/mcp/tools/search_database_schema_tool.ts
287349
+ var DEFAULT_LIMIT = 20;
287350
+ var MAX_LIMIT = 100;
287351
+ var MAX_COLUMNS_PER_TABLE = 80;
287352
+ var MAX_ARG_CHARS = 256;
287353
+ var searchDatabaseSchemaShape = {
287354
+ environmentName: exports_external.string().max(MAX_ARG_CHARS).optional().describe("Environment to look in. Omit to list the environments and their connections."),
287355
+ connectionName: exports_external.string().max(MAX_ARG_CHARS).optional().describe("Connection to introspect. Omit to list the connections in the environment."),
287356
+ packageName: exports_external.string().max(MAX_ARG_CHARS).optional().describe('Required only for the per-package "duckdb" sandbox connection, which exists once per package.'),
287357
+ schemaName: exports_external.string().max(MAX_ARG_CHARS).optional().describe(`Schema (or dataset/database) to list tables from. Omit to list the connection's schemas and use one of those names verbatim: DuckDB qualifies them as "catalog.schema" (for example "memory.main"), so a bare "main" is rejected.`),
287358
+ tableName: exports_external.string().max(MAX_ARG_CHARS).optional().describe("A single table to return in full, with every column. Requires schemaName."),
287359
+ searchQuery: exports_external.string().max(500).optional().describe(`Plain-English description of the data you are looking for, e.g. "customer orders and shipping addresses". Requires schemaName. Omit to list the schema's tables in order.`),
287360
+ limit: exports_external.number().int().positive().max(MAX_LIMIT).optional().describe(`Maximum tables to return. Default ${DEFAULT_LIMIT}.`),
287361
+ offset: exports_external.number().int().min(0).optional().describe("Tables to skip, for paging a long table listing. Pass back the nextOffset from a previous response. Ignored when searchQuery is set, because ranked results cannot be paged.")
287362
+ };
287363
+ function escapeMalloyString2(value) {
287364
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
287365
+ }
287366
+ function malloyIdentifier(name) {
287367
+ return `\`${name.replace(/\\/g, "\\\\").replace(/`/g, "\\`")}\``;
287368
+ }
287369
+ function malloySourceSnippet(connectionName, resource, tableName) {
287370
+ const alias = malloyIdentifier(tableName);
287371
+ const connection = malloyIdentifier(connectionName);
287372
+ return `source: ${alias} is ${connection}.table('${escapeMalloyString2(resource)}') extend { }`;
287373
+ }
287374
+ var DUCKDB_FILE_PATH = /^[A-Za-z0-9._~:/?#@!$&*+,=%-]+$/;
287375
+ var STRICT_BARE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
287376
+ function isPastableTablePath(resource) {
287377
+ if (!resource)
287378
+ return false;
287379
+ if (resource.includes("/"))
287380
+ return DUCKDB_FILE_PATH.test(resource);
287381
+ if (DATA_FILE_EXTENSION.test(resource))
287382
+ return false;
287383
+ return resource.split(".").every((seg) => STRICT_BARE_IDENT.test(seg));
287384
+ }
287385
+ function canPasteSource(entity) {
287386
+ return Boolean(entity.tableName) && isPastableTablePath(entity.resource);
287387
+ }
287388
+ var DATA_FILE_EXTENSION = /\.(parquet|csv|tsv|json|jsonl|ndjson|xlsx)$/i;
287389
+ function bareTableName2(resource) {
287390
+ const lastSlash = resource.lastIndexOf("/");
287391
+ if (lastSlash >= 0) {
287392
+ return resource.slice(lastSlash + 1).replace(DATA_FILE_EXTENSION, "");
287393
+ }
287394
+ if (DATA_FILE_EXTENSION.test(resource)) {
287395
+ return resource.replace(DATA_FILE_EXTENSION, "");
287396
+ }
287397
+ return resource.slice(resource.lastIndexOf(".") + 1);
287398
+ }
287399
+ var SANDBOX_CONNECTION = "duckdb";
287400
+ async function listConnectionsFor(environment) {
287401
+ const connections = environment.listApiConnections().map((c) => ({ name: c.name ?? "", type: c.type ?? "" }));
287402
+ let packages = [];
287403
+ try {
287404
+ packages = (await environment.listPackages()).map((p) => p.name).filter((n) => Boolean(n));
287405
+ } catch {
287406
+ packages = [];
287407
+ }
287408
+ if (packages.length > 0) {
287409
+ connections.push({
287410
+ name: SANDBOX_CONNECTION,
287411
+ type: "duckdb",
287412
+ scope: "package",
287413
+ packages
287414
+ });
287415
+ }
287416
+ return connections;
287417
+ }
287418
+ function toResponseTable(entity, options) {
287419
+ return {
287420
+ connectionName: entity.connectionName,
287421
+ schemaName: entity.schemaName,
287422
+ tableName: entity.tableName,
287423
+ tablePath: entity.resource,
287424
+ ...canPasteSource(entity) ? {
287425
+ malloySource: malloySourceSnippet(entity.connectionName, entity.resource, entity.tableName)
287426
+ } : {},
287427
+ columns: entity.columns.slice(0, options.maxColumns),
287428
+ columnCount: entity.columns.length,
287429
+ ...options.score !== undefined ? { score: options.score } : {}
287430
+ };
287431
+ }
287432
+ var SEARCH_DATABASE_SCHEMA_DESCRIPTION = `Find the tables in a database connection, by plain-English description. Use it to model a database you have not modelled yet, or to check schema, table and column names. To search an existing model, use malloy_getContext.
287433
+
287434
+ ## Drill down, one level at a time
287435
+ Supply what you know, omit the rest. No arguments lists the environments and their connections; + connectionName lists its schemas; + schemaName lists its tables (up to ${MAX_COLUMNS_PER_TABLE} columns each; add searchQuery to rank them); + tableName returns that one table with every column.
287436
+
287437
+ ## Contract rules
287438
+ - Use connectionName, tablePath and column names exactly as returned.
287439
+ - A connection with scope "package" (the "duckdb" sandbox) is per package: pass packageName too, from those it lists.
287440
+ - Schemas marked isHidden are system schemas; your tables are in the others.
287441
+ - malloySource is the ready-to-use \`source:\` line; its identifiers are already quoted, so paste it as-is.
287442
+ - Names and types only: no row value is returned. For a column's values, run malloy_executeQuery against a model using this connection: \`run: c.table('s.t') -> { group_by: col }\`.
287443
+ - No tables for a searchQuery means nothing matched, not an empty schema. Broaden it, or list without one.
287444
+ - An empty schema may still hold data: DuckDB over CSV or Parquet addresses files by path, registering none.
287445
+ - Read warnings: they name anything omitted or ignored.
287446
+
287447
+ ## Response
287448
+ JSON: tables (connectionName, schemaName, tableName, tablePath, malloySource, columns, columnCount, and score on a search), plus totalAvailable and returned. A search adds matched and ranking; a listing adds nextOffset when more remain, to pass back as offset.
287449
+
287450
+ ## Worked example
287451
+ Start with no arguments and follow what it names. For connection "warehouse", schema "sales":
287452
+ { "environmentName": "examples", "connectionName": "warehouse", "schemaName": "sales", "searchQuery": "customer orders" }
287453
+ Then paste that table's malloySource verbatim, e.g. source: \`orders\` is \`warehouse\`.table('sales.orders') extend { }`;
287454
+ function registerSearchDatabaseSchemaTool(mcpServer, environmentStore) {
287455
+ const connectionController = new ConnectionController(environmentStore);
287456
+ const unpastableWarning = (entities) => {
287457
+ const n = entities.filter((e) => !canPasteSource(e)).length;
287458
+ if (n === 0)
287459
+ return [];
287460
+ return [
287461
+ `${n} table(s) have a path this server will not vouch for across every dialect it serves, so malloySource is omitted for them rather than risk a line that does not compile. Build it from tablePath: on your own dialect the path may work as-is, or may need the offending segment quoted. Double quotes on DuckDB, Postgres, Snowflake and Trino; backticks on MySQL, BigQuery and Databricks.`
287462
+ ];
287463
+ };
287464
+ const assertCanAdmit = async (environmentName) => {
287465
+ (await environmentStore.getEnvironment(environmentName, false)).assertCanAdmitQuery();
287466
+ };
287467
+ mcpServer.tool("malloy_searchDatabaseSchema", SEARCH_DATABASE_SCHEMA_DESCRIPTION, searchDatabaseSchemaShape, async (params) => {
287468
+ const {
287469
+ environmentName,
287470
+ connectionName,
287471
+ packageName,
287472
+ schemaName,
287473
+ tableName,
287474
+ searchQuery,
287475
+ limit,
287476
+ offset
287477
+ } = params;
287478
+ const max = limit ?? DEFAULT_LIMIT;
287479
+ const skip = offset ?? 0;
287480
+ const uri = buildMalloyUri({
287481
+ environment: environmentName,
287482
+ package: packageName
287483
+ }, "search-database-schema");
287484
+ logger.info("[MCP Tool searchDatabaseSchema] Searching schema", {
287485
+ environmentName,
287486
+ connectionName,
287487
+ packageName,
287488
+ schemaName,
287489
+ tableName,
287490
+ searchQuery,
287491
+ limit,
287492
+ offset
287493
+ });
287494
+ try {
287495
+ const ignored = [];
287496
+ const noteIgnored = (entries) => {
287497
+ for (const [name, value] of entries) {
287498
+ if (value !== undefined && value !== "" && !ignored.includes(name)) {
287499
+ ignored.push(name);
287500
+ }
287501
+ }
287502
+ };
287503
+ const pagingArgs = [
287504
+ ["limit", limit],
287505
+ ["offset", offset]
287506
+ ];
287507
+ if (!environmentName) {
287508
+ noteIgnored([
287509
+ ["connectionName", connectionName],
287510
+ ["packageName", packageName],
287511
+ ["schemaName", schemaName],
287512
+ ["tableName", tableName],
287513
+ ["searchQuery", searchQuery],
287514
+ ...pagingArgs
287515
+ ]);
287516
+ } else if (!connectionName) {
287517
+ noteIgnored([
287518
+ ["packageName", packageName],
287519
+ ["schemaName", schemaName],
287520
+ ["tableName", tableName],
287521
+ ["searchQuery", searchQuery],
287522
+ ...pagingArgs
287523
+ ]);
287524
+ } else if (!schemaName) {
287525
+ noteIgnored([
287526
+ ["tableName", tableName],
287527
+ ["searchQuery", searchQuery],
287528
+ ...pagingArgs
287529
+ ]);
287530
+ } else if (tableName) {
287531
+ noteIgnored([["searchQuery", searchQuery], ...pagingArgs]);
287532
+ } else if (searchQuery !== undefined) {
287533
+ noteIgnored([["offset", offset]]);
287534
+ }
287535
+ if (connectionName && connectionName !== SANDBOX_CONNECTION) {
287536
+ noteIgnored([["packageName", packageName]]);
287537
+ }
287538
+ const ignoredWarning = ignored.length > 0 ? [
287539
+ `Ignored ${ignored.join(", ")}. Each level needs the one above it: environmentName, then connectionName (with packageName for the per-package "duckdb" sandbox), then schemaName, then either tableName or searchQuery. limit and offset apply only to a plain table listing, and offset does nothing on a ranked search because ranked results cannot be paged.`
287540
+ ] : [];
287541
+ if (!environmentName) {
287542
+ const environments = await environmentStore.listEnvironments();
287543
+ const results = await Promise.all(environments.map(async (env) => {
287544
+ const name = env.name ?? "";
287545
+ let connections = [];
287546
+ try {
287547
+ const environment = await environmentStore.getEnvironment(name, false);
287548
+ connections = await listConnectionsFor(environment);
287549
+ } catch (error) {
287550
+ logger.debug("[MCP Tool searchDatabaseSchema] Could not list connections", {
287551
+ environmentName: name,
287552
+ error: error instanceof Error ? error.message : String(error)
287553
+ });
287554
+ }
287555
+ return { name, connections };
287556
+ }));
287557
+ return jsonResource(uri, {
287558
+ environments: results,
287559
+ ...ignoredWarning.length > 0 ? { warnings: ignoredWarning } : {}
287560
+ });
287561
+ }
287562
+ if (!connectionName) {
287563
+ const environment = await environmentStore.getEnvironment(environmentName, false);
287564
+ const connections = await listConnectionsFor(environment);
287565
+ return jsonResource(uri, {
287566
+ environmentName,
287567
+ connections,
287568
+ ...ignoredWarning.length > 0 ? { warnings: ignoredWarning } : {}
287569
+ });
287570
+ }
287571
+ if (!schemaName) {
287572
+ await assertCanAdmit(environmentName);
287573
+ const schemas = await connectionController.listSchemas(environmentName, connectionName, packageName);
287574
+ return jsonResource(uri, {
287575
+ environmentName,
287576
+ connectionName,
287577
+ schemas: schemas.map((s) => ({
287578
+ name: s.name ?? "",
287579
+ isDefault: s.isDefault ?? false,
287580
+ isHidden: s.isHidden ?? false
287581
+ })),
287582
+ ...ignoredWarning.length > 0 ? { warnings: ignoredWarning } : {}
287583
+ });
287584
+ }
287585
+ if (tableName) {
287586
+ await assertCanAdmit(environmentName);
287587
+ const tables = await connectionController.listTables(environmentName, connectionName, schemaName, [tableName], packageName);
287588
+ if (tables.length === 0) {
287589
+ return jsonToolError(uri, {
287590
+ message: `Table "${tableName}" not found in schema "${schemaName}" of connection "${connectionName}".`,
287591
+ suggestions: [
287592
+ `List the schema's tables by calling this tool with schemaName "${schemaName}" and no tableName.`,
287593
+ "Check the table name's spelling and case; some warehouses are case-sensitive."
287594
+ ]
287595
+ }, { tables: [] });
287596
+ }
287597
+ const exact = tables.filter((t) => bareTableName2(t.resource ?? "") === tableName);
287598
+ const matching = exact.length > 0 ? exact : tables.filter((t) => bareTableName2(t.resource ?? "").toLowerCase() === tableName.toLowerCase());
287599
+ const entities2 = (matching.length > 0 ? matching : tables).map((t) => toEntity(t, connectionName, schemaName));
287600
+ if (matching.length === 0) {
287601
+ return jsonToolError(uri, {
287602
+ message: `Table "${tableName}" not found in schema "${schemaName}" of connection "${connectionName}".`,
287603
+ suggestions: [
287604
+ `This schema lists ${tables.length} table(s). Call this tool with schemaName "${schemaName}" and no tableName to see them.`,
287605
+ "Check the table name's spelling and case; some warehouses are case-sensitive."
287606
+ ]
287607
+ }, { tables: [] });
287608
+ }
287609
+ return jsonResource(uri, {
287610
+ environmentName,
287611
+ connectionName,
287612
+ schemaName,
287613
+ tables: entities2.map((e) => toResponseTable(e, {
287614
+ maxColumns: Number.MAX_SAFE_INTEGER
287615
+ })),
287616
+ totalAvailable: entities2.length,
287617
+ returned: entities2.length,
287618
+ ...(() => {
287619
+ const w = [
287620
+ ...ignoredWarning,
287621
+ ...unpastableWarning(entities2)
287622
+ ];
287623
+ return w.length > 0 ? { warnings: w } : {};
287624
+ })()
287625
+ });
287626
+ }
287627
+ await assertCanAdmit(environmentName);
287628
+ const allTables = await connectionController.listTables(environmentName, connectionName, schemaName, undefined, packageName);
287629
+ const entities = allTables.map((t) => toEntity(t, connectionName, schemaName));
287630
+ const warnings = [...ignoredWarning];
287631
+ if (entities.length === 0) {
287632
+ warnings.push(`No tables are registered in schema "${schemaName}". If this connection reads data files directly (DuckDB over CSV or Parquet), those files are not listed in a schema; reference them by path instead, for example ${connectionName}.table('data/orders.parquet'). Otherwise check the schema name against the schema list for this connection.`);
287633
+ }
287634
+ let page;
287635
+ let ranking;
287636
+ let nextOffset;
287637
+ let matched;
287638
+ if (searchQuery !== undefined) {
287639
+ const provider = resolveProvider();
287640
+ const ranked = await rankTables({
287641
+ tables: entities,
287642
+ query: searchQuery,
287643
+ limit: max,
287644
+ provider,
287645
+ cacheKey: [
287646
+ environmentName,
287647
+ connectionName === SANDBOX_CONNECTION ? packageName ?? "" : "",
287648
+ connectionName,
287649
+ schemaName
287650
+ ].join("\x00")
287651
+ });
287652
+ ranking = ranked.emptyQuery ? undefined : ranked.ranking;
287653
+ matched = ranked.emptyQuery ? undefined : ranked.matched;
287654
+ page = ranked.hits.map((hit) => ({
287655
+ entity: hit,
287656
+ score: hit.score
287657
+ }));
287658
+ if (ranked.emptyQuery) {
287659
+ warnings.push(`searchQuery carried no searchable content, so no ranking was run. Provide words to search for, or omit searchQuery to list the schema's tables.`);
287660
+ } else if (ranked.hits.length === 0 && entities.length > 0) {
287661
+ warnings.push(`No table in "${schemaName}" matched "${searchQuery}". List the schema without a searchQuery to see everything in it.`);
287662
+ }
287663
+ if (ranked.matched > page.length) {
287664
+ warnings.push(ranked.ranking === "lexical" ? `${ranked.matched} tables share at least one term with "${searchQuery}"; the top ${page.length} by relevance are shown. Term-matching counts loosely, so a large number here is normal and does not mean that many tables are relevant. Ranked results cannot be paged: make searchQuery more specific, or raise limit (max ${MAX_LIMIT}) if you want more of this ranking.` : `${ranked.matched} tables scored above the relevance floor for "${searchQuery}"; the top ${page.length} are shown. Ranked results cannot be paged: make searchQuery more specific, or raise limit (max ${MAX_LIMIT}).`);
287665
+ }
287666
+ } else {
287667
+ page = entities.slice(skip, skip + max).map((entity) => ({ entity }));
287668
+ if (skip + max < entities.length) {
287669
+ nextOffset = skip + max;
287670
+ warnings.push(`Showing ${page.length} of ${entities.length} tables. Pass offset ${nextOffset} for the next page, or add a searchQuery to narrow.`);
287671
+ } else if (page.length === 0 && entities.length > 0) {
287672
+ warnings.push(`offset ${skip} is past the end of this schema, which has ${entities.length} tables. Use an offset below ${entities.length}, or omit it to start from the beginning.`);
287673
+ }
287674
+ }
287675
+ warnings.push(...unpastableWarning(page.map(({ entity }) => entity)));
287676
+ const capped = page.filter(({ entity }) => entity.columns.length > MAX_COLUMNS_PER_TABLE).length;
287677
+ if (capped > 0) {
287678
+ warnings.push(`${capped} table(s) have more than ${MAX_COLUMNS_PER_TABLE} columns; only the first ${MAX_COLUMNS_PER_TABLE} are shown. Pass tableName to see all columns of one table.`);
287679
+ }
287680
+ return jsonResource(uri, {
287681
+ environmentName,
287682
+ connectionName,
287683
+ schemaName,
287684
+ tables: page.map(({ entity, score }) => toResponseTable(entity, {
287685
+ maxColumns: MAX_COLUMNS_PER_TABLE,
287686
+ score
287687
+ })),
287688
+ totalAvailable: entities.length,
287689
+ returned: page.length,
287690
+ ...matched !== undefined ? { matched } : {},
287691
+ ...nextOffset !== undefined ? { nextOffset } : {},
287692
+ ...ranking ? { ranking } : {},
287693
+ ...warnings.length > 0 ? { warnings } : {}
287694
+ });
287695
+ } catch (error) {
287696
+ const identifier = [environmentName, connectionName, schemaName].filter(Boolean).join("/");
287697
+ return jsonToolError(uri, classifyToolError("searchDatabaseSchema", identifier, error), { tables: [] });
287698
+ }
287699
+ });
287700
+ }
287701
+ function resolveProvider() {
287702
+ try {
287703
+ if (!schemaEmbeddingEnabled())
287704
+ return null;
287705
+ return getEmbeddingProvider();
287706
+ } catch (error) {
287707
+ logger.warn("[MCP Tool searchDatabaseSchema] Embedding config invalid; ranking lexically", { error: error instanceof Error ? error.message : String(error) });
287708
+ return null;
287709
+ }
287710
+ }
287711
+ function toEntity(table, connectionName, schemaName) {
287712
+ const resource = table.resource ?? "";
287713
+ return {
287714
+ connectionName,
287715
+ schemaName,
287716
+ tableName: bareTableName2(resource),
287717
+ resource,
287718
+ columns: (table.columns ?? []).map((c) => ({
287719
+ name: c.name ?? "",
287720
+ ...c.type ? { type: c.type } : {}
287721
+ }))
287722
+ };
287723
+ }
287036
287724
  // src/mcp/skills/skills_bundle.json
287037
287725
  var skills_bundle_default = {
287038
287726
  skills: [{ name: "malloy", description: 'Index of all Malloy skills. Use when user asks "malloy help", "what malloy skills are available", "how do I use malloy", or needs guidance on which Malloy skill to use.', body: `# Malloy Skills Index
@@ -287709,7 +288397,7 @@ Once the definitions are confirmed, write them into the \`.malloy\` model (see y
287709
288397
 
287710
288398
  ## Output
287711
288399
 
287712
- A confirmed source architecture and a confirmed set of field definitions (renames, dimensions, measures, business decisions), held in the conversation and ready to write into the \`.malloy\` model via your modeling workflow.` }, { name: "malloy-discover", description: "Silent data discovery for Malloy modeling. Used at Step 1 of the modeling workflow. Scans tables, columns, distributions, and relationships without user interaction. The agent builds an internal picture before presenting anything.", body: "# Data Discovery (Step 1, Silent)\n\n> **CRITICAL**: Read the model before writing ANY Malloy code. The model defines the sources, connection names, and fields. Never guess connection names.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n> **PREREQUISITE:** Make sure the Malloy MCP tools (`get_context`, `execute_query`, `search_malloy_docs`) are configured and reachable. If they are not, stop and resolve the MCP connection before continuing.\n\n**This step is silent.** The agent does not present findings to the user yet. That happens in the next step (PROPOSE SCOPE).\n\n## Tools\n\n- **`get_context`**: Ground yourself in the package's sources, views, and fields (with their docs). Call FIRST. The sources and their join paths are the schema you build on.\n- **`execute_query`**: Run ad-hoc queries to preview data, verify values, check NULLs, validate assumptions.\n- **`search_malloy_docs`**: Get Malloy syntax help when needed.\n\n## Workflow\n\n```\n1. Check for prior art signals → If found, ask user: \"I found [LookML/dbt] files, use as prior art?\"\n2. If user confirms: read adapter reference → Follow skill:malloy-lookml-review, keep prior-art notes in-conversation\n3. get_context → Ground yourself: sources, views, fields\n4. Inspect source definitions → See ALL fields and join paths for key sources\n5. Derive candidate joins/dimensions/measures → Read them off the model and the data, not a suggestion tool\n6. Define a minimal source if one is missing → Just enough to run execute_query for previews\n7. execute_query(query) → Preview data, verify values, check NULLs, check duplicates\n8. search_malloy_docs(query) → Get syntax help when needed\n9. Proceed to Step 2 (PROPOSE SCOPE)\n```\n\n**If the model has no sources defined** and no LookML files are present, do NOT silently retry or proceed without data. Tell the user: \"No model sources were found. Please check that the package points at a connected data source, then try again.\"\n\n**If the model has no sources defined** but LookML files ARE present (LookML-only mode), skip steps 3-7. Use connection name and table paths from the LookML review. Flag all proposals as unvalidated.\n\n**Key principle:** Query data to verify assumptions. Don't ask the user to confirm values you can check yourself.\n\n**Search docs proactively.** If you discover patterns that need derived/pre-aggregated sources, window functions, or unfamiliar features, call `search_malloy_docs` BEFORE writing code, not just when you hit errors.\n\n## Query File for Discovery\n\n**In the schema-first workflow:** Run ad-hoc queries with `execute_query`. If the source you want to preview is not yet defined in the model, define a minimal one against the connection and table so you can run previews. The real model fields are built in later steps.\n\n```malloy\n// minimal source for previewing data during discovery\nsource: explore is my_conn.table('schema.table') extend {}\n```\n\n**In analysis-first mode:** There is no temp file. The analysis `.malloy` file IS your working file. It grows throughout the session and becomes the input for formalizing into a model. See `skill:malloy-analyze` for that workflow.\n\n## What to Capture\n\nWhen reviewing tables and columns, capture:\n\n### Table-Level\n- All tables with row counts\n- Connection name and schema (CRITICAL, never guess)\n- Table roles: fact, dimension, bridge, lookup, staging, operational\n- Join relationships (FK → PK mappings)\n\n### Column-Level\n- Primary key and foreign key columns\n- Data types (watch for string dates, arrays, JSON)\n- Reserved word columns that need backticking (`Date`, `Type`, `number`, `source`, etc.)\n- Column cardinality and NULL rates (via `execute_query`)\n- Data distributions for key numeric and categorical columns\n\n### Data Quality\n- **Check for duplicate rows** on primary keys. Run `group_by: pk, aggregate: count(), having: count() > 1` on each key table. Duplicates cause `sum()` to return nonsensical values.\n- **Denormalized count columns**: beware pre-aggregated fields (e.g., `order_count` in a customer table) that may conflict with joined counts.\n- **Delimited list columns**: flag string columns containing comma-separated values.\n\n### Data-Driven Validation\n\n**Every recommendation must be grounded in queried data, not schema inference.** During discovery, run `execute_query` to validate assumptions before proposing anything in later steps.\n\n| What to validate | Query to run |\n|-----------------|-------------|\n| **Denormalized vs joined values** | Compare pre-computed columns (e.g., `customers.order_count`) against the actual joined aggregate (`count()` from `orders`). Report discrepancy rate. If >0%, flag for user decision. |\n| **Candidate date fields** | When multiple date/timestamp columns exist, query both. What % of rows differ? By how much? This informs which is canonical. |\n| **Numeric column distributions** | Query min, max, avg, percentiles (p25, p50, p75, p95). These inform tier boundaries and detect outliers. |\n| **Categorical column cardinality** | Query distinct values. A `status` column with 5 values behaves differently from one with 500. |\n| **Column usefulness** | Query NULL rates. Columns that are >95% NULL are candidates for `internal`. |\n| **Join cardinality** | Query FK uniqueness: `group_by: fk_col, aggregate: row_count is count(), having: row_count > 1`. Determines `join_one` vs `join_many`. |\n| **Revenue/amount columns** | When multiple money columns exist (`total`, `subtotal`, `amount`, `price`), query a sample to understand how they relate (does `total = subtotal + tax`?). |\n| **Join key value compatibility** | For every proposed join, sample 5-10 actual values from each side. Check for format mismatches: abbreviations (\"4th Av\" vs \"4 Avenue\"), ordinals (\"23 St\" vs \"23rd St\"), casing, prefixes. Mismatched values mean the join won't work even if column names match. |\n| **Mixed-grain rows** | For each key table, run top-N and bottom-N by primary metric. Look for summary/aggregate rows mixed with detail data (e.g., \"System Total\" rows in a station-level table). These corrupt measures if not filtered out. |\n\n**Never assume from column names.** Always query the data to confirm. A column named `total` could include or exclude tax. A `status` column could have unexpected values. A FK could have orphaned references.\n\n### Example Queries\n\n**Tier boundaries**: query distribution, propose breaks from percentiles:\n```malloy\nrun: orders -> {\n aggregate:\n min_val is min(sale_price), p25 is sale_price.percentile(25)\n median_val is sale_price.percentile(50), p75 is sale_price.percentile(75)\n p95 is sale_price.percentile(95), max_val is max(sale_price)\n}\n```\n\n**Denormalized vs joined**: compare pre-computed column against real aggregate, report match rate:\n```malloy\nrun: customers -> {\n join_many: orders on customer_id = orders.customer_id\n aggregate:\n total is count()\n match is count() { where: order_count = count(orders.order_id) }\n}\n```\n\n**Canonical date**: when multiple date columns exist, check how often they differ:\n```malloy\nrun: orders -> {\n aggregate:\n total is count()\n same_date is count() { where: created_at::date = submitted_at::date }\n max_gap_days is max(days(submitted_at - created_at))\n}\n```\n\n**Revenue columns**: when multiple money columns exist, verify their relationship:\n```malloy\nrun: orders -> {\n aggregate:\n total_eq_parts is count() { where: abs(sale_price - (subtotal + tax)) < 0.01 }\n total is count()\n}\n```\n\n### Schema Shape\n- Is this a star/snowflake schema (use base + joined source layers) or normalized/ER-style (may need 3-stage pattern)?\n- Combined vs split tables: prefer filtered/split tables over combined when both exist.\n\n## Computed Source Detection\n\nFlag potential computed sources when:\n\n1. **Grain mismatch**: the analytical scope requires a grain that no physical table provides (e.g., customer-level metrics from an order-grain table)\n2. **Repeated aggregation patterns**: the same GROUP BY + aggregate pattern would be needed in multiple analyses\n3. **Cross-entity aggregations**: the model or the data implies cross-entity aggregations that require a pre-aggregated entity\n\n## Prior Art Detection\n\nCheck for prior art signals at the start of discovery. If a signal is found and the user confirms, **you MUST read** the corresponding reference skill and follow its instructions.\n\n| Signal | Source Type | Reference to Read |\n|--------|------------|-------------------|\n| `.lkml` files in project or subdirectories | lookml | `skill:malloy-lookml-review` |\n| `dbt_project.yml` in project or parent dirs | dbt | dbt review (future) |\n\nThe reference handles inventory, classification, and produces prior-art notes. Keep those notes in-conversation, then continue with normal discovery below.\n\n**If DB connection available (LookML + DB mode):**\n- Read the model and run `execute_query` as normal\n- Use prior art as additional context, not a replacement for data validation\n- **The LookML connection name is NOT the Malloy connection name.** Always use the connection name from the model.\n\n**If no DB connection (LookML-only mode):**\n- Skip the model-read and `execute_query` steps\n- Use connection name and table paths extracted from prior art source files\n- Flag all proposals in Steps 2-4 as **unvalidated**\n- Proceed directly to Step 2 (PROPOSE SCOPE)\n\n**Prior art findings enhance discovery, they don't replace it.** When a DB connection is available, always validate assumptions against the actual data.\n\n## After Discovery\n\nDo NOT present findings to the user yet.\n\n## Done\n\nStep complete. Output: discovery findings (internal: tables, columns, relationships, data quality, prior art). Continue to the next modeling step (see your modeling workflow).\n\n## Verify Source Joins\n\nWhen reading joins off the model or the data, watch for `join_many` where the actual relationship is many-to-one. Always verify cardinality. Prefer `join_one` when each row in the primary table matches at most one row in the joined table." }, { name: "malloy-document", description: 'Add documentation with #(doc) tags to Malloy models so fields and sources are described in plain language. Use when user asks to "add documentation", "add doc tags", "document the model", or wants fields and sources described for natural-language search and discovery. For declaring parameterizable filters with #(filter), see the malloy-model skill. Filters are a runtime/modeling construct (governance, latency, correctness), not a documentation tag.', body: "# Documenting a Malloy Model\n\nAdd `#(doc)` tags to describe sources and fields in plain language so they are easy to find and understand:\n\n| Tag | Purpose | Goes on |\n|-----|---------|---------|\n| `#(doc)` | Plain-language description for natural-language search | source, dimension, measure, view, join |\n| `#(filter)` | Declare a parameterizable filter (runtime/modeling concern, see `malloy-model`) | source |\n\n`#(doc)` is a standard Malloy annotation. It documents a field or source with a human-readable description that downstream tools can surface and search against.\n\n## #(doc) Tag\n\nAdd before any source, dimension, measure, view, or join. When multiple fields share a keyword, use it once as a block header. Tags and field names are indented under the keyword; tags go on the line(s) directly above the field they annotate.\n\n**Tag ordering** (when a field has multiple tags): `#(doc)` → render tags (`# currency`, `# label`, etc.) → field name. Separate each field group with a blank line:\n\n```malloy\n#(doc) Customer who placed the order\njoin_one: users with user_id\n\ndimension:\n #(doc) Date the order was placed (UTC)\n order_date is created_at::date\n\nmeasure:\n #(doc) Total revenue from all orders in USD\n # currency\n revenue is sum(total)\n```\n\n### Writing Doc Strings for Retrieval\n\nDoc strings power natural-language search: users type plain-English questions and the system matches against your `#(doc)` strings. Write descriptions that match how analysts would search:\n\n- **Include business meaning**, not code mechanics: what it represents, not how it's implemented\n- **Include units** (USD, count, percentage) and valid values for categorical fields\n- **Avoid Malloy jargon**: never use \"filterable\", \"groupable\", \"dimension\", \"measure\", \"aggregation\"\n\n**Good examples:**\n- `#(doc) Total revenue from completed orders in USD` matches \"what was our revenue?\"\n- `#(doc) Customer signup date (UTC)` matches \"when did the customer join?\"\n- `#(doc) Order status: pending, processing, shipped, delivered, cancelled` matches \"what are the order statuses?\"\n\n**Bad examples:**\n- `#(doc) Filterable dimension for order status`: no analyst searches for \"filterable\"\n- `#(doc) Groupable by region`: \"groupable\" is a system concept\n- `#(doc) Aggregation of total sales`: \"aggregation\" doesn't match natural queries\n\n## #(filter): see `malloy-model`\n\n`#(filter)` is also a `#(...)`-shaped annotation, but unlike `#(doc)` it's a **runtime/modeling construct**: it shapes governance, query latency, and correctness, not discoverability. The full reference (syntax, filter types, `required` / `implicit` flags, and when each applies) lives in `malloy-model` § Parameterizable Filters with `#(filter)` alongside the other source-authoring constructs.\n\nOne rule worth knowing here: filters live on the source, never on the consumer. Ad-hoc reports and notebooks that import a source inherit its filters automatically; they do not (and cannot) declare new ones.\n\n## `internal:` and `private:`: column-level access in a source\n\n`#(doc)` describes what's exposed. Two access modifiers control what's exposed in the first place, and both live **inside** a source's `include {}` block. They are about the source's public API and data sensitivity, not about documentation, so reach for them when curating which columns callers can pick.\n\n| Mechanism | Layer | Why you reach for it |\n|---|---|---|\n| `internal:` | Inside a source (one column in `include {}`) | The column **isn't part of your model's public API**. Common reasons: data is messy (empty/garbage, raw JSON, duplicates), or a documented derived dimension already supersedes it, or the raw column exists only to be joined on / referenced internally and shouldn't appear as a dimension callers can pick. The data may be perfectly fine, it's just not what you want exposed. |\n| `private:` | Inside a source (one column in `include {}`) | The **data is sensitive**: SSN, raw credit card, password. Governance / security concern; a harder block than `internal:`. |\n\nIn one sentence: **`internal:` and `private:` shape what's inside a source's public API; `#(doc)` describes the fields you do expose.**\n\n### Example\n\nA base source pulled from a messy raw table often uses `internal:` to drop raw fields from the public API, while documenting the curated columns with `#(doc)`.\n\n```malloy\n// orders_base.malloy\n#(doc) Raw orders. Use orders.malloy as the entry point for analysis.\nsource: orders_base is conn.table('orders_raw')\n include {\n public: id, customer_id, order_date, total\n internal: raw_json_payload, deprecated_status_code, _temp_dedup_marker\n }\n extend {\n primary_key: id\n }\n```\n\n```malloy\n// orders.malloy\nimport \"orders_base.malloy\"\n\n#(doc) Order analysis. Use for revenue, fulfillment, and customer-order joins.\nsource: orders is orders_base extend {\n // joins, measures, curated dimensions\n}\n```\n\nThe base source stays fully queryable (`run: orders_base -> { ... }` still works); `internal:` only governs which columns appear as public dimensions callers can pick.\n\n## Annotating Columns in Include (Experimental)\n\nWith `##! experimental.access_modifiers`, you can add `#(doc)` tags to raw table columns inside `include` blocks. This documents columns without redefining them as dimensions.\n\n```malloy\n##! experimental.access_modifiers\n\nsource: orders is conn.table('orders') include {\n public:\n #(doc) Order line item identifier\n id\n\n #(doc) Customer email address\n email\n\n #(doc) Order status: pending, shipped, delivered\n status\n\n // internal: only for verified noise (empty cols, raw JSON blobs, duplicates)\n}\nextend {\n // ... dimensions and measures\n}\n```\n\n**When to use:**\n- Documenting raw columns without creating explicit dimensions\n- Curating which columns are public vs internal\n\n## Source-Level Documentation\n\nDocument **when to use** a source, not what it contains. Dimensions and measures can already be searched directly, so the source-level `#(doc)` should describe what questions/analyses this source answers.\n\n**Base source files:** Document what the table represents.\n```malloy\n#(doc) Customer records with demographics and segmentation. One row per customer.\nsource: customers is conn.table('sales.customers') extend { ... }\n```\n\n**Source files:** Document what analytical questions the source answers.\n```malloy\n#(doc) Customer health analysis. Use for retention, segmentation, churn risk, and lifetime value. For order-level analysis, use order_analysis instead.\nsource: customer_health is customers extend { ... }\n```\n\n**Best practices:**\n- Add `#(doc)` to all base source and joined source definitions\n- Base source docs: describe what the table is (one row per what)\n- Source docs: describe what questions/analyses the source answers\n- Documentation happens per-source-file, not in one monolithic file\n\n## Flag Ambiguous Descriptions\n\nAfter writing `#(doc)` tags, present any that required judgment to the user for confirmation:\n\n| Field | Proposed doc | Confidence | Uncertainty |\n|-------|-------------|------------|-------------|\n| `total` | \"Total order amount in USD\" | Medium | Could be gross or net, verified with sample query |\n| `status` | \"Order status: pending, shipped, delivered\" | High | Values confirmed via a query of distinct values |\n\nOnly flag fields where the description required assumptions about business meaning, units, or valid values. When in doubt about valid values, run a quick query against the data to confirm them before writing the description. Use `malloy_getContext` to ground yourself in the package's sources and fields and `malloy_executeQuery` to check distinct values, for example `run: source -> { group_by: status }`.\n\n## Done\n\nStep complete. Output: `#(doc)` tags added to all public fields and sources." }, { name: "malloy-getting-started", description: "First steps for using a Malloy Publisher deployment through its MCP tools. Use when connecting to Publisher for the first time, when you do not yet know the available environments, packages, or models, or when a user asks what data they can explore. Covers verifying the server, discovering data with malloy_getContext, and running a first grounded query.", body: '# Getting started with Malloy Publisher\n\nGoal: go from "connected" to a correct, grounded answer without guessing any names.\n\n## 0. Confirm the tools are reachable\n\nAt minimum you need `malloy_getContext`, `malloy_executeQuery`, and `malloy_searchDocs`. Authoring a model also needs `malloy_compile` and `malloy_reloadPackage` (see section 4); an older Publisher may not serve those two.\n\nIf none of the tools are there, either the server is not running or your client connected before it was. Start the server (`npx @malloy-publisher/server --port 4000`, or `bun run build && bun run start` from a clone) and wait until `curl -s http://localhost:4000/api/v0/status` reports `operationalState: serving`.\n\nIf there is no Publisher workspace here at all, and the user wants to work with data of their own rather than the bundled examples, `npm create @malloy-publisher/malloy-package@latest <name>` scaffolds one: the package and a starter model, registered so the server actually serves it, plus the start script, the MCP config and these skills. Keep the `@latest` when you type it: `npm create` resolves through npm\'s npx cache and an unversioned name is satisfied by any copy already there, so on a machine that has scaffolded before npm never asks the registry and you get an old scaffolder pinning an old server, with nothing to say so. Run bare, it comes with a small sample dataset, so there is something to query straight away. In a fresh directory `npm start` then runs the pinned server against the package in watch mode; if the directory already had a `package.json` the scaffolder leaves it alone and adds no script, printing the equivalent `npx` command to use instead. Where you run it matters: only the package lands in `<name>/`, and the workspace files, the agent instructions and the MCP config among them, are written to the current directory. Run it here if this directory is empty or is meant to become the workspace. If it already holds other work, scaffold into a new directory instead (`mkdir my-data && cd my-data`), because agent config is discovered by walking up, so writing those files here changes what every session beneath this directory inherits. Seed the starter model from a local file with `npm create @malloy-publisher/malloy-package@latest <name> -- --data <path/to/their-file.csv>` (CSV, Parquet, or Excel `.xlsx`), keeping the `--`, which is how `npm create` passes options through. That path is relative to wherever you run the command, so if you scaffolded into a new directory it has to reach back out to their file; the scaffolder copies it into the package and leaves the original alone. A seeded package starts smaller than the sample one, since the scaffolder does not read their columns: expect a row count and an overview, and build the model from there. A package is just Malloy, so it can instead query a database connection the config defines. Because it writes a `.mcp.json` that did not exist when the client connected, the user has to restart or reconnect once before these tools appear, and their client will ask them to approve the new project-scoped server the first time. That only works when the workspace is at the session\'s own root, so if you scaffolded into a new directory below that root, the user has to open a session there instead: a `.mcp.json` further down is never discovered.\n\nIf you started the server yourself in this session, the tools still will not appear: your tool list was fixed when you connected, and you cannot reconnect yourself. Tell the user the tools are missing for that reason and ask them to run `/mcp`, select `malloy`, and choose Reconnect. The panel offers `Authenticate` first and reports `Auth: not authenticated`; that is a red herring, the endpoint has no auth. Restarting Claude Code also works. Continue once the tools are there.\n\nWhen a user is present, do not route around it by calling the REST API with curl. It appears to work, so the user never learns their session is missing the tools, and you lose what they are for: grounded discovery instead of guessed names, `malloy_compile` instead of throwaway queries, and `malloy_reloadPackage` instead of a restart. Say the tools are missing and let the user fix it in five seconds. Running unattended, with nobody who can reconnect you, is different: there the REST API is the supported interface, not a workaround. Discovery, query, compile, and reload all have REST equivalents (`malloy_searchDocs` and `malloy_getContext`\'s plain-English ranking do not; read the bundled skills for syntax and ground from model metadata instead); the running server serves the full spec at `http://localhost:4000/api-doc.yaml`, and AGENTS.md carries the endpoint map.\n\n## 1. Discover what exists (never guess names)\n\n`malloy_getContext` is progressive. Call it with as much as you know:\n\n- No arguments: the available environments, each with its package names.\n- `environmentName` only: the packages in that environment.\n- `environmentName` + `packageName`: that package\'s sources.\n- `environmentName` + `packageName` + `query` (plain English): the sources, views, named queries, and dimension/measure fields most relevant to the question.\n\nUse the names it returns exactly. Do not invent environments, packages, sources, or fields.\n\n## 2. Run the query\n\nCall `malloy_executeQuery` with the `environmentName`, `packageName`, and `modelPath` from the context results, plus either:\n\n- a named view or query: pass its `name` as `queryName` (with `sourceName` for a view), or\n- an ad-hoc query: pass Malloy code as `query`.\n\nThe result is JSON. Charts and dashboards defined in the model render in the Publisher UI at http://localhost:4000.\n\n## 3. When you need Malloy syntax\n\nUse `malloy_searchDocs` for language questions (filters, aggregates, joins, nesting, renderers).\n\n## 4. What else you can do here\n\nAnswering questions is the start, not the whole surface. When the user asks what is possible, say so rather than offering queries alone. Switch skills for the deeper work:\n\n- `malloy-modeling`: build or change a model. Validate the edit with `malloy_compile`, save it, then `malloy_reloadPackage` so the new sources and views run by name without restarting the server.\n- `malloy-analysis`: explore a package and answer data questions.\n- `malloy-html-data-apps`: build a data app, a hand-authored HTML page in the package\'s `public/` directory that Publisher serves, backed by the package\'s models and needing no build step.\n- `malloy-review`: check Malloy for correctness.\n\n## Contract\n\n- Ground every query in `malloy_getContext` results. If a name is not in the results, do not use it.\n- Start broad and narrow down: environments, then packages, then sources, then query.\n- Confirm the environment and package before running a query.' }, { name: "malloy-gotchas-modeling", description: "Common Malloy modeling mistakes and how to avoid them. Read BEFORE writing source definitions, dimensions, measures, or joins. Covers reserved words, NULL checks, date functions, type casts, field management (extend except/accept/rename vs include public/internal/private), and query-based source gotchas.", body: "# Modeling Gotchas\n\n> **Read this before writing Malloy code.** These patterns cause most modeling errors.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n## Reserved Words: Backtick Them\n\n**When in doubt, backtick it.** Unquoted reserved words cause cascading errors on unrelated lines.\n\n```malloy\n// WRONG // RIGHT\ndimension: d is Date::date dimension: d is `Date`::date\n```\n\nWords most likely to appear as column names:\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n\n## NULL Checks: `is not null`, NOT `!= null`\n\n```malloy\n// WRONG // RIGHT\ndimension: is_sold is sold_at != null dimension: is_sold is sold_at is not null\n```\n\n## Date Functions vs Properties\n\n```malloy\n// WRONG: day_of_week is a function // RIGHT\ndimension: dow is created_at.day_of_week dimension: dow is day_of_week(created_at)\n```\n\n**Property access:** `.month`, `.year`, `.quarter`, `.day`, `::date`\n**Function call required:** `day_of_week()`, `week()`, `hour()`, `minute()`, `second()`\n\n## `.date` Is a Cast, Not a Truncation\n\nCalendar truncations are `.day`, `.week`, `.month`, `.quarter`, `.year` (plus `.hour`, `.minute`, `.second` for timestamps). `.date` is **not** among them: it's a **cast** (`::date`), not a truncation, so `created_at.date` does not compile. This bites twice: once at compile time, and again as a latent bad `#(doc)` comment that only a review pass catches (\"truncated to date\" is a doc smell; it should say \"to day\").\n\n```malloy\n// WRONG // RIGHT\ncreated_at.date created_at.day // truncate to day\n created_at::date // cast to a date\n```\n\n## Interval Functions: Only `seconds` / `minutes` / `hours` / `days`\n\n`weeks()`, `months()`, `quarters()`, `years()` are **documented but don't work** in this build; only `seconds`, `minutes`, `hours`, `days` actually function. Compute in days and derive the larger unit: a *units conversion*, not a calendar-floored duration:\n\n```malloy\n// WRONG: weeks()/months() don't compile\ndimension: weeks_open is weeks(opened_at to closed_at)\n\n// RIGHT: measure in days, convert (documents that it's approximate)\ndimension: days_open is days(opened_at to closed_at)\ndimension: weeks_open is days(opened_at to closed_at) / 7 // ≈ weeks\ndimension: months_open is days(opened_at to closed_at) / 30.44 // ≈ months\n```\n\n(Contrast: `search_malloy_docs` gets this right when asked narrowly; trust the docs on the supported units, not on the missing ones.)\n\n## Safe Division: Always `nullif`\n\n```malloy\n// WRONG // RIGHT\na / b a / nullif(b, 0)\n```\n\n## String Columns Need Casts for Aggregates\n\n```malloy\n// WRONG: \"Can't use type string\" // RIGHT\nmeasure: avg_score is avg(score) measure: avg_score is avg(score::number)\n```\n\n**Dirty columns: null the sentinel before casting.** `::number` is a strict cast, so a column that carries non-numeric sentinels (`'NA'`, `'N/A'`, `''`, `'-'`, `'null'`) compiles fine but fails at query time with `Could not convert string 'NA' to DOUBLE`. Strip the sentinel with `nullif` first, then cast (aggregates skip nulls):\n\n```malloy\n// WRONG: throws on 'NA' at query time // RIGHT: nulls 'NA', then casts\nmeasure: s is avg(score::number) measure: s is avg(nullif(score, 'NA')::number)\n```\n\nChain `nullif` for multiple sentinels: `nullif(nullif(score, 'NA'), '')::number`. Sample the column's values first (`run: source -> { group_by: score; limit: 20 }`) to see which sentinels it uses.\n\n## Boolean Columns: No Quotes\n\n```malloy\n// WRONG // RIGHT\ncount() { where: complaint = 'true' } count() { where: complaint = true }\n```\n\nCheck schema: if `BOOL`, use `true`/`false`. If `STRING`, use `'true'`/`'false'`.\n\n## `greatest()` / `least()` Are Null-Poisoning\n\nMalloy's `greatest()` / `least()` return **NULL if *any* argument is null**, unlike Postgres `GREATEST`/`LEAST`, which ignore nulls. Porting a LookML/SQL expression verbatim is a silent parity bug: the number just goes null for any row with a missing input. Coalesce the result back to a non-null argument:\n\n```malloy\n// WRONG: one null input nulls the whole thing\ndimension: last_touch is greatest(email_at, call_at)\n\n// RIGHT: fall back so a null arg can't poison the result\ndimension: last_touch is greatest(email_at, call_at) ?? email_at ?? call_at\n```\n\n## No Scalar Median; Raw-SQL Aggregates Don't Compile\n\n**There is no scalar `median`, and `PERCENTILE_CONT` cannot be expressed as a measure in this build.** Every documented form for a custom SQL aggregate - `percentile_cont!(x, 0.5)`, `sql_number(...)`, `sql_number(...) { is_aggregate: true }`, and the `# is_aggregate` annotation - resolves as a **scalar** and fails with *\"Cannot use a scalar field in a measure declaration.\"* The docs' own `avg_dist` example fails the same way. This is a deployed-runtime limitation, not a syntax error you can fix: **do not** burn cycles trying `!`, `sql_number`, or `is_aggregate` variations to get a median.\n\n```malloy\n// DOES NOT COMPILE in this build (all forms resolve as scalar):\nmeasure: median_x is percentile_cont!(x, 0.5)\nmeasure: median_x is sql_number(\"PERCENTILE_CONT(...) ...\") { is_aggregate: true }\n```\n\n**Ship `avg` instead, or defer median with a documented gap** (\"median deferred: no scalar median / runtime rejects raw-SQL aggregates\"). Tell the user; don't silently substitute `avg` for a metric that was specified as median.\n\n**`stddev` does work**, so reach for it when the question is about spread. It is a native Malloy aggregate rather than a raw-SQL escape, so unlike everything above it compiles both inline and as a `measure:`, and it is the sample standard deviation. `variance`, `stddev_samp`, and `stddev_pop` are not Malloy functions, and pushing them through `!` fails as a scalar exactly like `percentile_cont!`.\n\n```malloy\n// WORKS: inline, or as a measure on a source\nrun: order_items -> { aggregate: sd is stddev(sale_price) }\nsource: items is order_items extend { measure: price_stddev is stddev(sale_price) }\n```\n\n## Field Management: `extend {}` vs `include {}` Don't Compose\n\nMalloy has two field-management mechanisms for base sources. **`include {}` is the curated default; `extend { except / accept / rename }` is the fallback when a `rename:` is unavoidable.** They have different capabilities and **do not combine**.\n\n| Mechanism | Where it lives | Keywords | Compatible with `rename:`? | Experimental flag? |\n|---|---|---|---|---|\n| Access modifiers (default) | `include {}` | `public:` / `internal:` / `private:` | **No** | Yes (`##! experimental.access_modifiers`) |\n| Field management (fallback) | `extend {}` | `accept:` / `except:` / `rename:` | Yes (same block) | No |\n\n### Default: `include {}` for documented, curated base sources\n\nUse `include {}` whenever the source doesn't need a `rename:`. It's the only way to attach `#(doc)` tags to raw columns, and it's the canonical way to hide empty/garbage/duplicate columns (`internal:`) and sensitive ones (`private:`). See `skill:malloy-model` § Access Modifiers.\n\n```malloy\n##! experimental.access_modifiers\nsource: orders is conn.table('orders') include {\n public:\n #(doc) Order identifier\n order_id\n\n #(doc) Customer who placed the order\n user_id\n\n internal:\n raw_payload_json // empty after JSON extraction\n legacy_status_code // superseded by status_code\n}\n```\n\n### When `rename:` is unavoidable: fall back to `extend {}`\n\n`include {}` does not compose with `rename:`. The combination errors with `Can't find field 'X' to set access modifier` because `rename:` runs first and leaves no `X` for `include` to attach a modifier to. There's also a collision inside `include {}` itself: a measure cannot share a name with a raw column, even one tagged `internal:` (`Cannot redefine 'X'`), and the natural fix for that is `rename:`, which then triggers the first error.\n\nWhen a rename is genuinely required (most often during `conn.sql()` to `conn.table()` migration where a SQL alias matches a measure name that's already in heavy use downstream), drop `include {}` and curate the source with `extend { except: ... }` + `rename:` instead. You forfeit `#(doc)` on raw columns and the `public/internal/private` tiers, but keep column gating and the rename.\n\n```malloy\n// RIGHT: rename is required to free `revenue` for the measure\nextend {\n except: legacy_status_code // hide garbage column without include {}\n rename: raw_revenue is revenue\n measure: revenue is raw_revenue.sum()\n}\n```\n\nIf you can rename the measure or split the source instead, prefer that: it preserves `include {}` and the curated surface.\n\n### `extend {}` clauses (reference)\n\n- **`accept:`**: allow-list, keep only the named columns\n- **`except:`**: deny-list, drop the named columns; keep everything else (mutually exclusive with `accept:`)\n- **`rename:`**: alias a raw column to free up its original name for a measure or dimension\n\n### Migrating `conn.sql()` to `conn.table()` + Malloy clauses\n\nThe biggest reason teams reach for `conn.sql()` is column gating, aliasing, and per-row derivation in one place. All three have native equivalents:\n\n1. **Verify the schema**: `run: <source> -> { select: *; limit: 1 }` to discover all columns. Anything in the table but not in the SQL's `SELECT` was being intentionally hidden, so preserve that gating.\n2. Switch to `conn.table('…')`.\n3. Hidden columns: preferably `include { internal: ... }` (lets you also `#(doc)` the public columns). If a `rename:` is also needed in the same source, fall back to `extend { except: ... }`.\n4. SQL aliases: `extend { rename: ... }` (forces the fallback path, since `rename:` and `include {}` don't compose). If the alias was to free up a name for a measure, use `rename: raw_X is X`, then `measure: X is raw_X.sum()`.\n5. SQL derivations: `dimension:` definitions in `extend {}`.\n6. SQL `WHERE`: source-level `where:`.\n\n## Cannot Redefine Query-Based Source Columns\n\nColumns from `table -> { group_by, aggregate }` or `conn.sql()` already exist. You cannot re-declare them.\n\n```malloy\n// WRONG: \"Cannot redefine 'user_id'\"\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: user_id is user_id }\n// RIGHT: add only NEW derived dimensions\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: is_high_value is total > 1000 }\n```\n\nTo add `#(doc)` tags to existing query columns, use `include {}` between the query and extend.\n\n## Extending a Source Cannot Reuse a Name It Already Defines\n\n```malloy\n// WRONG: \"Cannot redefine 'overview'\" when sales already declares view: overview\nsource: wines is sales extend { view: overview is { aggregate: record_count } }\n// RIGHT: give the extension its own name\nsource: wines is sales extend { view: summary is { aggregate: record_count } }\n```\n\nAn extension adds to the parent's namespace, it does not override it. This bites when you extend a source to \"replace\" one of its views: rename the new definition, or edit the view on the parent source instead of extending it. Malloy reports the same `Cannot redefine 'X'` for dimensions and measures that collide with an inherited name, per the sections above and below.\n\n## Never Use `conn.sql()` When Malloy Has a Native Pattern\n\n```malloy\n// WRONG: raw SQL for pre-aggregation\nsource: facts is conn.sql(\"\"\"SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id\"\"\")\n// RIGHT: Malloy query-based source\nsource: facts is conn.table('orders') -> { group_by: user_id, aggregate: total is sum(amount) }\n```\n\n**Mandatory: call `search_malloy_docs` before reaching for `conn.sql()`.** Don't argue from intuition. Most patterns that look SQL-only have a Malloy equivalent, including the ones reviewers historically said couldn't be expressed.\n\n| Looks like it needs SQL | Malloy equivalent |\n|---|---|\n| Multi-CTE pipeline | Stacked query-based sources: `source: a is t -> {...}`; `source: b is a -> {...}`; `source: c is b -> {...}` |\n| UNNEST / array column access | `array_column.each.field`: arrays auto-join as nested tables ([data types docs](https://docs.malloydata.dev/documentation/language/datatypes#array-access)) |\n| PIVOT (conditional aggregation) | Filtered aggregates: `aggregate: a is x.sum() { where: cat = 'a' }, b is x.sum() { where: cat = 'b' }` |\n| Window functions (any frame, including custom) | `calculate:` with `sum_cumulative`, `lag`, `lead`, `rank`, `row_number`, `avg_moving`, `first_value`, `last_value`: supports `partition_by:` and `order_by:` ([window functions docs](https://docs.malloydata.dev/documentation/language/functions#window-functions)) |\n| `ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING` | `sum_cumulative(x) - x` (cumulative-including-current minus current = cumulative-excluding-current) |\n| `WHERE date = (SELECT max(date) FROM …)` (latest snapshot) | `join_cross` to a one-row aggregate source, then filter on the joined `max_date` field |\n| Multi-key joins | `join_one: x is target on a = x.a and b = x.b and c = x.c` |\n| `greatest()` / `least()` / `CASE` chains | All native: `greatest(a, b, c)`, `least(a, b)`, `pick 'x' when cond else 'y'` |\n| Dialect-specific scalar functions | `function_name!return_type(args)`: Malloy's raw-SQL function escape (no `conn.sql()` block needed) |\n\n**Genuinely valid `conn.sql()` candidates (rare):**\n\n- SQL features Malloy explicitly doesn't model (e.g., DML/DDL, specific `MERGE` patterns)\n- Multi-stage transformations where every CTE has 3+ joins to different tables AND the result is consumed by multiple downstream sources, but in this case an intermediate table in the data warehouse is usually still better than `conn.sql()`\n\n**Never use `conn.sql()` for:** simple column selection or renaming, `WHERE` filters, two-table joins, column type casts, latest-snapshot patterns, conditional aggregation, or window functions of any kind.\n\nIf a project's standards file specifies a stricter policy (e.g., a `search_malloy_docs` rationale comment requirement above every `conn.sql()` block), defer to that.\n\n## JSON Files: Read Them In Place Like CSV\n\n```malloy\n// RIGHT: .json works like .csv/.parquet\nsource: reviews is duckdb.table('data/reviews.json')\n// RIGHT: newline-delimited JSON is read the same way\nsource: events is duckdb.table('data/events.ndjson')\n// RIGHT: read options need read_json_auto in a SQL source\nsource: nested is duckdb.sql(\"\"\"SELECT * FROM read_json_auto('data/reviews.json')\"\"\")\n// WRONG: shelling out to python, or converting to CSV first\n```\n\nDuckDB reads JSON directly, so never preprocess a `.json` file before modeling it and never reach for a scripting language to inspect one. Both a top-level array of objects and newline-delimited JSON work through `duckdb.table()`.\n\nQuirk: JSON carries no schema, so a value written as `\"90\"` arrives as a string where the same data in CSV would be inferred as a number. Cast it in the source, under a new name (reusing the column's own name is a redefinition error):\n\n```malloy\nsource: reviews is duckdb.table('data/reviews.json') extend {\n dimension: points_num is points::number\n}\n```\n\n## Excel Files: Read `.xlsx` In Place, Never Convert\n\n```malloy\n// RIGHT when the sheet is a plain table (header in row 1, data under it, no blank row inside\n// it): read it where it sits, like .csv/.parquet (in a Publisher package the sandbox\n// connection is `duckdb`)\nsource: budget is duckdb.table('data/budget.xlsx')\n// RIGHT for anything messier. Profile the top rows first to find the real header row and the\n// last real column, because nothing else will tell you where they are. Put the probe in the\n// model file as its own source: Publisher refuses raw SQL in an ad-hoc query.\n// SELECT * FROM read_xlsx('data/sales.xlsx', sheet = 'Sales Data',\n// range = 'A1:Z15', header = false, all_varchar = true)\nsource: sales is duckdb.sql(\"\"\"\n SELECT * FROM read_xlsx('data/sales.xlsx',\n sheet = 'Sales Data', -- EDIT: only the first sheet is read by default\n header = true,\n range = 'A5:J100000' -- EDIT: A5 is the real header row. Keep the column bound at the\n ) -- last real column; the row bound just has to clear the end.\n WHERE \"Order ID\" LIKE 'SO-%' -- EDIT, REQUIRED: a data-row predicate. This is what ends the\n\"\"\") -- read; drop it and every empty row in the range comes back.\n// WRONG: converting the spreadsheet to Parquet or CSV first (an unnecessary extra step)\n```\n\nDo not convert spreadsheets before modeling. DuckDB's excel extension reads `.xlsx` directly and loads automatically on first use, so a sheet that is a plain table needs nothing more than `duckdb.table()`. Converting does not avoid any of the problems below, it just moves them into a copy that goes stale the next time someone updates the workbook.\n\n**Plenty of real exports are not plain tables, and nothing tells you.** A report title, a \"generated on\" banner, a merged group header, a blank line above the header, or a blank spacer row inside the data are all ordinary, and none of them is visible from Malloy. There is no error either: the package loads, the server reports serving, the query returns 200, and the number is just wrong. So make two checks before building on the read: compare `aggregate: record_count is count()` against what you know is in the file, and `select: *; limit: 1` to see what the columns really are. If either disagrees with the file, the read is wrong and so is every measure over it.\n\n`table()` takes a plain file path only, so anything needing `read_xlsx` options (`sheet`, `range`, `header`, `ignore_errors`, `normalize_names`, `all_varchar`, `empty_as_varchar`, `stop_at_empty`) goes through the SQL-source form.\n\nQuirks:\n\n- Only the FIRST sheet is read by default. Select another with `sheet = 'Name'`. There is no function that lists a workbook's sheet names, but passing one that does not exist reports a suggestion (`Sheet \"x\" not found ... Did you mean: \"Notes\"`), which is one way to find a name you were not given.\n- A title or banner row above the header collapses the read. DuckDB takes the first row it finds as the column names, so a lone title cell in A1 becomes the only column. How many rows you then get is the next quirk's business: whatever sits between the title and the first blank row, often none or one, otherwise a plausible-looking partial count. Pass a `range` that starts at the real header row.\n- With no `range`, `stop_at_empty` defaults to true and the read stops at the first blank row, which on a real sheet is usually a spacer between blocks rather than the end of the data: a 30-row sheet with one spacer after row 10 reads as 10 rows. `stop_at_empty = false` lifts that, but it only helps when the header really is in row 1; with a title above the header you need the `range` anyway, and a `range` flips the default for you. It also hands the blank rows back as all-null rows, so the count comes out one high per spacer until you filter them.\n- A `range` reads every cell inside it, so an overshot bound manufactures padding: past the last real column you get all-null fields (`A5:Z100000` on a ten-column sheet yields 26, the extras named `C10` and `_1` through `_15`), and past the last real row all-null rows (`A5:J100000` on a 1,500-row sheet reads 99,995). Spacers, subtotals, and footnotes come through as rows too. So the row filter is not tidying-up, it is the thing that ends the read: filter to what a data row looks like (`WHERE \"Order ID\" LIKE 'SO-%'`) rather than to `IS NOT NULL`, which keeps any footnote carrying text in the first column. A bound that falls SHORT of the data is the dangerous direction: the rows and columns past it are dropped with no error at all, so overshoot the row bound and let the filter end the read.\n- Every number in an xlsx is stored as a double, so there are no integer columns. Typing is per column and decided by the FIRST data row, and `$1,234`, `12%` and `N/A` are all text: a text cell in that first row makes the whole column a string (on one real export, all ten of them), while a text cell further down leaves the column numeric and makes the read throw instead (`Could not convert string ... to DOUBLE`). `ignore_errors = true` fixes that second case, nulling the bad cells and keeping the column a number. It does nothing for the first.\n- Sample the column's SHAPES before writing any conversion, not its values: `run: source -> { group_by: shape is replace(raw_col, r'[0-9]', '9'); aggregate: n is count(); order_by: n desc }` collapses every value to its format and counts it, so on one real price column the 16 euro-denominated rows surface beside the 1,484 in dollars. A plain `group_by raw_col; limit: 20` sorts lexicographically, which hides exactly the shapes that matter.\n- Convert in the SQL source, not in Malloy, where `::number` throws on the first bad cell. `try_cast(regexp_replace(\"Total Revenue\", '[^0-9.-]', '', 'g') AS double)` nulls what it cannot read instead of failing and is right for a plain `$1,234.56`, but it is not a general parser. It concatenates every digit in the cell, so `1,234 (see tab 2)` becomes 12342. It understands only a leading ASCII `-`, so an accounting `(1,234)`, a Unicode minus and a `CR` suffix all come back positive, while a trailing `-` (`1,234-`) comes back null and drops the row from the sum. And it assumes `.` is the decimal point, so a European `1.234,56` comes back a thousandfold small. Handle the shapes your sample actually found, and divide a percent by 100. Failure is quiet either way: a cast that fails on every row sums to 0 rather than erroring, and a text date strips to a number rather than a null (`'01/02/2023'` becomes 1022023).\n- Check the answer against the sheet's own total row, read as raw text. Lift the data-row filter and select the footer by its label, which usually sits in a different column from the one your data-row predicate uses: on one export `WHERE \"Customer Name\" = 'TOTAL'` finds it and `WHERE \"Order ID\" = 'TOTAL'` returns nothing, and an empty result reads as a pass. Do not run the total through the same expression, because a wrong sign survives a row count, survives `select: *`, and cancels out when both sides are parsed the same broken way.\n- A sheet with no header row whose first row is all text silently loses that row to header detection. Pass `header = false`.\n- Headers with spaces are kept verbatim: backtick them in Malloy, or pass `normalize_names = true` for snake_case names.\n- `all_varchar = true` hands back each cell's stored value as text, so a date arrives as its raw Excel serial number rather than a date: `'44929'` from a sheet Excel wrote, `'44927.0'` from one DuckDB's own xlsx writer wrote, and `'44929.5'` where the cell carries a time of day. Which form you get depends on the tool that wrote the file, so do not detect serials by matching for an integer; `try_cast(... AS double)` accepts all three and returns null for a cell that was stored as text (`'01/02/2023'`), which is the test you want. Convert with `date '1899-12-30' + floor(try_cast(d AS double))::int`, not from 1900-01-01. Both wrappers earn their place: adding a double to a date does not compile, and a bare `::int` rounds, so an afternoon timestamp would land on the next day.\n- A date column that mixes both, which is what an export edited by hand gives you, needs both branches or you silently lose every row of one kind: `CASE WHEN try_cast(d AS double) IS NOT NULL THEN date '1899-12-30' + floor(try_cast(d AS double))::int ELSE try_strptime(d, '%m/%d/%Y')::date END`. Without `all_varchar`, a uniformly date-formatted column arrives as real `date` and `timestamp` values, and a stray text cell behaves exactly as the typing rule above says. Note what `ignore_errors = true` does here: it nulls that cell rather than parsing it, so the hand-typed date is lost silently.\n\n## Duplicate Rows: Check Before Building Measures\n\n```malloy\nrun: source -> { group_by: pk_field, aggregate: n is count(), having: n > 1, limit: 10 }\n```\n\nSymptoms: `sum()` returns astronomical values. Causes: event tables, batch retries, merged sources.\n\n## `except:` Removes Fields From Namespace Entirely\n\n`except:` in `include {}` completely removes fields: dimensions and measures cannot reference excluded fields. Use `internal:` instead when derived dimensions need the raw column.\n\n```malloy\n// WRONG: dimension references excluded field\nsource: x is conn.table('t')\ninclude { except: raw_date }\nextend { dimension: order_date is raw_date::date } // ERROR! raw_date is gone\n\n// RIGHT: internal fields are still available in extend\nsource: x is conn.table('t')\ninclude { internal: raw_date }\nextend { dimension: order_date is raw_date::date } // Works\n```\n\n## Source Order: Define Joined Tables First\n\nMalloy compiles top-to-bottom. Define lookup/dimension tables before the source that joins them, or use `import` statements in multi-file projects.\n\n## MUST Search Docs Before Using Unfamiliar Patterns\n\nCall `search_malloy_docs` BEFORE first use of any of these. Don't guess the syntax:\n- `pick` expressions\n- Window functions (`calculate`)\n- `percentile` or statistical functions: but see the hard limit above, raw-SQL aggregates (`sql_number` / `is_aggregate` / `percentile_cont!`) do **not** compile as measures in this build; there is no scalar median (`stddev` is the exception and does work as a measure)\n- Time interval functions (`days()`, `seconds()`): only `seconds`/`minutes`/`hours`/`days` exist (see above)\n- Query-based sources (`from()`)\n- `!` operator / `sql_number()`" }, { name: "malloy-gotchas-queries", description: "Common Malloy query and view mistakes. Read BEFORE writing views, queries, or notebooks. Covers chart constraints, aggregate filters, joined field aliasing, method syntax, and time truncation vs extraction.", body: "# Query & View Gotchas\n\n> **Read this before writing views or queries.** These patterns cause most query errors.\n\n## Charts: ONE Aggregate Per View\n\nCharts render only the **first** aggregate. Use exactly one aggregate per `# bar_chart` / `# line_chart` view.\n\n```malloy\n// WRONG: revenue is ignored\n# bar_chart\nview: x is { group_by: status, aggregate: order_count, revenue }\n// RIGHT: single aggregate\n# bar_chart\nview: x is { group_by: status, aggregate: revenue }\n```\n\nFor multiple metrics: nest separate chart views in a `# dashboard`, or use `y=['revenue','cost']` for multi-measure series.\n\n## Joined Fields in `order_by`: Must Alias First\n\n```malloy\n// WRONG: compile error\nview: x is { group_by: races.season_year, aggregate: pts, order_by: races.season_year }\n// RIGHT: alias then reference\nview: x is { group_by: yr is races.season_year, aggregate: pts, order_by: yr }\n```\n\nAny time you `group_by` a joined field, create an alias and use it in `order_by`.\n\n## `having:` vs `where:`: Aggregate Filters\n\n```malloy\n// WRONG: \"Aggregate expressions not allowed in where\"\nview: x is { group_by: cat, aggregate: n is count(), where: n > 10 }\n// RIGHT\nview: x is { group_by: cat, aggregate: n is count(), having: n > 10 }\n```\n\n- `where:` filters rows BEFORE aggregation (dimensions/raw columns)\n- `having:` filters AFTER aggregation (measures)\n\n## Aggregating Joined Fields: Method Syntax\n\n```malloy\n// WRONG: compile error: \"Join path is required for this calculation; use 'inventory_items.item_cost.sum()'\"\nmeasure: cogs is sum(inventory_items.item_cost)\n// RIGHT: method syntax\nmeasure: cogs is inventory_items.item_cost.sum()\n```\n\n`sum`, `avg`, `min`, and `max` over a dotted joined path all produce that compile error; the diagnostic message even tells you the exact fix. Don't worry about catching this in code review; the compiler does it for you.\n\n**Method syntax is for aggregates over a path. Scalar functions never take it.**\n\n```malloy\n// WRONG: \"something is missing before 'round'\"\naggregate: avg_price_r is avg(price).round(2)\naggregate: avg_price_r is price.avg().round(2)\n// WRONG: \"Cannot call function round(number, number) with source\"\naggregate: avg_price_r is avg_price.round(2)\ndimension: rounded is price.round(2)\n// RIGHT: scalar functions are always call form\naggregate: avg_price_r is round(avg(price), 2)\ndimension: rounded is round(price, 2)\n```\n\nTwo separate rules produce those errors:\n\n- **No method call chains onto the result of a function call.** `avg(price).round(2)` and `price.avg().round(2)` are both parse errors. The message names `round` without saying it is unsupported in that position, so it reads like a typo somewhere else. `.floor()` and `.ceil()` fail identically.\n- **Scalar functions have no method form.** `round`, `floor`, and `ceil` are always `round(x, 2)`, never `x.round(2)`, whether `x` is a named measure or a plain column.\n\n`price.avg()` and `inventory_items.item_cost.sum()` are correct because `avg` and `sum` are aggregate functions over a field path, which is exactly what method syntax is for.\n\n**Exception: `count(joined.field)` is correct, not a bug.** `count(joined.field)` is the **canonical Malloy idiom** for distinct-count through a join. Keep it as-is even when nearby `sum`/`avg`/`min`/`max` calls have to use method syntax. The closest method-syntax form `joined.count()` counts *rows* in the joined source (different semantics, differs from the distinct count when the joined field has duplicates within the joined table). The Malloy docs example `joined.count(field)` does NOT compile against current Malloy (error: `Expression illegal inside path.count()`); it only works for double-nested paths like `aircraft.count(aircraft_models.code)`.\n\n## `sum`/`avg` Need a Numeric Field\n\n```malloy\n// WRONG: \"Can't use type string\" - status is a string column\naggregate: avg_status is avg(status)\n// RIGHT: aggregate a numeric field; count a string one\naggregate: avg_price is avg(price), statuses is count(status)\n```\n\nCheck the field's type in the `get_context` result before aggregating it. A name that reads numeric (`order_number`, `zip`, `account_id`) is very often typed string.\n\n## Dotted Paths Must Name a Declared Join\n\n```malloy\n// WRONG: the source declares the join as `carrier`, so this fails with\n// \"'carriers.name' is not a source or join\"\nrun: flights -> { group_by: carriers.name }\n// RIGHT: use the join name the source actually declares\nrun: flights -> { group_by: carrier.nickname }\n```\n\nA dotted path resolves only against a join declared on the source you are running. Confirm both the join name and the field under it in a `get_context` result; do not infer either from a table name or a plural/singular guess.\n\n## `order_by:` Can Only Name an Output Column\n\n```malloy\n// WRONG: \"Unknown field total in output space\" - total is never emitted\nrun: orders -> { group_by: state, aggregate: revenue is sum(total), order_by: total }\n// RIGHT: order by a column the query actually outputs\nrun: orders -> { group_by: state, aggregate: revenue is sum(total), order_by: revenue }\n```\n\n`order_by:` resolves against the query's *output* columns, not the source's fields. To order by something, `group_by` or `aggregate` it first - and if it comes through a join, alias it (see above).\n\n## Chart Annotation Placement\n\nPlace `# bar_chart` / `# line_chart` on the **nested view definition**, not on `nest:` itself. Putting it on `nest:` causes \"not a repeated record\" errors.\n\n## DRY: Define in Source, Reference in View\n\n```malloy\n// WRONG: inline in view\nview: summary is { aggregate: revenue is sum(total) }\n// RIGHT: reference existing measure\nview: summary is { aggregate: revenue }\n```\n\n## Time Truncation vs Extraction\n\n| Syntax | What it does | Returns |\n|--------|--------------|---------|\n| `ts.month` | Truncates to start of month | Timestamp (`@2024-03-01`) |\n| `month(ts)` | Extracts month number | Integer (1-12) |\n| `ts.year` | Truncates to start of year | Timestamp (`@2024-01-01`) |\n| `year(ts)` | Extracts year number | Integer (2024) |\n\nUse `.month` for time series charts (proper date ordering). Use `month()` for cross-year comparison.\n\n**Year integers render with commas.** `year(ts)` displays as `2,018`. Tag with `# number=id` to suppress commas. Same for zip codes, IDs.\n\n## `?` Alternation: Use Commas to Combine Filters\n\nThe `?` operator is Malloy's **alternation operator**: a shorthand for \"match any of these values.\" `party ? 'Democrat' | 'Republican'` means `party = 'Democrat' OR party = 'Republican'`. The `|` separates the alternatives.\n\nWhen combining an alternation filter with other filters, **use a comma**:\n\n```malloy\n// CANONICAL: commas separate independent filter conditions\nwhere: is_us = true, party ? 'Democrat' | 'Republican'\n```\n\n`and` works in some arrangements (when the alternation is the second operand) but produces a confusing `'logical operator' Can't use type string` compile error when the alternation comes first. The comma form is unambiguous in every position, so just use it.\n\n## Query Clauses Are Newline-Separated\n\nDo not use trailing commas between query clauses. Each clause goes on its own line.\n\n```malloy\n// WRONG: trailing comma before limit\nrun: source -> { group_by: status, aggregate: n is count(), limit: 10 }\n// RIGHT: newline-separated\nrun: source -> {\n group_by: status\n aggregate: n is count()\n limit: 10\n}\n```\n\nClauses: `group_by:`, `aggregate:`, `nest:`, `order_by:`, `limit:`, `where:`, `having:`, `select:`, `calculate:`\n\n## Fields Within a Clause: Commas or Newlines, Never Semicolons\n\nSemicolons are not a separator anywhere in Malloy. Multiple fields under one `aggregate:` / `group_by:` are separated by commas (inline) or newlines (one per line); a `;` fails with `no viable alternative at input '<next-field>'` pointing at the field right after it.\n\n```malloy\n// WRONG: semicolons between fields\nrun: schools -> { aggregate: total is count(); charters is count() { where: is_charter } }\n// RIGHT: commas inline...\nrun: schools -> { aggregate: total is count(), charters is count() { where: is_charter } }\n// ...or newlines\nrun: schools -> {\n aggregate:\n total is count()\n charters is count() { where: is_charter }\n}\n```" }, { name: "malloy-gotchas-rendering", description: "Common Malloy renderer annotation mistakes. Read BEFORE adding chart annotations, formatting tags, or building dashboards. Covers tag syntax, scale rules, sparkline setup, and big_value patterns.", body: `# Rendering Gotchas
288400
+ A confirmed source architecture and a confirmed set of field definitions (renames, dimensions, measures, business decisions), held in the conversation and ready to write into the \`.malloy\` model via your modeling workflow.` }, { name: "malloy-discover", description: "Silent data discovery for Malloy modeling. Used at Step 1 of the modeling workflow. Scans tables, columns, distributions, and relationships without user interaction. The agent builds an internal picture before presenting anything.", body: "# Data Discovery (Step 1, Silent)\n\n> **CRITICAL**: Read the model before writing ANY Malloy code. The model defines the sources, connection names, and fields. Never guess connection names.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n> **PREREQUISITE:** Make sure the Malloy MCP tools (`get_context`, `execute_query`, `search_malloy_docs`) are configured and reachable. If they are not, stop and resolve the MCP connection before continuing.\n\n**This step is silent.** The agent does not present findings to the user yet. That happens in the next step (PROPOSE SCOPE).\n\n## Tools\n\n- **`get_context`**: Ground yourself in the package's sources, views, and fields (with their docs). Call FIRST. The sources and their join paths are the schema you build on.\n- **`execute_query`**: Run ad-hoc queries to preview data, verify values, check NULLs, validate assumptions.\n- **`search_malloy_docs`**: Get Malloy syntax help when needed.\n\n## Workflow\n\n```\n1. Check for prior art signals → If found, ask user: \"I found [LookML/dbt] files, use as prior art?\"\n2. If user confirms: read adapter reference → Follow skill:malloy-lookml-review, keep prior-art notes in-conversation\n3. get_context → Ground yourself: sources, views, fields\n4. Inspect source definitions → See ALL fields and join paths for key sources\n5. Derive candidate joins/dimensions/measures → Read them off the model and the data, not a suggestion tool\n6. Define a minimal source if one is missing → Just enough to run execute_query for previews\n7. execute_query(query) → Preview data, verify values, check NULLs, check duplicates\n8. search_malloy_docs(query) → Get syntax help when needed\n9. Proceed to Step 2 (PROPOSE SCOPE)\n```\n\n**If the model has no sources defined** and no LookML files are present, do NOT silently retry or proceed without data. Tell the user: \"No model sources were found. Please check that the package points at a connected data source, then try again.\"\n\n**If the model has no sources defined** but LookML files ARE present (LookML-only mode), skip steps 3-7. Use connection name and table paths from the LookML review. Flag all proposals as unvalidated.\n\n**Key principle:** Query data to verify assumptions. Don't ask the user to confirm values you can check yourself.\n\n**Search docs proactively.** If you discover patterns that need derived/pre-aggregated sources, window functions, or unfamiliar features, call `search_malloy_docs` BEFORE writing code, not just when you hit errors.\n\n## Query File for Discovery\n\n**In the schema-first workflow:** Run ad-hoc queries with `execute_query`. If the source you want to preview is not yet defined in the model, define a minimal one against the connection and table so you can run previews. The real model fields are built in later steps.\n\n```malloy\n// minimal source for previewing data during discovery\nsource: explore is my_conn.table('schema.table') extend {}\n```\n\n**In analysis-first mode:** There is no temp file. The analysis `.malloy` file IS your working file. It grows throughout the session and becomes the input for formalizing into a model. See `skill:malloy-analyze` for that workflow.\n\n## What to Capture\n\nWhen reviewing tables and columns, capture:\n\n### Table-Level\n- All tables with row counts\n- Connection name and schema (CRITICAL, never guess)\n- Table roles: fact, dimension, bridge, lookup, staging, operational\n- Join relationships (FK → PK mappings)\n\n### Column-Level\n- Primary key and foreign key columns\n- Data types (watch for string dates, arrays, JSON)\n- Reserved word columns that need backticking (`Date`, `Type`, `number`, `source`, etc.)\n- Column cardinality and NULL rates (via `execute_query`)\n- Data distributions for key numeric and categorical columns\n\n### Data Quality\n- **Check for duplicate rows** on primary keys. Run `group_by: pk, aggregate: count(), having: count() > 1` on each key table. Duplicates cause `sum()` to return nonsensical values.\n- **Denormalized count columns**: beware pre-aggregated fields (e.g., `order_count` in a customer table) that may conflict with joined counts.\n- **Delimited list columns**: flag string columns containing comma-separated values.\n\n### Data-Driven Validation\n\n**Every recommendation must be grounded in queried data, not schema inference.** During discovery, run `execute_query` to validate assumptions before proposing anything in later steps.\n\n| What to validate | Query to run |\n|-----------------|-------------|\n| **Denormalized vs joined values** | Compare pre-computed columns (e.g., `customers.order_count`) against the actual joined aggregate (`count()` from `orders`). Report discrepancy rate. If >0%, flag for user decision. |\n| **Candidate date fields** | When multiple date/timestamp columns exist, query both. What % of rows differ? By how much? This informs which is canonical. |\n| **Numeric column distributions** | Query min, max, avg, percentiles (p25, p50, p75, p95). These inform tier boundaries and detect outliers. |\n| **Categorical column cardinality** | Query distinct values. A `status` column with 5 values behaves differently from one with 500. |\n| **Column usefulness** | Query NULL rates. Columns that are >95% NULL are candidates for `internal`. |\n| **Join cardinality** | Query FK uniqueness: `group_by: fk_col, aggregate: row_count is count(), having: row_count > 1`. Determines `join_one` vs `join_many`. |\n| **Revenue/amount columns** | When multiple money columns exist (`total`, `subtotal`, `amount`, `price`), query a sample to understand how they relate (does `total = subtotal + tax`?). |\n| **Join key value compatibility** | For every proposed join, sample 5-10 actual values from each side. Check for format mismatches: abbreviations (\"4th Av\" vs \"4 Avenue\"), ordinals (\"23 St\" vs \"23rd St\"), casing, prefixes. Mismatched values mean the join won't work even if column names match. |\n| **Mixed-grain rows** | For each key table, run top-N and bottom-N by primary metric. Look for summary/aggregate rows mixed with detail data (e.g., \"System Total\" rows in a station-level table). These corrupt measures if not filtered out. |\n\n**Never assume from column names.** Always query the data to confirm. A column named `total` could include or exclude tax. A `status` column could have unexpected values. A FK could have orphaned references.\n\n### Example Queries\n\n**Tier boundaries**: query distribution, propose breaks from percentiles:\n```malloy\nrun: orders -> {\n aggregate:\n min_val is min(sale_price), p25 is sale_price.percentile(25)\n median_val is sale_price.percentile(50), p75 is sale_price.percentile(75)\n p95 is sale_price.percentile(95), max_val is max(sale_price)\n}\n```\n\n**Denormalized vs joined**: compare pre-computed column against real aggregate, report match rate:\n```malloy\nrun: customers -> {\n join_many: orders on customer_id = orders.customer_id\n aggregate:\n total is count()\n match is count() { where: order_count = count(orders.order_id) }\n}\n```\n\n**Canonical date**: when multiple date columns exist, check how often they differ:\n```malloy\nrun: orders -> {\n aggregate:\n total is count()\n same_date is count() { where: created_at::date = submitted_at::date }\n max_gap_days is max(days(submitted_at - created_at))\n}\n```\n\n**Revenue columns**: when multiple money columns exist, verify their relationship:\n```malloy\nrun: orders -> {\n aggregate:\n total_eq_parts is count() { where: abs(sale_price - (subtotal + tax)) < 0.01 }\n total is count()\n}\n```\n\n### Schema Shape\n- Is this a star/snowflake schema (use base + joined source layers) or normalized/ER-style (may need 3-stage pattern)?\n- Combined vs split tables: prefer filtered/split tables over combined when both exist.\n\n## Computed Source Detection\n\nFlag potential computed sources when:\n\n1. **Grain mismatch**: the analytical scope requires a grain that no physical table provides (e.g., customer-level metrics from an order-grain table)\n2. **Repeated aggregation patterns**: the same GROUP BY + aggregate pattern would be needed in multiple analyses\n3. **Cross-entity aggregations**: the model or the data implies cross-entity aggregations that require a pre-aggregated entity\n\n## Prior Art Detection\n\nCheck for prior art signals at the start of discovery. If a signal is found and the user confirms, **you MUST read** the corresponding reference skill and follow its instructions.\n\n| Signal | Source Type | Reference to Read |\n|--------|------------|-------------------|\n| `.lkml` files in project or subdirectories | lookml | `skill:malloy-lookml-review` |\n| `dbt_project.yml` in project or parent dirs | dbt | dbt review (future) |\n\nThe reference handles inventory, classification, and produces prior-art notes. Keep those notes in-conversation, then continue with normal discovery below.\n\n**If DB connection available (LookML + DB mode):**\n- Read the model and run `execute_query` as normal\n- Use prior art as additional context, not a replacement for data validation\n- **The LookML connection name is NOT the Malloy connection name.** Always use the connection name from the model.\n\n**If no DB connection (LookML-only mode):**\n- Skip the model-read and `execute_query` steps\n- Use connection name and table paths extracted from prior art source files\n- Flag all proposals in Steps 2-4 as **unvalidated**\n- Proceed directly to Step 2 (PROPOSE SCOPE)\n\n**Prior art findings enhance discovery, they don't replace it.** When a DB connection is available, always validate assumptions against the actual data.\n\n## After Discovery\n\nDo NOT present findings to the user yet.\n\n## Done\n\nStep complete. Output: discovery findings (internal: tables, columns, relationships, data quality, prior art). Continue to the next modeling step (see your modeling workflow).\n\n## Verify Source Joins\n\nWhen reading joins off the model or the data, watch for `join_many` where the actual relationship is many-to-one. Always verify cardinality. Prefer `join_one` when each row in the primary table matches at most one row in the joined table." }, { name: "malloy-document", description: 'Add documentation with #(doc) tags to Malloy models so fields and sources are described in plain language. Use when user asks to "add documentation", "add doc tags", "document the model", or wants fields and sources described for natural-language search and discovery. For declaring parameterizable filters with #(filter), see the malloy-model skill. Filters are a runtime/modeling construct (governance, latency, correctness), not a documentation tag.', body: "# Documenting a Malloy Model\n\nAdd `#(doc)` tags to describe sources and fields in plain language so they are easy to find and understand:\n\n| Tag | Purpose | Goes on |\n|-----|---------|---------|\n| `#(doc)` | Plain-language description for natural-language search | source, dimension, measure, view, join |\n| `#(filter)` | Declare a parameterizable filter (runtime/modeling concern, see `malloy-model`) | source |\n\n`#(doc)` is a standard Malloy annotation. It documents a field or source with a human-readable description that downstream tools can surface and search against.\n\n## #(doc) Tag\n\nAdd before any source, dimension, measure, view, or join. When multiple fields share a keyword, use it once as a block header. Tags and field names are indented under the keyword; tags go on the line(s) directly above the field they annotate.\n\n**Tag ordering** (when a field has multiple tags): `#(doc)` → render tags (`# currency`, `# label`, etc.) → field name. Separate each field group with a blank line:\n\n```malloy\n#(doc) Customer who placed the order\njoin_one: users with user_id\n\ndimension:\n #(doc) Date the order was placed (UTC)\n order_date is created_at::date\n\nmeasure:\n #(doc) Total revenue from all orders in USD\n # currency\n revenue is sum(total)\n```\n\n### Writing Doc Strings for Retrieval\n\nDoc strings power natural-language search: users type plain-English questions and the system matches against your `#(doc)` strings. Write descriptions that match how analysts would search:\n\n- **Include business meaning**, not code mechanics: what it represents, not how it's implemented\n- **Include units** (USD, count, percentage) and valid values for categorical fields\n- **Avoid Malloy jargon**: never use \"filterable\", \"groupable\", \"dimension\", \"measure\", \"aggregation\"\n\n**Good examples:**\n- `#(doc) Total revenue from completed orders in USD` matches \"what was our revenue?\"\n- `#(doc) Customer signup date (UTC)` matches \"when did the customer join?\"\n- `#(doc) Order status: pending, processing, shipped, delivered, cancelled` matches \"what are the order statuses?\"\n\n**Bad examples:**\n- `#(doc) Filterable dimension for order status`: no analyst searches for \"filterable\"\n- `#(doc) Groupable by region`: \"groupable\" is a system concept\n- `#(doc) Aggregation of total sales`: \"aggregation\" doesn't match natural queries\n\n## #(filter): see `malloy-model`\n\n`#(filter)` is also a `#(...)`-shaped annotation, but unlike `#(doc)` it's a **runtime/modeling construct**: it shapes governance, query latency, and correctness, not discoverability. The full reference (syntax, filter types, `required` / `implicit` flags, and when each applies) lives in `malloy-model` § Parameterizable Filters with `#(filter)` alongside the other source-authoring constructs.\n\nOne rule worth knowing here: filters live on the source, never on the consumer. Ad-hoc reports and notebooks that import a source inherit its filters automatically; they do not (and cannot) declare new ones.\n\n## `internal:` and `private:`: column-level access in a source\n\n`#(doc)` describes what's exposed. Two access modifiers control what's exposed in the first place, and both live **inside** a source's `include {}` block. They are about the source's public API and data sensitivity, not about documentation, so reach for them when curating which columns callers can pick.\n\n| Mechanism | Layer | Why you reach for it |\n|---|---|---|\n| `internal:` | Inside a source (one column in `include {}`) | The column **isn't part of your model's public API**. Common reasons: data is messy (empty/garbage, raw JSON, duplicates), or a documented derived dimension already supersedes it, or the raw column exists only to be joined on / referenced internally and shouldn't appear as a dimension callers can pick. The data may be perfectly fine, it's just not what you want exposed. |\n| `private:` | Inside a source (one column in `include {}`) | The **data is sensitive**: SSN, raw credit card, password. Governance / security concern; a harder block than `internal:`. |\n\nIn one sentence: **`internal:` and `private:` shape what's inside a source's public API; `#(doc)` describes the fields you do expose.**\n\n### Example\n\nA base source pulled from a messy raw table often uses `internal:` to drop raw fields from the public API, while documenting the curated columns with `#(doc)`.\n\n```malloy\n// orders_base.malloy\n#(doc) Raw orders. Use orders.malloy as the entry point for analysis.\nsource: orders_base is conn.table('orders_raw')\n include {\n public: id, customer_id, order_date, total\n internal: raw_json_payload, deprecated_status_code, _temp_dedup_marker\n }\n extend {\n primary_key: id\n }\n```\n\n```malloy\n// orders.malloy\nimport \"orders_base.malloy\"\n\n#(doc) Order analysis. Use for revenue, fulfillment, and customer-order joins.\nsource: orders is orders_base extend {\n // joins, measures, curated dimensions\n}\n```\n\nThe base source stays fully queryable (`run: orders_base -> { ... }` still works); `internal:` only governs which columns appear as public dimensions callers can pick.\n\n## Annotating Columns in Include (Experimental)\n\nWith `##! experimental.access_modifiers`, you can add `#(doc)` tags to raw table columns inside `include` blocks. This documents columns without redefining them as dimensions.\n\n```malloy\n##! experimental.access_modifiers\n\nsource: orders is conn.table('orders') include {\n public:\n #(doc) Order line item identifier\n id\n\n #(doc) Customer email address\n email\n\n #(doc) Order status: pending, shipped, delivered\n status\n\n // internal: only for verified noise (empty cols, raw JSON blobs, duplicates)\n}\nextend {\n // ... dimensions and measures\n}\n```\n\n**When to use:**\n- Documenting raw columns without creating explicit dimensions\n- Curating which columns are public vs internal\n\n## Source-Level Documentation\n\nDocument **when to use** a source, not what it contains. Dimensions and measures can already be searched directly, so the source-level `#(doc)` should describe what questions/analyses this source answers.\n\n**Base source files:** Document what the table represents.\n```malloy\n#(doc) Customer records with demographics and segmentation. One row per customer.\nsource: customers is conn.table('sales.customers') extend { ... }\n```\n\n**Source files:** Document what analytical questions the source answers.\n```malloy\n#(doc) Customer health analysis. Use for retention, segmentation, churn risk, and lifetime value. For order-level analysis, use order_analysis instead.\nsource: customer_health is customers extend { ... }\n```\n\n**Best practices:**\n- Add `#(doc)` to all base source and joined source definitions\n- Base source docs: describe what the table is (one row per what)\n- Source docs: describe what questions/analyses the source answers\n- Documentation happens per-source-file, not in one monolithic file\n\n## Flag Ambiguous Descriptions\n\nAfter writing `#(doc)` tags, present any that required judgment to the user for confirmation:\n\n| Field | Proposed doc | Confidence | Uncertainty |\n|-------|-------------|------------|-------------|\n| `total` | \"Total order amount in USD\" | Medium | Could be gross or net, verified with sample query |\n| `status` | \"Order status: pending, shipped, delivered\" | High | Values confirmed via a query of distinct values |\n\nOnly flag fields where the description required assumptions about business meaning, units, or valid values. When in doubt about valid values, run a quick query against the data to confirm them before writing the description. Use `malloy_getContext` to ground yourself in the package's sources and fields and `malloy_executeQuery` to check distinct values, for example `run: source -> { group_by: status }`.\n\n## Done\n\nStep complete. Output: `#(doc)` tags added to all public fields and sources." }, { name: "malloy-getting-started", description: "First steps for using a Malloy Publisher deployment through its MCP tools. Use when connecting to Publisher for the first time, when you do not yet know the available environments, packages, or models, or when a user asks what data they can explore. Covers verifying the server, discovering data with malloy_getContext, and running a first grounded query.", body: '# Getting started with Malloy Publisher\n\nGoal: go from "connected" to a correct, grounded answer without guessing any names.\n\n## 0. Confirm the tools are reachable\n\nAt minimum you need `malloy_getContext`, `malloy_executeQuery`, and `malloy_searchDocs`. Authoring a model also needs `malloy_compile` and `malloy_reloadPackage` (see section 4); an older Publisher may not serve those two.\n\nIf none of the tools are there, either the server is not running or your client connected before it was. Start the server (`npx @malloy-publisher/server --port 4000`, or `bun run build && bun run start` from a clone) and wait until `curl -s http://localhost:4000/api/v0/status` reports `operationalState: serving`.\n\nIf there is no Publisher workspace here at all, and the user wants to work with data of their own rather than the bundled examples, `npm create @malloy-publisher/malloy-package@latest <name>` scaffolds one: the package and a starter model, registered so the server actually serves it, plus the start script, the MCP config and these skills. Keep the `@latest` when you type it: `npm create` resolves through npm\'s npx cache and an unversioned name is satisfied by any copy already there, so on a machine that has scaffolded before npm never asks the registry and you get an old scaffolder pinning an old server, with nothing to say so. Run bare, it comes with a small sample dataset, so there is something to query straight away. In a fresh directory `npm start` then runs the pinned server against the package in watch mode; if the directory already had a `package.json` the scaffolder leaves it alone and adds no script, printing the equivalent `npx` command to use instead. Where you run it matters: only the package lands in `<name>/`, and the workspace files, the agent instructions and the MCP config among them, are written to the current directory. Run it here if this directory is empty or is meant to become the workspace. If it already holds other work, scaffold into a new directory instead (`mkdir my-data && cd my-data`), because agent config is discovered by walking up, so writing those files here changes what every session beneath this directory inherits. Seed the starter model from a local file with `npm create @malloy-publisher/malloy-package@latest <name> -- --data <path/to/their-file.csv>` (CSV, Parquet, or Excel `.xlsx`), keeping the `--`, which is how `npm create` passes options through. That path is relative to wherever you run the command, so if you scaffolded into a new directory it has to reach back out to their file; the scaffolder copies it into the package and leaves the original alone. A seeded package starts smaller than the sample one, since the scaffolder does not read their columns: expect a row count and an overview, and build the model from there. A package is just Malloy, so it can instead query a database connection the config defines. Because it writes a `.mcp.json` that did not exist when the client connected, the user has to restart or reconnect once before these tools appear, and their client will ask them to approve the new project-scoped server the first time. That only works when the workspace is at the session\'s own root, so if you scaffolded into a new directory below that root, the user has to open a session there instead: a `.mcp.json` further down is never discovered.\n\nIf you started the server yourself in this session, the tools still will not appear: your tool list was fixed when you connected, and you cannot reconnect yourself. Tell the user the tools are missing for that reason and ask them to run `/mcp`, select `malloy`, and choose Reconnect. The panel offers `Authenticate` first and reports `Auth: not authenticated`; that is a red herring, the endpoint has no auth. Restarting Claude Code also works. Continue once the tools are there.\n\nWhen a user is present, do not route around it by calling the REST API with curl. It appears to work, so the user never learns their session is missing the tools, and you lose what they are for: grounded discovery instead of guessed names, `malloy_compile` instead of throwaway queries, and `malloy_reloadPackage` instead of a restart. Say the tools are missing and let the user fix it in five seconds. Running unattended, with nobody who can reconnect you, is different: there the REST API is the supported interface, not a workaround. Discovery, query, compile, and reload all have REST equivalents (`malloy_searchDocs` and `malloy_getContext`\'s plain-English ranking do not; read the bundled skills for syntax and ground from model metadata instead); the running server serves the full spec at `http://localhost:4000/api-doc.yaml`, and AGENTS.md carries the endpoint map.\n\n## 1. Discover what exists (never guess names)\n\n`malloy_getContext` is progressive. Call it with as much as you know:\n\n- No arguments: the available environments, each with its package names.\n- `environmentName` only: the packages in that environment.\n- `environmentName` + `packageName`: that package\'s sources.\n- `environmentName` + `packageName` + `query` (plain English): the sources, views, named queries, and dimension/measure fields most relevant to the question.\n\nUse the names it returns exactly. Do not invent environments, packages, sources, or fields.\n\n## 2. Run the query\n\nCall `malloy_executeQuery` with the `environmentName`, `packageName`, and `modelPath` from the context results, plus either:\n\n- a named view or query: pass its `name` as `queryName` (with `sourceName` for a view), or\n- an ad-hoc query: pass Malloy code as `query`.\n\nThe result is JSON. Charts and dashboards defined in the model render in the Publisher UI at http://localhost:4000.\n\n## 3. When you need Malloy syntax\n\nUse `malloy_searchDocs` for language questions (filters, aggregates, joins, nesting, renderers).\n\nIf the data you want is in a connected database but not yet in any package, use `malloy_searchDatabaseSchema` instead of `malloy_getContext`: it walks a connection\'s schemas and tables and ranks them against a plain-English description, and hands back the `source:` line to start a model from. It returns names and types only, so to see what a column actually contains run `malloy_executeQuery` against a model in a package that uses the same connection, with an ad-hoc query like `run: my_conn.table(\'sales.orders\') -> { group_by: order_status }`. That tool needs an existing model to run against, so a table you have not modelled yet has none of its own.\n\n## 4. What else you can do here\n\nAnswering questions is the start, not the whole surface. When the user asks what is possible, say so rather than offering queries alone. Switch skills for the deeper work:\n\n- `malloy-modeling`: build or change a model. Validate the edit with `malloy_compile`, save it, then `malloy_reloadPackage` so the new sources and views run by name without restarting the server.\n- `malloy-analysis`: explore a package and answer data questions.\n- `malloy-html-data-apps`: build a data app, a hand-authored HTML page in the package\'s `public/` directory that Publisher serves, backed by the package\'s models and needing no build step.\n- `malloy-review`: check Malloy for correctness.\n\n## Contract\n\n- Ground every query in `malloy_getContext` results. If a name is not in the results, do not use it.\n- Start broad and narrow down: environments, then packages, then sources, then query.\n- Confirm the environment and package before running a query.' }, { name: "malloy-gotchas-modeling", description: "Common Malloy modeling mistakes and how to avoid them. Read BEFORE writing source definitions, dimensions, measures, or joins. Covers reserved words, NULL checks, date functions, type casts, field management (extend except/accept/rename vs include public/internal/private), and query-based source gotchas.", body: "# Modeling Gotchas\n\n> **Read this before writing Malloy code.** These patterns cause most modeling errors.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n## Reserved Words: Backtick Them\n\n**When in doubt, backtick it.** Unquoted reserved words cause cascading errors on unrelated lines.\n\n```malloy\n// WRONG // RIGHT\ndimension: d is Date::date dimension: d is `Date`::date\n```\n\nWords most likely to appear as column names:\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n\n## NULL Checks: `is not null`, NOT `!= null`\n\n```malloy\n// WRONG // RIGHT\ndimension: is_sold is sold_at != null dimension: is_sold is sold_at is not null\n```\n\n## Date Functions vs Properties\n\n```malloy\n// WRONG: day_of_week is a function // RIGHT\ndimension: dow is created_at.day_of_week dimension: dow is day_of_week(created_at)\n```\n\n**Property access:** `.month`, `.year`, `.quarter`, `.day`, `::date`\n**Function call required:** `day_of_week()`, `week()`, `hour()`, `minute()`, `second()`\n\n## `.date` Is a Cast, Not a Truncation\n\nCalendar truncations are `.day`, `.week`, `.month`, `.quarter`, `.year` (plus `.hour`, `.minute`, `.second` for timestamps). `.date` is **not** among them: it's a **cast** (`::date`), not a truncation, so `created_at.date` does not compile. This bites twice: once at compile time, and again as a latent bad `#(doc)` comment that only a review pass catches (\"truncated to date\" is a doc smell; it should say \"to day\").\n\n```malloy\n// WRONG // RIGHT\ncreated_at.date created_at.day // truncate to day\n created_at::date // cast to a date\n```\n\n## Interval Functions: Only `seconds` / `minutes` / `hours` / `days`\n\n`weeks()`, `months()`, `quarters()`, `years()` are **documented but don't work** in this build; only `seconds`, `minutes`, `hours`, `days` actually function. Compute in days and derive the larger unit: a *units conversion*, not a calendar-floored duration:\n\n```malloy\n// WRONG: weeks()/months() don't compile\ndimension: weeks_open is weeks(opened_at to closed_at)\n\n// RIGHT: measure in days, convert (documents that it's approximate)\ndimension: days_open is days(opened_at to closed_at)\ndimension: weeks_open is days(opened_at to closed_at) / 7 // ≈ weeks\ndimension: months_open is days(opened_at to closed_at) / 30.44 // ≈ months\n```\n\n(Contrast: `search_malloy_docs` gets this right when asked narrowly; trust the docs on the supported units, not on the missing ones.)\n\n## Safe Division: Always `nullif`\n\n```malloy\n// WRONG // RIGHT\na / b a / nullif(b, 0)\n```\n\n## String Columns Need Casts for Aggregates\n\n```malloy\n// WRONG: \"Can't use type string\" // RIGHT\nmeasure: avg_score is avg(score) measure: avg_score is avg(score::number)\n```\n\n**Dirty columns: null the sentinel before casting.** `::number` is a strict cast, so a column that carries non-numeric sentinels (`'NA'`, `'N/A'`, `''`, `'-'`, `'null'`) compiles fine but fails at query time with `Could not convert string 'NA' to DOUBLE`. Strip the sentinel with `nullif` first, then cast (aggregates skip nulls):\n\n```malloy\n// WRONG: throws on 'NA' at query time // RIGHT: nulls 'NA', then casts\nmeasure: s is avg(score::number) measure: s is avg(nullif(score, 'NA')::number)\n```\n\nChain `nullif` for multiple sentinels: `nullif(nullif(score, 'NA'), '')::number`. Sample the column's values first (`run: source -> { group_by: score; limit: 20 }`) to see which sentinels it uses.\n\n## Boolean Columns: No Quotes\n\n```malloy\n// WRONG // RIGHT\ncount() { where: complaint = 'true' } count() { where: complaint = true }\n```\n\nCheck schema: if `BOOL`, use `true`/`false`. If `STRING`, use `'true'`/`'false'`.\n\n## `greatest()` / `least()` Are Null-Poisoning\n\nMalloy's `greatest()` / `least()` return **NULL if *any* argument is null**, unlike Postgres `GREATEST`/`LEAST`, which ignore nulls. Porting a LookML/SQL expression verbatim is a silent parity bug: the number just goes null for any row with a missing input. Coalesce the result back to a non-null argument:\n\n```malloy\n// WRONG: one null input nulls the whole thing\ndimension: last_touch is greatest(email_at, call_at)\n\n// RIGHT: fall back so a null arg can't poison the result\ndimension: last_touch is greatest(email_at, call_at) ?? email_at ?? call_at\n```\n\n## No Scalar Median; Raw-SQL Aggregates Don't Compile\n\n**There is no scalar `median`, and `PERCENTILE_CONT` cannot be expressed as a measure in this build.** Every documented form for a custom SQL aggregate - `percentile_cont!(x, 0.5)`, `sql_number(...)`, `sql_number(...) { is_aggregate: true }`, and the `# is_aggregate` annotation - resolves as a **scalar** and fails with *\"Cannot use a scalar field in a measure declaration.\"* The docs' own `avg_dist` example fails the same way. This is a deployed-runtime limitation, not a syntax error you can fix: **do not** burn cycles trying `!`, `sql_number`, or `is_aggregate` variations to get a median.\n\n```malloy\n// DOES NOT COMPILE in this build (all forms resolve as scalar):\nmeasure: median_x is percentile_cont!(x, 0.5)\nmeasure: median_x is sql_number(\"PERCENTILE_CONT(...) ...\") { is_aggregate: true }\n```\n\n**Ship `avg` instead, or defer median with a documented gap** (\"median deferred: no scalar median / runtime rejects raw-SQL aggregates\"). Tell the user; don't silently substitute `avg` for a metric that was specified as median.\n\n**`stddev` does work**, so reach for it when the question is about spread. It is a native Malloy aggregate rather than a raw-SQL escape, so unlike everything above it compiles both inline and as a `measure:`, and it is the sample standard deviation. `variance`, `stddev_samp`, and `stddev_pop` are not Malloy functions, and pushing them through `!` fails as a scalar exactly like `percentile_cont!`.\n\n```malloy\n// WORKS: inline, or as a measure on a source\nrun: order_items -> { aggregate: sd is stddev(sale_price) }\nsource: items is order_items extend { measure: price_stddev is stddev(sale_price) }\n```\n\n## Field Management: `extend {}` vs `include {}` Don't Compose\n\nMalloy has two field-management mechanisms for base sources. **`include {}` is the curated default; `extend { except / accept / rename }` is the fallback when a `rename:` is unavoidable.** They have different capabilities and **do not combine**.\n\n| Mechanism | Where it lives | Keywords | Compatible with `rename:`? | Experimental flag? |\n|---|---|---|---|---|\n| Access modifiers (default) | `include {}` | `public:` / `internal:` / `private:` | **No** | Yes (`##! experimental.access_modifiers`) |\n| Field management (fallback) | `extend {}` | `accept:` / `except:` / `rename:` | Yes (same block) | No |\n\n### Default: `include {}` for documented, curated base sources\n\nUse `include {}` whenever the source doesn't need a `rename:`. It's the only way to attach `#(doc)` tags to raw columns, and it's the canonical way to hide empty/garbage/duplicate columns (`internal:`) and sensitive ones (`private:`). See `skill:malloy-model` § Access Modifiers.\n\n```malloy\n##! experimental.access_modifiers\nsource: orders is conn.table('orders') include {\n public:\n #(doc) Order identifier\n order_id\n\n #(doc) Customer who placed the order\n user_id\n\n internal:\n raw_payload_json // empty after JSON extraction\n legacy_status_code // superseded by status_code\n}\n```\n\n### When `rename:` is unavoidable: fall back to `extend {}`\n\n`include {}` does not compose with `rename:`. The combination errors with `Can't find field 'X' to set access modifier` because `rename:` runs first and leaves no `X` for `include` to attach a modifier to. There's also a collision inside `include {}` itself: a measure cannot share a name with a raw column, even one tagged `internal:` (`Cannot redefine 'X'`), and the natural fix for that is `rename:`, which then triggers the first error.\n\nWhen a rename is genuinely required (most often during `conn.sql()` to `conn.table()` migration where a SQL alias matches a measure name that's already in heavy use downstream), drop `include {}` and curate the source with `extend { except: ... }` + `rename:` instead. You forfeit `#(doc)` on raw columns and the `public/internal/private` tiers, but keep column gating and the rename.\n\n```malloy\n// RIGHT: rename is required to free `revenue` for the measure\nextend {\n except: legacy_status_code // hide garbage column without include {}\n rename: raw_revenue is revenue\n measure: revenue is raw_revenue.sum()\n}\n```\n\nIf you can rename the measure or split the source instead, prefer that: it preserves `include {}` and the curated surface.\n\n### `extend {}` clauses (reference)\n\n- **`accept:`**: allow-list, keep only the named columns\n- **`except:`**: deny-list, drop the named columns; keep everything else (mutually exclusive with `accept:`)\n- **`rename:`**: alias a raw column to free up its original name for a measure or dimension\n\n### Migrating `conn.sql()` to `conn.table()` + Malloy clauses\n\nThe biggest reason teams reach for `conn.sql()` is column gating, aliasing, and per-row derivation in one place. All three have native equivalents:\n\n1. **Verify the schema**: `run: <source> -> { select: *; limit: 1 }` to discover all columns. Anything in the table but not in the SQL's `SELECT` was being intentionally hidden, so preserve that gating.\n2. Switch to `conn.table('…')`.\n3. Hidden columns: preferably `include { internal: ... }` (lets you also `#(doc)` the public columns). If a `rename:` is also needed in the same source, fall back to `extend { except: ... }`.\n4. SQL aliases: `extend { rename: ... }` (forces the fallback path, since `rename:` and `include {}` don't compose). If the alias was to free up a name for a measure, use `rename: raw_X is X`, then `measure: X is raw_X.sum()`.\n5. SQL derivations: `dimension:` definitions in `extend {}`.\n6. SQL `WHERE`: source-level `where:`.\n\n## Cannot Redefine Query-Based Source Columns\n\nColumns from `table -> { group_by, aggregate }` or `conn.sql()` already exist. You cannot re-declare them.\n\n```malloy\n// WRONG: \"Cannot redefine 'user_id'\"\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: user_id is user_id }\n// RIGHT: add only NEW derived dimensions\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: is_high_value is total > 1000 }\n```\n\nTo add `#(doc)` tags to existing query columns, use `include {}` between the query and extend.\n\n## Extending a Source Cannot Reuse a Name It Already Defines\n\n```malloy\n// WRONG: \"Cannot redefine 'overview'\" when sales already declares view: overview\nsource: wines is sales extend { view: overview is { aggregate: record_count } }\n// RIGHT: give the extension its own name\nsource: wines is sales extend { view: summary is { aggregate: record_count } }\n```\n\nAn extension adds to the parent's namespace, it does not override it. This bites when you extend a source to \"replace\" one of its views: rename the new definition, or edit the view on the parent source instead of extending it. Malloy reports the same `Cannot redefine 'X'` for dimensions and measures that collide with an inherited name, per the sections above and below.\n\n## Never Use `conn.sql()` When Malloy Has a Native Pattern\n\n```malloy\n// WRONG: raw SQL for pre-aggregation\nsource: facts is conn.sql(\"\"\"SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id\"\"\")\n// RIGHT: Malloy query-based source\nsource: facts is conn.table('orders') -> { group_by: user_id, aggregate: total is sum(amount) }\n```\n\n**Mandatory: call `search_malloy_docs` before reaching for `conn.sql()`.** Don't argue from intuition. Most patterns that look SQL-only have a Malloy equivalent, including the ones reviewers historically said couldn't be expressed.\n\n| Looks like it needs SQL | Malloy equivalent |\n|---|---|\n| Multi-CTE pipeline | Stacked query-based sources: `source: a is t -> {...}`; `source: b is a -> {...}`; `source: c is b -> {...}` |\n| UNNEST / array column access | `array_column.each.field`: arrays auto-join as nested tables ([data types docs](https://docs.malloydata.dev/documentation/language/datatypes#array-access)) |\n| PIVOT (conditional aggregation) | Filtered aggregates: `aggregate: a is x.sum() { where: cat = 'a' }, b is x.sum() { where: cat = 'b' }` |\n| Window functions (any frame, including custom) | `calculate:` with `sum_cumulative`, `lag`, `lead`, `rank`, `row_number`, `avg_moving`, `first_value`, `last_value`: supports `partition_by:` and `order_by:` ([window functions docs](https://docs.malloydata.dev/documentation/language/functions#window-functions)) |\n| `ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING` | `sum_cumulative(x) - x` (cumulative-including-current minus current = cumulative-excluding-current) |\n| `WHERE date = (SELECT max(date) FROM …)` (latest snapshot) | `join_cross` to a one-row aggregate source, then filter on the joined `max_date` field |\n| Multi-key joins | `join_one: x is target on a = x.a and b = x.b and c = x.c` |\n| `greatest()` / `least()` / `CASE` chains | All native: `greatest(a, b, c)`, `least(a, b)`, `pick 'x' when cond else 'y'` |\n| Dialect-specific scalar functions | `function_name!return_type(args)`: Malloy's raw-SQL function escape (no `conn.sql()` block needed) |\n\n**Genuinely valid `conn.sql()` candidates (rare):**\n\n- SQL features Malloy explicitly doesn't model (e.g., DML/DDL, specific `MERGE` patterns)\n- Multi-stage transformations where every CTE has 3+ joins to different tables AND the result is consumed by multiple downstream sources, but in this case an intermediate table in the data warehouse is usually still better than `conn.sql()`\n\n**Never use `conn.sql()` for:** simple column selection or renaming, `WHERE` filters, two-table joins, column type casts, latest-snapshot patterns, conditional aggregation, or window functions of any kind.\n\nIf a project's standards file specifies a stricter policy (e.g., a `search_malloy_docs` rationale comment requirement above every `conn.sql()` block), defer to that.\n\n## JSON Files: Read Them In Place Like CSV\n\n```malloy\n// RIGHT: .json works like .csv/.parquet\nsource: reviews is duckdb.table('data/reviews.json')\n// RIGHT: newline-delimited JSON is read the same way\nsource: events is duckdb.table('data/events.ndjson')\n// RIGHT: read options need read_json_auto in a SQL source\nsource: nested is duckdb.sql(\"\"\"SELECT * FROM read_json_auto('data/reviews.json')\"\"\")\n// WRONG: shelling out to python, or converting to CSV first\n```\n\nDuckDB reads JSON directly, so never preprocess a `.json` file before modeling it and never reach for a scripting language to inspect one. Both a top-level array of objects and newline-delimited JSON work through `duckdb.table()`.\n\nQuirk: JSON carries no schema, so a value written as `\"90\"` arrives as a string where the same data in CSV would be inferred as a number. Cast it in the source, under a new name (reusing the column's own name is a redefinition error):\n\n```malloy\nsource: reviews is duckdb.table('data/reviews.json') extend {\n dimension: points_num is points::number\n}\n```\n\n## Excel Files: Read `.xlsx` In Place, Never Convert\n\n```malloy\n// RIGHT when the sheet is a plain table (header in row 1, data under it, no blank row inside\n// it): read it where it sits, like .csv/.parquet (in a Publisher package the sandbox\n// connection is `duckdb`)\nsource: budget is duckdb.table('data/budget.xlsx')\n// RIGHT for anything messier. Profile the top rows first to find the real header row and the\n// last real column, because nothing else will tell you where they are. Put the probe in the\n// model file as its own source: Publisher refuses raw SQL in an ad-hoc query.\n// SELECT * FROM read_xlsx('data/sales.xlsx', sheet = 'Sales Data',\n// range = 'A1:Z15', header = false, all_varchar = true)\nsource: sales is duckdb.sql(\"\"\"\n SELECT * FROM read_xlsx('data/sales.xlsx',\n sheet = 'Sales Data', -- EDIT: only the first sheet is read by default\n header = true,\n range = 'A5:J100000' -- EDIT: A5 is the real header row. Keep the column bound at the\n ) -- last real column; the row bound just has to clear the end.\n WHERE \"Order ID\" LIKE 'SO-%' -- EDIT, REQUIRED: a data-row predicate. This is what ends the\n\"\"\") -- read; drop it and every empty row in the range comes back.\n// WRONG: converting the spreadsheet to Parquet or CSV first (an unnecessary extra step)\n```\n\nDo not convert spreadsheets before modeling. DuckDB's excel extension reads `.xlsx` directly and loads automatically on first use, so a sheet that is a plain table needs nothing more than `duckdb.table()`. Converting does not avoid any of the problems below, it just moves them into a copy that goes stale the next time someone updates the workbook.\n\n**Plenty of real exports are not plain tables, and nothing tells you.** A report title, a \"generated on\" banner, a merged group header, a blank line above the header, or a blank spacer row inside the data are all ordinary, and none of them is visible from Malloy. There is no error either: the package loads, the server reports serving, the query returns 200, and the number is just wrong. So make two checks before building on the read: compare `aggregate: record_count is count()` against what you know is in the file, and `select: *; limit: 1` to see what the columns really are. If either disagrees with the file, the read is wrong and so is every measure over it.\n\n`table()` takes a plain file path only, so anything needing `read_xlsx` options (`sheet`, `range`, `header`, `ignore_errors`, `normalize_names`, `all_varchar`, `empty_as_varchar`, `stop_at_empty`) goes through the SQL-source form.\n\nQuirks:\n\n- Only the FIRST sheet is read by default. Select another with `sheet = 'Name'`. There is no function that lists a workbook's sheet names, but passing one that does not exist reports a suggestion (`Sheet \"x\" not found ... Did you mean: \"Notes\"`), which is one way to find a name you were not given.\n- A title or banner row above the header collapses the read. DuckDB takes the first row it finds as the column names, so a lone title cell in A1 becomes the only column. How many rows you then get is the next quirk's business: whatever sits between the title and the first blank row, often none or one, otherwise a plausible-looking partial count. Pass a `range` that starts at the real header row.\n- With no `range`, `stop_at_empty` defaults to true and the read stops at the first blank row, which on a real sheet is usually a spacer between blocks rather than the end of the data: a 30-row sheet with one spacer after row 10 reads as 10 rows. `stop_at_empty = false` lifts that, but it only helps when the header really is in row 1; with a title above the header you need the `range` anyway, and a `range` flips the default for you. It also hands the blank rows back as all-null rows, so the count comes out one high per spacer until you filter them.\n- A `range` reads every cell inside it, so an overshot bound manufactures padding: past the last real column you get all-null fields (`A5:Z100000` on a ten-column sheet yields 26, the extras named `C10` and `_1` through `_15`), and past the last real row all-null rows (`A5:J100000` on a 1,500-row sheet reads 99,995). Spacers, subtotals, and footnotes come through as rows too. So the row filter is not tidying-up, it is the thing that ends the read: filter to what a data row looks like (`WHERE \"Order ID\" LIKE 'SO-%'`) rather than to `IS NOT NULL`, which keeps any footnote carrying text in the first column. A bound that falls SHORT of the data is the dangerous direction: the rows and columns past it are dropped with no error at all, so overshoot the row bound and let the filter end the read.\n- Every number in an xlsx is stored as a double, so there are no integer columns. Typing is per column and decided by the FIRST data row, and `$1,234`, `12%` and `N/A` are all text: a text cell in that first row makes the whole column a string (on one real export, all ten of them), while a text cell further down leaves the column numeric and makes the read throw instead (`Could not convert string ... to DOUBLE`). `ignore_errors = true` fixes that second case, nulling the bad cells and keeping the column a number. It does nothing for the first.\n- Sample the column's SHAPES before writing any conversion, not its values: `run: source -> { group_by: shape is replace(raw_col, r'[0-9]', '9'); aggregate: n is count(); order_by: n desc }` collapses every value to its format and counts it, so on one real price column the 16 euro-denominated rows surface beside the 1,484 in dollars. A plain `group_by raw_col; limit: 20` sorts lexicographically, which hides exactly the shapes that matter.\n- Convert in the SQL source, not in Malloy, where `::number` throws on the first bad cell. `try_cast(regexp_replace(\"Total Revenue\", '[^0-9.-]', '', 'g') AS double)` nulls what it cannot read instead of failing and is right for a plain `$1,234.56`, but it is not a general parser. It concatenates every digit in the cell, so `1,234 (see tab 2)` becomes 12342. It understands only a leading ASCII `-`, so an accounting `(1,234)`, a Unicode minus and a `CR` suffix all come back positive, while a trailing `-` (`1,234-`) comes back null and drops the row from the sum. And it assumes `.` is the decimal point, so a European `1.234,56` comes back a thousandfold small. Handle the shapes your sample actually found, and divide a percent by 100. Failure is quiet either way: a cast that fails on every row sums to 0 rather than erroring, and a text date strips to a number rather than a null (`'01/02/2023'` becomes 1022023).\n- Check the answer against the sheet's own total row, read as raw text. Lift the data-row filter and select the footer by its label, which usually sits in a different column from the one your data-row predicate uses: on one export `WHERE \"Customer Name\" = 'TOTAL'` finds it and `WHERE \"Order ID\" = 'TOTAL'` returns nothing, and an empty result reads as a pass. Do not run the total through the same expression, because a wrong sign survives a row count, survives `select: *`, and cancels out when both sides are parsed the same broken way.\n- A sheet with no header row whose first row is all text silently loses that row to header detection. Pass `header = false`.\n- Headers with spaces are kept verbatim: backtick them in Malloy, or pass `normalize_names = true` for snake_case names.\n- `all_varchar = true` hands back each cell's stored value as text, so a date arrives as its raw Excel serial number rather than a date: `'44929'` from a sheet Excel wrote, `'44927.0'` from one DuckDB's own xlsx writer wrote, and `'44929.5'` where the cell carries a time of day. Which form you get depends on the tool that wrote the file, so do not detect serials by matching for an integer; `try_cast(... AS double)` accepts all three and returns null for a cell that was stored as text (`'01/02/2023'`), which is the test you want. Convert with `date '1899-12-30' + floor(try_cast(d AS double))::int`, not from 1900-01-01. Both wrappers earn their place: adding a double to a date does not compile, and a bare `::int` rounds, so an afternoon timestamp would land on the next day.\n- A date column that mixes both, which is what an export edited by hand gives you, needs both branches or you silently lose every row of one kind: `CASE WHEN try_cast(d AS double) IS NOT NULL THEN date '1899-12-30' + floor(try_cast(d AS double))::int ELSE try_strptime(d, '%m/%d/%Y')::date END`. Without `all_varchar`, a uniformly date-formatted column arrives as real `date` and `timestamp` values, and a stray text cell behaves exactly as the typing rule above says. Note what `ignore_errors = true` does here: it nulls that cell rather than parsing it, so the hand-typed date is lost silently.\n\n## Duplicate Rows: Check Before Building Measures\n\n```malloy\nrun: source -> { group_by: pk_field, aggregate: n is count(), having: n > 1, limit: 10 }\n```\n\nSymptoms: `sum()` returns astronomical values. Causes: event tables, batch retries, merged sources.\n\n## `except:` Removes Fields From Namespace Entirely\n\n`except:` in `include {}` completely removes fields: dimensions and measures cannot reference excluded fields. Use `internal:` instead when derived dimensions need the raw column.\n\n```malloy\n// WRONG: dimension references excluded field\nsource: x is conn.table('t')\ninclude { except: raw_date }\nextend { dimension: order_date is raw_date::date } // ERROR! raw_date is gone\n\n// RIGHT: internal fields are still available in extend\nsource: x is conn.table('t')\ninclude { internal: raw_date }\nextend { dimension: order_date is raw_date::date } // Works\n```\n\n## Source Order: Define Joined Tables First\n\nMalloy compiles top-to-bottom. Define lookup/dimension tables before the source that joins them, or use `import` statements in multi-file projects.\n\n## MUST Search Docs Before Using Unfamiliar Patterns\n\nCall `search_malloy_docs` BEFORE first use of any of these. Don't guess the syntax:\n- `pick` expressions\n- Window functions (`calculate`)\n- `percentile` or statistical functions: but see the hard limit above, raw-SQL aggregates (`sql_number` / `is_aggregate` / `percentile_cont!`) do **not** compile as measures in this build; there is no scalar median (`stddev` is the exception and does work as a measure)\n- Time interval functions (`days()`, `seconds()`): only `seconds`/`minutes`/`hours`/`days` exist (see above)\n- Query-based sources (`from()`)\n- `!` operator / `sql_number()`" }, { name: "malloy-gotchas-queries", description: "Common Malloy query and view mistakes. Read BEFORE writing views, queries, or notebooks. Covers chart constraints, aggregate filters, joined field aliasing, method syntax, and time truncation vs extraction.", body: "# Query & View Gotchas\n\n> **Read this before writing views or queries.** These patterns cause most query errors.\n\n## Charts: ONE Aggregate Per View\n\nCharts render only the **first** aggregate. Use exactly one aggregate per `# bar_chart` / `# line_chart` view.\n\n```malloy\n// WRONG: revenue is ignored\n# bar_chart\nview: x is { group_by: status, aggregate: order_count, revenue }\n// RIGHT: single aggregate\n# bar_chart\nview: x is { group_by: status, aggregate: revenue }\n```\n\nFor multiple metrics: nest separate chart views in a `# dashboard`, or use `y=['revenue','cost']` for multi-measure series.\n\n## Joined Fields in `order_by`: Must Alias First\n\n```malloy\n// WRONG: compile error\nview: x is { group_by: races.season_year, aggregate: pts, order_by: races.season_year }\n// RIGHT: alias then reference\nview: x is { group_by: yr is races.season_year, aggregate: pts, order_by: yr }\n```\n\nAny time you `group_by` a joined field, create an alias and use it in `order_by`.\n\n## `having:` vs `where:`: Aggregate Filters\n\n```malloy\n// WRONG: \"Aggregate expressions not allowed in where\"\nview: x is { group_by: cat, aggregate: n is count(), where: n > 10 }\n// RIGHT\nview: x is { group_by: cat, aggregate: n is count(), having: n > 10 }\n```\n\n- `where:` filters rows BEFORE aggregation (dimensions/raw columns)\n- `having:` filters AFTER aggregation (measures)\n\n## Aggregating Joined Fields: Method Syntax\n\n```malloy\n// WRONG: compile error: \"Join path is required for this calculation; use 'inventory_items.item_cost.sum()'\"\nmeasure: cogs is sum(inventory_items.item_cost)\n// RIGHT: method syntax\nmeasure: cogs is inventory_items.item_cost.sum()\n```\n\n`sum`, `avg`, `min`, and `max` over a dotted joined path all produce that compile error; the diagnostic message even tells you the exact fix. Don't worry about catching this in code review; the compiler does it for you.\n\n**Method syntax is for aggregates over a path. Scalar functions never take it.**\n\n```malloy\n// WRONG: \"something is missing before 'round'\"\naggregate: avg_price_r is avg(price).round(2)\naggregate: avg_price_r is price.avg().round(2)\n// WRONG: \"Cannot call function round(number, number) with source\"\naggregate: avg_price_r is avg_price.round(2)\ndimension: rounded is price.round(2)\n// RIGHT: scalar functions are always call form\naggregate: avg_price_r is round(avg(price), 2)\ndimension: rounded is round(price, 2)\n```\n\nTwo separate rules produce those errors:\n\n- **No method call chains onto the result of a function call.** `avg(price).round(2)` and `price.avg().round(2)` are both parse errors. The message names `round` without saying it is unsupported in that position, so it reads like a typo somewhere else. `.floor()` and `.ceil()` fail identically.\n- **Scalar functions have no method form.** `round`, `floor`, and `ceil` are always `round(x, 2)`, never `x.round(2)`, whether `x` is a named measure or a plain column.\n\n`price.avg()` and `inventory_items.item_cost.sum()` are correct because `avg` and `sum` are aggregate functions over a field path, which is exactly what method syntax is for.\n\n**Exception: `count(joined.field)` is correct, not a bug.** `count(joined.field)` is the **canonical Malloy idiom** for distinct-count through a join. Keep it as-is even when nearby `sum`/`avg`/`min`/`max` calls have to use method syntax. The closest method-syntax form `joined.count()` counts *rows* in the joined source (different semantics, differs from the distinct count when the joined field has duplicates within the joined table). The Malloy docs example `joined.count(field)` does NOT compile against current Malloy (error: `Expression illegal inside path.count()`); it only works for double-nested paths like `aircraft.count(aircraft_models.code)`.\n\n## `sum`/`avg` Need a Numeric Field\n\n```malloy\n// WRONG: \"Can't use type string\" - status is a string column\naggregate: avg_status is avg(status)\n// RIGHT: aggregate a numeric field; count a string one\naggregate: avg_price is avg(price), statuses is count(status)\n```\n\nCheck the field's type in the `get_context` result before aggregating it. A name that reads numeric (`order_number`, `zip`, `account_id`) is very often typed string.\n\n## Dotted Paths Must Name a Declared Join\n\n```malloy\n// WRONG: the source declares the join as `carrier`, so this fails with\n// \"'carriers.name' is not a source or join\"\nrun: flights -> { group_by: carriers.name }\n// RIGHT: use the join name the source actually declares\nrun: flights -> { group_by: carrier.nickname }\n```\n\nA dotted path resolves only against a join declared on the source you are running. Confirm both the join name and the field under it in a `get_context` result; do not infer either from a table name or a plural/singular guess.\n\n## `order_by:` Can Only Name an Output Column\n\n```malloy\n// WRONG: \"Unknown field total in output space\" - total is never emitted\nrun: orders -> { group_by: state, aggregate: revenue is sum(total), order_by: total }\n// RIGHT: order by a column the query actually outputs\nrun: orders -> { group_by: state, aggregate: revenue is sum(total), order_by: revenue }\n```\n\n`order_by:` resolves against the query's *output* columns, not the source's fields. To order by something, `group_by` or `aggregate` it first - and if it comes through a join, alias it (see above).\n\n## Chart Annotation Placement\n\nPlace `# bar_chart` / `# line_chart` on the **nested view definition**, not on `nest:` itself. Putting it on `nest:` causes \"not a repeated record\" errors.\n\n## DRY: Define in Source, Reference in View\n\n```malloy\n// WRONG: inline in view\nview: summary is { aggregate: revenue is sum(total) }\n// RIGHT: reference existing measure\nview: summary is { aggregate: revenue }\n```\n\n## Time Truncation vs Extraction\n\n| Syntax | What it does | Returns |\n|--------|--------------|---------|\n| `ts.month` | Truncates to start of month | Timestamp (`@2024-03-01`) |\n| `month(ts)` | Extracts month number | Integer (1-12) |\n| `ts.year` | Truncates to start of year | Timestamp (`@2024-01-01`) |\n| `year(ts)` | Extracts year number | Integer (2024) |\n\nUse `.month` for time series charts (proper date ordering). Use `month()` for cross-year comparison.\n\n**Year integers render with commas.** `year(ts)` displays as `2,018`. Tag with `# number=id` to suppress commas. Same for zip codes, IDs.\n\n## `?` Alternation: Use Commas to Combine Filters\n\nThe `?` operator is Malloy's **alternation operator**: a shorthand for \"match any of these values.\" `party ? 'Democrat' | 'Republican'` means `party = 'Democrat' OR party = 'Republican'`. The `|` separates the alternatives.\n\nWhen combining an alternation filter with other filters, **use a comma**:\n\n```malloy\n// CANONICAL: commas separate independent filter conditions\nwhere: is_us = true, party ? 'Democrat' | 'Republican'\n```\n\n`and` works in some arrangements (when the alternation is the second operand) but produces a confusing `'logical operator' Can't use type string` compile error when the alternation comes first. The comma form is unambiguous in every position, so just use it.\n\n## Query Clauses Are Newline-Separated\n\nDo not use trailing commas between query clauses. Each clause goes on its own line.\n\n```malloy\n// WRONG: trailing comma before limit\nrun: source -> { group_by: status, aggregate: n is count(), limit: 10 }\n// RIGHT: newline-separated\nrun: source -> {\n group_by: status\n aggregate: n is count()\n limit: 10\n}\n```\n\nClauses: `group_by:`, `aggregate:`, `nest:`, `order_by:`, `limit:`, `where:`, `having:`, `select:`, `calculate:`\n\n## Fields Within a Clause: Commas or Newlines, Never Semicolons\n\nSemicolons are not a separator anywhere in Malloy. Multiple fields under one `aggregate:` / `group_by:` are separated by commas (inline) or newlines (one per line); a `;` fails with `no viable alternative at input '<next-field>'` pointing at the field right after it.\n\n```malloy\n// WRONG: semicolons between fields\nrun: schools -> { aggregate: total is count(); charters is count() { where: is_charter } }\n// RIGHT: commas inline...\nrun: schools -> { aggregate: total is count(), charters is count() { where: is_charter } }\n// ...or newlines\nrun: schools -> {\n aggregate:\n total is count()\n charters is count() { where: is_charter }\n}\n```" }, { name: "malloy-gotchas-rendering", description: "Common Malloy renderer annotation mistakes. Read BEFORE adding chart annotations, formatting tags, or building dashboards. Covers tag syntax, scale rules, sparkline setup, and big_value patterns.", body: `# Rendering Gotchas
287713
288401
 
287714
288402
  > **Read this before adding renderer annotations.** These patterns cause most rendering issues.
287715
288403
 
@@ -289228,7 +289916,7 @@ source: first_touch is conn.table('events') -> {
289228
289916
  - **Cannot redefine** columns from query-based sources, they already exist as fields. Add only NEW derived dimensions in \`extend {}\`.
289229
289917
  - To add \`#(doc)\` tags to existing query columns, use \`include {}\` between the query and extend.
289230
289918
  - **Use the RAW TABLE** in query-based sources, not a modeled source, when the modeled source would create a circular dependency.
289231
- - **Never use \`conn.sql()\`** when Malloy has a native pattern. \`conn.sql()\` is a last resort for UNNEST, PIVOT, or dialect-specific functions only. Call \`search_malloy_docs\` first.` }, { name: "malloy-modeling", description: "Build semantic models with Malloy for the Malloy Publisher. Read this skill whenever the user asks about modeling data or specifically mentions Malloy.", body: "# STOP - READ BEFORE WRITING ANY MALLOY CODE\n\n> **AI AGENTS: You MUST review this file before writing Malloy code.** Cross-skill references below use logical `skill:` names; load the referenced skill before acting. Before writing code, also read the gotcha skills: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering`.\n\n## Pre-Flight Checklist\n\n1. **Discover first**: ground yourself with `malloy_getContext` before writing ANY code. It returns the package's sources, views, and fields (with their docs), so you build on what actually exists. Never guess field names.\n2. **Search docs proactively**: call `malloy_searchDocs` BEFORE writing unfamiliar patterns (window functions, query-based sources, pipelines). Don't guess. Malloy syntax is specific and SQL intuition is often wrong.\n3. **Use `skill:malloy-patterns`** to discover available doc topics (YoY, cohorts, rendering, window functions).\n4. **Check diagnostics** after writing: fix the FIRST error first, errors cascade.\n5. **Read the gotcha skills**: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering` prevent the most common mistakes.\n\n**Quick syntax reminders:**\n1. **Backtick reserved words:** `` `Date` ``, `` `Hour` ``, `` `Timestamp` ``, `` `Type` ``, `` `number` ``, `` `source` ``\n2. **Use `having:` for aggregate filters**: not `where:` on measures\n3. **Alias joined fields in `group_by`** if using them in `order_by`\n4. **Use `count(x)` not `count(distinct x)`**: Malloy's count() is always distinct\n5. **One tag per line**: `# label=\"Revenue\"` and `# currency` on separate lines\n6. **No fixed scale on measures**: use `# currency` not `# currency=usd0m`\n7. **Cast strings for aggregates:** `avg(score::number)` not `avg(score)`\n8. **Boolean columns:** use `= true` not `= 'true'` (no quotes!)\n9. **Read data files in place:** `.csv`, `.parquet`, `.json`, `.ndjson`, and `.xlsx` all work as-is through `duckdb.table('data/file.ext')`. Never convert a file to another format first, and never read one with python or jq to \"have a look\" first: query it. For `.xlsx`, check the row count before trusting it: a workbook with a title row or a blank spacer reads short and reports no error. (Per-format quirks: `skill:malloy-gotchas-modeling`)\n\n## Planning and `modeling-notes.md`\n\nIf the IDE has a native plan mode, use it for the high-level approach: do data exploration during planning, then present a concrete plan for user approval before writing any files. Once approved, you can write a `modeling-notes.md` during execution to record decisions (scope, sources, key choices, prior art, gaps). This file persists alongside the model. Otherwise, keep the proposal and decisions in the conversation; Publisher has no separate workspace document store to write them to.\n\n## 8-Step Modeling Workflow\n\nThe agent orchestrates all steps. Steps marked **(user)** pause for input. Each step has a dedicated skill with full instructions; load the relevant skill when needed.\n\n**A field is not complete until it has its definition, `#(doc)` tag, and rendering tags.** Documentation is part of defining a field, not a separate activity. Read `skill:malloy-document` for full documentation standards (doc string writing, tag ordering).\n\n```\nDISCOVER → SCOPE → SOURCES → DEFINITIONS → BUILD BASE → BUILD JOINED → REVIEW → CURATE\n (silent) (user) (user) (user) (agent) (agent) (user) (user)\n```\n\n| Step | Skill | What Happens |\n|------|-------|-------------|\n| 1. Discover | `skill:malloy-discover` | Read the model and data; scan sources, fields, distributions; detect prior art |\n| 2. Propose Scope | `skill:malloy-scope` | Present findings, user selects focus |\n| 3. Propose Sources | `skill:malloy-define` | Propose source plan, user confirms architecture |\n| 4. Propose Definitions | `skill:malloy-define` | Propose fields per base source, user confirms logic |\n| 5. Build Base Sources | `skill:malloy-model` | Write fully documented base source files (one per table), check diagnostics. Read `skill:malloy-document` for doc standards. |\n| 6. Build Joined Sources | `skill:malloy-model` | Write fully documented joined source files, validate. Read `skill:malloy-document` for doc standards. |\n| 7. Review | (none) | Present structure, assumptions, and doc coverage; user confirms |\n| 8. Curate | `skill:malloy-model` | Propose access controls, user approves: optional, ask user |\n\nPublishing is out of scope for open-source v1. Self-hosters move a finished model into a served package via git and the host's publish path; see `skill:malloy-publish` for the local-to-served handoff.\n\n**Two paths to a model: both produce the same fully documented result:**\n- **Schema-first:** \"Model my data\" → 8-step workflow above using the relevant skills\n- **Analysis-first:** \"Explore this data\" → `skill:malloy-analyze` → formalize via `skill:malloy-model` (`reference/analysis-to-model.md`)\n\nAfter analysis completes, **always recommend formalizing into a model.**\n\n## Agent Behavior\n\n**Research before asking.** Present proposals with evidence. Never ask open-ended questions: propose with data and let the user confirm.\n\n**Use business language.** Say \"I simplified the column name\" not \"reserved word replaced.\" Don't expose Malloy internals unless the user asks.\n\n**Describe what you're doing, not which step you're on.** The user doesn't have the skill files open. Say \"I'll propose which tables to include and how they relate\" not \"Steps 3 and 4.\" Say \"Now I'll write the source files\" not \"Moving to Step 5.\" Explain the purpose of each phase in plain language before doing it.\n\n**Present choices as A/B/C.** When asking the user to choose, use lettered options with one-line descriptions. Mark your recommendation.\n\n**Complete all workflow steps.** Once modeling begins, complete through review. A field without documentation is not finished. If you lose track, re-read the model and your notes. Suggest notebooks at the end.\n\n## Route by Intent\n\n| User says... | Route to |\n|-------------|----------|\n| \"Model my data\", \"create a model\" | 8-step workflow (`skill:malloy-discover`) |\n| \"Model from LookML\" | 8-step with prior art via `skill:malloy-lookml-review` |\n| \"Explore this data\", \"what's interesting?\", \"show me the top X\" | `skill:malloy-analyze` (EDA) |\n| \"Build a dashboard\", \"create views\" on existing model | `skill:malloy-analyze` (views), plus `skill:malloy-charts` or `skill:malloy-notebooks` as needed |\n| \"Build a model but not sure what metrics\" | `skill:malloy-analyze` first, then formalize via `skill:malloy-model` |\n\n**If the user's first message is a data question** (not \"build me a model\"), route to `skill:malloy-analyze`. After analysis completes, **always recommend formalizing via the analysis-to-model workflow** (`skill:malloy-model` → `reference/analysis-to-model.md`).\n\n## Additional Support Skills\n\nThese supplemental skills may also be loaded as needed:\n\n- **`skill:malloy`**: Index of Malloy skills and routing guide\n- **`skill:malloy-debug`**: Fix compile errors and interpret diagnostics\n\n## Publisher MCP Tools\n\nEnsure the Publisher MCP tools are configured before modeling.\n\n| Tool | Purpose |\n|------|---------|\n| `malloy_getContext` | Ground yourself in a package: its sources, views, and fields |\n| `malloy_executeQuery` | Run ad-hoc queries for validation |\n| `malloy_compile` | Compile-check a change and get diagnostics back without running a query |\n| `malloy_reloadPackage` | Recompile a package from disk so a saved edit becomes queryable by name |\n| `malloy_searchDocs` | Search Malloy docs (call BEFORE unfamiliar patterns) |\n\nNever guess field names. Ground yourself with `malloy_getContext` to see the sources and fields a package defines.\n\n### The edit-and-run loop\n\nPublisher compiles each configured package at boot and serves that cached model, so a source or view you add afterwards is not queryable by name until you reload the package. The loop is:\n\n1. **Validate** the change with `malloy_compile`, which reads the model fresh from disk and returns diagnostics without running anything.\n2. **Save** it to the package's model file.\n3. **Reload** with `malloy_reloadPackage`.\n4. **Run** the new view with `malloy_executeQuery`.\n\nA reload that fails to compile is safe: your files are left alone and the previously compiled model keeps serving, with the compile errors returned to you. Compile first anyway for faster feedback. Keep the source of truth outside `publisher_data/`, which is not version-controlled and is wiped by a `--init` restart. If these two tools are missing, the Publisher you are connected to predates them; fall back to validating with a throwaway `malloy_executeQuery`.\n\n## SQL-to-Malloy Quick Reference\n\n| SQL | Malloy |\n|-----|--------|\n| `COUNT(*)` | `count()` |\n| `COUNT(DISTINCT x)` | `count(x)` |\n| `NOW()` | `now` |\n| `CASE WHEN...END` | `pick...when...else` |\n| `col IN ('a','b')` | `col ? 'a' \\| 'b'` |\n| `COALESCE(a,b)` | `a ?? b` |\n| `CAST(x AS type)` | `x::type` |\n| `DATEDIFF(day, a, b)` | `days(a to b)` |\n| `CONCAT(a, b)` or `a \\|\\| b` | `concat(a, b)` |\n| `TIMESTAMP_DIFF(a, b, SECOND)` | `seconds(b to a)` |\n\n## Critical Rules\n\n1. **All keywords require colons**: `source:`, `dimension:`, `measure:`, `view:`\n2. **Use `is` not `as`**: `dimension: name is expression`\n3. **Arrow operator required**: `run: source -> { operations }`\n4. **Specify join type**: `join_one:`, `join_many:`, `join_cross:`\n5. **Safe division**: `revenue / nullif(count, 0)`\n6. **Group definitions under one keyword**: `measure:` then indent fields beneath\n\n## Common Anti-Patterns\n\n```\nWRONG: source flights is ... RIGHT: source: flights is ...\nWRONG: dimension: x as y RIGHT: dimension: y is x\nWRONG: count(*) RIGHT: count()\nWRONG: count(distinct x) RIGHT: count(x)\nWRONG: revenue / order_count RIGHT: revenue / nullif(order_count, 0)\nWRONG: run: src { ... } RIGHT: run: src -> { ... }\n```\n\n## Reserved Words: Scan Schema First\n\n**Malloy has many reserved words. When in doubt, backtick it.** Most likely to appear as column names:\n\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n- `string`, `boolean`, `true`, `false`: backtick any column with these exact names\n\n## Gotcha Skills: Read Before Writing Code\n\nThe following skills contain detailed WRONG/RIGHT patterns that prevent the most common Malloy errors. **Read them before writing code:**\n\n- **`skill:malloy-gotchas-modeling`**: Reserved words, NULL checks, date functions, type casts, rename pitfalls, query-based source gotchas, `conn.sql()` anti-pattern\n- **`skill:malloy-gotchas-queries`**: Chart constraints, aggregate filters, joined field aliasing, time truncation vs extraction\n- **`skill:malloy-gotchas-rendering`**: Tag syntax, scale rules, sparkline setup, big_value patterns" }, { name: "malloy-notebook-chat", description: "Steps to follow when the chat is bound to a notebook or saved report. The notebook's cells are the agent's primary context, answer from it, run its queries, and only reach for get_context when the user asks about something outside it.", body: `# Notebook/Report Chat Workflow
289919
+ - **Never use \`conn.sql()\`** when Malloy has a native pattern. \`conn.sql()\` is a last resort for UNNEST, PIVOT, or dialect-specific functions only. Call \`search_malloy_docs\` first.` }, { name: "malloy-modeling", description: "Build semantic models with Malloy for the Malloy Publisher. Read this skill whenever the user asks about modeling data or specifically mentions Malloy.", body: "# STOP - READ BEFORE WRITING ANY MALLOY CODE\n\n> **AI AGENTS: You MUST review this file before writing Malloy code.** Cross-skill references below use logical `skill:` names; load the referenced skill before acting. Before writing code, also read the gotcha skills: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering`.\n\n## Pre-Flight Checklist\n\n1. **Discover first**: ground yourself before writing ANY code, with the tool that matches what you are modelling.\n - Modelling data **already in a package**: `malloy_getContext` returns that package's sources, views, and fields (with their docs).\n - Modelling **a database with no package yet**: `malloy_getContext` has nothing to return, so use `malloy_searchDatabaseSchema` instead. It walks the connection's schemas and tables, ranks them against a plain-English description, and gives you each table's columns plus the `source:` line to start from. Take those names verbatim into step 5.\n Never guess field names either way.\n2. **Search docs proactively**: call `malloy_searchDocs` BEFORE writing unfamiliar patterns (window functions, query-based sources, pipelines). Don't guess. Malloy syntax is specific and SQL intuition is often wrong.\n3. **Use `skill:malloy-patterns`** to discover available doc topics (YoY, cohorts, rendering, window functions).\n4. **Check diagnostics** after writing: fix the FIRST error first, errors cascade.\n5. **Read the gotcha skills**: `skill:malloy-gotchas-modeling`, `skill:malloy-gotchas-queries`, and `skill:malloy-gotchas-rendering` prevent the most common mistakes.\n\n**Quick syntax reminders:**\n1. **Backtick reserved words:** `` `Date` ``, `` `Hour` ``, `` `Timestamp` ``, `` `Type` ``, `` `number` ``, `` `source` ``\n2. **Use `having:` for aggregate filters**: not `where:` on measures\n3. **Alias joined fields in `group_by`** if using them in `order_by`\n4. **Use `count(x)` not `count(distinct x)`**: Malloy's count() is always distinct\n5. **One tag per line**: `# label=\"Revenue\"` and `# currency` on separate lines\n6. **No fixed scale on measures**: use `# currency` not `# currency=usd0m`\n7. **Cast strings for aggregates:** `avg(score::number)` not `avg(score)`\n8. **Boolean columns:** use `= true` not `= 'true'` (no quotes!)\n9. **Read data files in place:** `.csv`, `.parquet`, `.json`, `.ndjson`, and `.xlsx` all work as-is through `duckdb.table('data/file.ext')`. Never convert a file to another format first, and never read one with python or jq to \"have a look\" first: query it. For `.xlsx`, check the row count before trusting it: a workbook with a title row or a blank spacer reads short and reports no error. (Per-format quirks: `skill:malloy-gotchas-modeling`)\n\n## Planning and `modeling-notes.md`\n\nIf the IDE has a native plan mode, use it for the high-level approach: do data exploration during planning, then present a concrete plan for user approval before writing any files. Once approved, you can write a `modeling-notes.md` during execution to record decisions (scope, sources, key choices, prior art, gaps). This file persists alongside the model. Otherwise, keep the proposal and decisions in the conversation; Publisher has no separate workspace document store to write them to.\n\n## 8-Step Modeling Workflow\n\nThe agent orchestrates all steps. Steps marked **(user)** pause for input. Each step has a dedicated skill with full instructions; load the relevant skill when needed.\n\n**A field is not complete until it has its definition, `#(doc)` tag, and rendering tags.** Documentation is part of defining a field, not a separate activity. Read `skill:malloy-document` for full documentation standards (doc string writing, tag ordering).\n\n```\nDISCOVER → SCOPE → SOURCES → DEFINITIONS → BUILD BASE → BUILD JOINED → REVIEW → CURATE\n (silent) (user) (user) (user) (agent) (agent) (user) (user)\n```\n\n| Step | Skill | What Happens |\n|------|-------|-------------|\n| 1. Discover | `skill:malloy-discover` | Read the model and data; scan sources, fields, distributions; detect prior art. With no package yet, start from `malloy_searchDatabaseSchema` to find the tables in the connection |\n| 2. Propose Scope | `skill:malloy-scope` | Present findings, user selects focus |\n| 3. Propose Sources | `skill:malloy-define` | Propose source plan, user confirms architecture |\n| 4. Propose Definitions | `skill:malloy-define` | Propose fields per base source, user confirms logic |\n| 5. Build Base Sources | `skill:malloy-model` | Write fully documented base source files (one per table), check diagnostics. Read `skill:malloy-document` for doc standards. |\n| 6. Build Joined Sources | `skill:malloy-model` | Write fully documented joined source files, validate. Read `skill:malloy-document` for doc standards. |\n| 7. Review | (none) | Present structure, assumptions, and doc coverage; user confirms |\n| 8. Curate | `skill:malloy-model` | Propose access controls, user approves: optional, ask user |\n\nPublishing is out of scope for open-source v1. Self-hosters move a finished model into a served package via git and the host's publish path; see `skill:malloy-publish` for the local-to-served handoff.\n\n**Two paths to a model: both produce the same fully documented result:**\n- **Schema-first:** \"Model my data\" → 8-step workflow above using the relevant skills\n- **Analysis-first:** \"Explore this data\" → `skill:malloy-analyze` → formalize via `skill:malloy-model` (`reference/analysis-to-model.md`)\n\nAfter analysis completes, **always recommend formalizing into a model.**\n\n## Agent Behavior\n\n**Research before asking.** Present proposals with evidence. Never ask open-ended questions: propose with data and let the user confirm.\n\n**Use business language.** Say \"I simplified the column name\" not \"reserved word replaced.\" Don't expose Malloy internals unless the user asks.\n\n**Describe what you're doing, not which step you're on.** The user doesn't have the skill files open. Say \"I'll propose which tables to include and how they relate\" not \"Steps 3 and 4.\" Say \"Now I'll write the source files\" not \"Moving to Step 5.\" Explain the purpose of each phase in plain language before doing it.\n\n**Present choices as A/B/C.** When asking the user to choose, use lettered options with one-line descriptions. Mark your recommendation.\n\n**Complete all workflow steps.** Once modeling begins, complete through review. A field without documentation is not finished. If you lose track, re-read the model and your notes. Suggest notebooks at the end.\n\n## Route by Intent\n\n| User says... | Route to |\n|-------------|----------|\n| \"Model my data\", \"create a model\" | 8-step workflow (`skill:malloy-discover`) |\n| \"Model from LookML\" | 8-step with prior art via `skill:malloy-lookml-review` |\n| \"Explore this data\", \"what's interesting?\", \"show me the top X\" | `skill:malloy-analyze` (EDA) |\n| \"Build a dashboard\", \"create views\" on existing model | `skill:malloy-analyze` (views), plus `skill:malloy-charts` or `skill:malloy-notebooks` as needed |\n| \"Build a model but not sure what metrics\" | `skill:malloy-analyze` first, then formalize via `skill:malloy-model` |\n\n**If the user's first message is a data question** (not \"build me a model\"), route to `skill:malloy-analyze`. After analysis completes, **always recommend formalizing via the analysis-to-model workflow** (`skill:malloy-model` → `reference/analysis-to-model.md`).\n\n## Additional Support Skills\n\nThese supplemental skills may also be loaded as needed:\n\n- **`skill:malloy`**: Index of Malloy skills and routing guide\n- **`skill:malloy-debug`**: Fix compile errors and interpret diagnostics\n\n## Publisher MCP Tools\n\nEnsure the Publisher MCP tools are configured before modeling.\n\n| Tool | Purpose |\n|------|---------|\n| `malloy_getContext` | Ground yourself in a package: its sources, views, and fields |\n| `malloy_executeQuery` | Run ad-hoc queries for validation |\n| `malloy_compile` | Compile-check a change and get diagnostics back without running a query |\n| `malloy_reloadPackage` | Recompile a package from disk so a saved edit becomes queryable by name |\n| `malloy_searchDocs` | Search Malloy docs (call BEFORE unfamiliar patterns) |\n| `malloy_searchDatabaseSchema` | Find the tables in a database connection by plain-English description, when modelling data that is not in a package yet. Returns each table's columns and the `source:` line to start from. Names and types only: no row value is returned |\n\nNever guess field names. Ground yourself with `malloy_getContext` to see the sources and fields a package defines.\n\n### The edit-and-run loop\n\nPublisher compiles each configured package at boot and serves that cached model, so a source or view you add afterwards is not queryable by name until you reload the package. The loop is:\n\n1. **Validate** the change with `malloy_compile`, which reads the model fresh from disk and returns diagnostics without running anything.\n2. **Save** it to the package's model file.\n3. **Reload** with `malloy_reloadPackage`.\n4. **Run** the new view with `malloy_executeQuery`.\n\nA reload that fails to compile is safe: your files are left alone and the previously compiled model keeps serving, with the compile errors returned to you. Compile first anyway for faster feedback. Keep the source of truth outside `publisher_data/`, which is not version-controlled and is wiped by a `--init` restart. If these two tools are missing, the Publisher you are connected to predates them; fall back to validating with a throwaway `malloy_executeQuery`.\n\n## SQL-to-Malloy Quick Reference\n\n| SQL | Malloy |\n|-----|--------|\n| `COUNT(*)` | `count()` |\n| `COUNT(DISTINCT x)` | `count(x)` |\n| `NOW()` | `now` |\n| `CASE WHEN...END` | `pick...when...else` |\n| `col IN ('a','b')` | `col ? 'a' \\| 'b'` |\n| `COALESCE(a,b)` | `a ?? b` |\n| `CAST(x AS type)` | `x::type` |\n| `DATEDIFF(day, a, b)` | `days(a to b)` |\n| `CONCAT(a, b)` or `a \\|\\| b` | `concat(a, b)` |\n| `TIMESTAMP_DIFF(a, b, SECOND)` | `seconds(b to a)` |\n\n## Critical Rules\n\n1. **All keywords require colons**: `source:`, `dimension:`, `measure:`, `view:`\n2. **Use `is` not `as`**: `dimension: name is expression`\n3. **Arrow operator required**: `run: source -> { operations }`\n4. **Specify join type**: `join_one:`, `join_many:`, `join_cross:`\n5. **Safe division**: `revenue / nullif(count, 0)`\n6. **Group definitions under one keyword**: `measure:` then indent fields beneath\n\n## Common Anti-Patterns\n\n```\nWRONG: source flights is ... RIGHT: source: flights is ...\nWRONG: dimension: x as y RIGHT: dimension: y is x\nWRONG: count(*) RIGHT: count()\nWRONG: count(distinct x) RIGHT: count(x)\nWRONG: revenue / order_count RIGHT: revenue / nullif(order_count, 0)\nWRONG: run: src { ... } RIGHT: run: src -> { ... }\n```\n\n## Reserved Words: Scan Schema First\n\n**Malloy has many reserved words. When in doubt, backtick it.** Most likely to appear as column names:\n\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n- `string`, `boolean`, `true`, `false`: backtick any column with these exact names\n\n## Gotcha Skills: Read Before Writing Code\n\nThe following skills contain detailed WRONG/RIGHT patterns that prevent the most common Malloy errors. **Read them before writing code:**\n\n- **`skill:malloy-gotchas-modeling`**: Reserved words, NULL checks, date functions, type casts, rename pitfalls, query-based source gotchas, `conn.sql()` anti-pattern\n- **`skill:malloy-gotchas-queries`**: Chart constraints, aggregate filters, joined field aliasing, time truncation vs extraction\n- **`skill:malloy-gotchas-rendering`**: Tag syntax, scale rules, sparkline setup, big_value patterns" }, { name: "malloy-notebook-chat", description: "Steps to follow when the chat is bound to a notebook or saved report. The notebook's cells are the agent's primary context, answer from it, run its queries, and only reach for get_context when the user asks about something outside it.", body: `# Notebook/Report Chat Workflow
289232
289920
 
289233
289921
  Steps to follow when the user asks a question:
289234
289922
 
@@ -290245,6 +290933,8 @@ var MCP_INSTRUCTIONS = `Malloy Publisher serves one or more Malloy semantic-mode
290245
290933
 
290246
290934
  Start with malloy_getContext. Call it with no arguments to list the environments (each with its packages), with an environment to list its packages, with a package to list its sources, and with a package plus a plain-English question to get the sources, views, and fields most relevant to it. Use the names it returns verbatim and do not guess. Then run a query with malloy_executeQuery. To change a model: validate the edit with malloy_compile, save it, then call malloy_reloadPackage so the new sources and views become queryable by name without restarting the server. ${RELOAD_FAILURE_IS_SAFE}
290247
290935
 
290936
+ To build a model from a database rather than from an existing package, start with malloy_searchDatabaseSchema: it lists the connections, their schemas, and their tables, and ranks those tables against a plain-English description of the data you want. Each table it returns carries the source line to start from. It returns names and types only: no row value is returned.
290937
+
290248
290938
  Task-specific guidance is served as prompts you can fetch by name: malloy-getting-started to begin, malloy-modeling to build or change a model, malloy-analysis to explore and answer questions, and malloy-review to check correctness.
290249
290939
 
290250
290940
  Results and any charts render in the Publisher web UI on the REST port (4000 by default).`;
@@ -290259,6 +290949,7 @@ function initializeMcpServer(environmentStore) {
290259
290949
  registerDocsSearchTool(mcpServer, environmentStore);
290260
290950
  registerCompileTool(mcpServer, environmentStore);
290261
290951
  registerReloadPackageTool(mcpServer, environmentStore);
290952
+ registerSearchDatabaseSchemaTool(mcpServer, environmentStore);
290262
290953
  for (const skill of AGENT_SKILLS) {
290263
290954
  mcpServer.prompt(skill.name, skill.description, () => ({
290264
290955
  messages: [