@malloy-publisher/server 0.0.248 → 0.0.249

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/app/api-doc.yaml +396 -21
  2. package/dist/app/assets/{EnvironmentPage-DrTRtRHz.js → EnvironmentPage-CB0NkVVs.js} +1 -1
  3. package/dist/app/assets/HomePage-BEfG37L3.js +1 -0
  4. package/dist/app/assets/{LightMode-4Y7DEWAD.js → LightMode--nzFqff1.js} +1 -1
  5. package/dist/app/assets/{MainPage-DKNR-8Vr.js → MainPage-owd1ua9R.js} +2 -2
  6. package/dist/app/assets/{MaterializationsPage-h3ONDcgJ.js → MaterializationsPage-BqaGRrxZ.js} +1 -1
  7. package/dist/app/assets/ModelPage-BydBAuRk.js +1 -0
  8. package/dist/app/assets/{PackagePage-D8mM4T37.js → PackagePage-BDl_rs-E.js} +1 -1
  9. package/dist/app/assets/{RouteError-DzJ0TPOj.js → RouteError-CyReblMg.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-BYvxc5op.js → ThemeEditorPage-zW7ndXQ8.js} +1 -1
  11. package/dist/app/assets/{WorkbookPage-CYOpAw7I.js → WorkbookPage-CzjOTRw8.js} +1 -1
  12. package/dist/app/assets/{core-BuvlzSIX.es-cZHBIpH6.js → core-DFBwNhvb.es-BwyvPM0R.js} +10 -10
  13. package/dist/app/assets/index-Byu4Enw7.js +2 -0
  14. package/dist/app/assets/{index-BgAa2J25.js → index-Cmp8wg1s.js} +1 -1
  15. package/dist/app/assets/{index-BgMR5Uuv.js → index-D6bZJBhc.js} +113 -113
  16. package/dist/app/assets/index-t7IEDVsK.js +615 -0
  17. package/dist/app/index.html +1 -1
  18. package/dist/package_load_worker.mjs +342 -2
  19. package/dist/server.mjs +1726 -134
  20. package/dist/sshcrypto-vd2k5hq9.node +0 -0
  21. package/package.json +2 -1
  22. package/dist/app/assets/HomePage-X_Wozguy.js +0 -1
  23. package/dist/app/assets/ModelPage-B8Qsrbxn.js +0 -1
  24. package/dist/app/assets/index-BwoU1Ko8.js +0 -529
  25. package/dist/app/assets/index-CuaX7xuf.js +0 -18
package/dist/server.mjs CHANGED
@@ -156501,6 +156501,8 @@ function internalErrorToHttpError(error) {
156501
156501
  return httpError(404, error.message);
156502
156502
  } else if (error instanceof ModelNotFoundError) {
156503
156503
  return httpError(404, error.message);
156504
+ } else if (error instanceof DashboardNotFoundError) {
156505
+ return httpError(404, error.message);
156504
156506
  } else if (error instanceof NotQueryableError) {
156505
156507
  return httpError(404, error.message);
156506
156508
  } else if (error instanceof MalloyError) {
@@ -156546,7 +156548,7 @@ function httpError(code, message) {
156546
156548
  }
156547
156549
  };
156548
156550
  }
156549
- var NotImplementedError, BadRequestError, InvalidArgumentError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, ConnectionNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, MaterializationEligibilityError, FrozenConfigError, AccessDeniedError, NotQueryableError, MaterializationNotFoundError, MaterializationConflictError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
156551
+ var NotImplementedError, BadRequestError, InvalidArgumentError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, MaterializationEligibilityError, FrozenConfigError, AccessDeniedError, NotQueryableError, MaterializationNotFoundError, MaterializationConflictError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
156550
156552
  var init_errors = __esm(() => {
156551
156553
  init_constants();
156552
156554
  NotImplementedError = class NotImplementedError extends Error {
@@ -156576,6 +156578,11 @@ var init_errors = __esm(() => {
156576
156578
  super(message);
156577
156579
  }
156578
156580
  };
156581
+ DashboardNotFoundError = class DashboardNotFoundError extends Error {
156582
+ constructor(message) {
156583
+ super(message);
156584
+ }
156585
+ };
156579
156586
  ConnectionNotFoundError = class ConnectionNotFoundError extends Error {
156580
156587
  constructor(message) {
156581
156588
  super(message);
@@ -179835,6 +179842,11 @@ var require_utils74 = __commonJS((exports, module) => {
179835
179842
  };
179836
179843
  });
179837
179844
 
179845
+ // ../../node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
179846
+ var require_sshcrypto = __commonJS((exports, module) => {
179847
+ module.exports = __require("./sshcrypto-vd2k5hq9.node");
179848
+ });
179849
+
179838
179850
  // ../../node_modules/ssh2/lib/protocol/crypto/poly1305.js
179839
179851
  var require_poly1305 = __commonJS((exports, module) => {
179840
179852
  var __dirname = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto", __filename = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto/poly1305.js";
@@ -180321,7 +180333,7 @@ var require_crypto = __commonJS((exports, module) => {
180321
180333
  var ChaChaPolyDecipher;
180322
180334
  var GenericDecipher;
180323
180335
  try {
180324
- binding = (()=>{throw new Error("Cannot require module "+"./crypto/build/Release/sshcrypto.node");})();
180336
+ binding = require_sshcrypto();
180325
180337
  ({
180326
180338
  AESGCMCipher,
180327
180339
  ChaChaPolyCipher,
@@ -244754,6 +244766,835 @@ var require_dist11 = __commonJS((exports) => {
244754
244766
  }
244755
244767
  });
244756
244768
 
244769
+ // src/service/annotations.ts
244770
+ import { Annotations } from "@malloydata/malloy";
244771
+ function isReservedRoute(route) {
244772
+ return route === "" || !/[\p{L}\p{N}]/u.test(route);
244773
+ }
244774
+ function ownModelAnnotations(modelDef) {
244775
+ return foldModelAnnotations(modelDef, (id) => id === modelDef.modelID || id.startsWith("internal://"));
244776
+ }
244777
+ function modelAnnotations(modelDef) {
244778
+ return foldModelAnnotations(modelDef, () => true);
244779
+ }
244780
+ function foldModelAnnotations(modelDef, admits) {
244781
+ const registry = modelDef.modelAnnotations ?? {};
244782
+ const visited = new Set;
244783
+ const order = [];
244784
+ const visit = (id) => {
244785
+ if (!admits(id))
244786
+ return;
244787
+ if (visited.has(id))
244788
+ return;
244789
+ visited.add(id);
244790
+ const entry = registry[id];
244791
+ if (!entry)
244792
+ return;
244793
+ for (const dep of entry.inheritsFrom)
244794
+ visit(dep);
244795
+ order.push(id);
244796
+ };
244797
+ visit(modelDef.modelID);
244798
+ let folded;
244799
+ for (const id of order) {
244800
+ const own = registry[id].ownNotes;
244801
+ if (!own.notes?.length && !own.blockNotes?.length)
244802
+ continue;
244803
+ folded = {
244804
+ notes: own.notes,
244805
+ blockNotes: own.blockNotes,
244806
+ inherits: folded
244807
+ };
244808
+ }
244809
+ return folded ?? {};
244810
+ }
244811
+ function ownModelNotes(modelDef) {
244812
+ const registry = modelDef.modelAnnotations ?? {};
244813
+ const isSameDocument = (id) => id === modelDef.modelID || id.startsWith("internal://");
244814
+ const seen = new Set;
244815
+ const texts = [];
244816
+ const visit = (id) => {
244817
+ if (seen.has(id) || !isSameDocument(id))
244818
+ return;
244819
+ seen.add(id);
244820
+ const entry = registry[id];
244821
+ if (!entry)
244822
+ return;
244823
+ for (const dep of entry.inheritsFrom)
244824
+ visit(dep);
244825
+ texts.push(...ownLevelNoteTexts(entry.ownNotes));
244826
+ };
244827
+ visit(modelDef.modelID);
244828
+ return texts;
244829
+ }
244830
+ function annotationTexts(annote) {
244831
+ const texts = new Annotations(annote).texts();
244832
+ return texts.length > 0 ? texts : undefined;
244833
+ }
244834
+ function ownLevelNoteTexts(annote) {
244835
+ return ownLevelNotes(annote).map((note) => note.text);
244836
+ }
244837
+ function ownLevelNotes(annote) {
244838
+ return [...annote?.blockNotes ?? [], ...annote?.notes ?? []];
244839
+ }
244840
+ var init_annotations = () => {};
244841
+
244842
+ // src/service/motly.ts
244843
+ import { parseAnnotation } from "@malloydata/malloy-tag";
244844
+ function onMotlyRoute(text) {
244845
+ const afterSigil = text.replace(/^##?\|?/, "");
244846
+ return afterSigil === "" || /^[ \t\r\n]/.test(afterSigil);
244847
+ }
244848
+ function hasEnvReference(annotation) {
244849
+ return annotation.includes("@env.");
244850
+ }
244851
+ function pollutionTargets() {
244852
+ const targets = [Object.prototype];
244853
+ for (const key of Object.getOwnPropertyNames(Object.prototype)) {
244854
+ const descriptor = Object.getOwnPropertyDescriptor(Object.prototype, key);
244855
+ if (!descriptor)
244856
+ continue;
244857
+ let value;
244858
+ if ("value" in descriptor) {
244859
+ value = descriptor.value;
244860
+ } else {
244861
+ if (BUILT_IN_PROTO_GETTER === undefined || descriptor.get !== BUILT_IN_PROTO_GETTER) {
244862
+ return;
244863
+ }
244864
+ continue;
244865
+ }
244866
+ if ((typeof value === "object" || typeof value === "function") && value !== null && !targets.includes(value)) {
244867
+ targets.push(value);
244868
+ }
244869
+ }
244870
+ return targets;
244871
+ }
244872
+ function parseGuarded(texts) {
244873
+ const targets = pollutionTargets();
244874
+ if (targets === undefined) {
244875
+ return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
244876
+ }
244877
+ let unreadable = false;
244878
+ const ownNames = (target) => {
244879
+ try {
244880
+ return Object.getOwnPropertyNames(target);
244881
+ } catch {
244882
+ unreadable = true;
244883
+ return [];
244884
+ }
244885
+ };
244886
+ const before = targets.map(ownNames);
244887
+ if (unreadable)
244888
+ return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
244889
+ const undoPollution = () => {
244890
+ let polluted = false;
244891
+ targets.forEach((target, index) => {
244892
+ for (const key of ownNames(target)) {
244893
+ if (before[index].includes(key))
244894
+ continue;
244895
+ polluted = true;
244896
+ try {
244897
+ delete target[key];
244898
+ } catch {}
244899
+ }
244900
+ });
244901
+ return polluted || unreadable;
244902
+ };
244903
+ if (texts.some((text) => text.length > MAX_ANNOTATION_CHARS)) {
244904
+ return { tag: undefined, messages: [ANNOTATION_TOO_LONG] };
244905
+ }
244906
+ try {
244907
+ const result = parseAnnotation([...texts]);
244908
+ if (undoPollution())
244909
+ return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
244910
+ return {
244911
+ tag: result.tag,
244912
+ messages: result.log.map((error) => error.message)
244913
+ };
244914
+ } catch {
244915
+ undoPollution();
244916
+ return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
244917
+ }
244918
+ }
244919
+ function docCommentText(texts) {
244920
+ const lines = texts.filter((text) => /^##?\|?"([ \t\r\n]|$)/.test(text)).map((text) => text.replace(/^##?\|?"[ \t\r\n]?/, "").trimEnd());
244921
+ return lines.some((text) => text.trim().length > 0) ? lines.join(`
244922
+ `) : undefined;
244923
+ }
244924
+ function splitDocComment(texts) {
244925
+ const text = docCommentText(texts);
244926
+ if (text === undefined)
244927
+ return {};
244928
+ const lines = text.split(`
244929
+ `);
244930
+ const titleLine = lines.findIndex((line) => line.trim().length > 0);
244931
+ if (titleLine === -1)
244932
+ return {};
244933
+ const body = lines.slice(titleLine + 1).join(`
244934
+ `).replace(/^[ \t\r\n]+/, "").trimEnd();
244935
+ return {
244936
+ title: lines[titleLine].trim(),
244937
+ body: body.length > 0 ? body : undefined
244938
+ };
244939
+ }
244940
+ function docCommentTitleAndDescription(texts, explicitTitle) {
244941
+ if (explicitTitle !== undefined) {
244942
+ return { title: explicitTitle, description: docCommentText(texts) };
244943
+ }
244944
+ const { title, body } = splitDocComment(texts);
244945
+ return { title, description: body };
244946
+ }
244947
+ function quoteFilterLiterals(annotation) {
244948
+ const VALUE_START = /[=[,]/;
244949
+ const BARE_FILTER_LITERAL = /^([ \t]*)f(['"])((?:\\.|(?!\2)[^\\])*)\2/;
244950
+ let out = "";
244951
+ let i = 0;
244952
+ while (i < annotation.length) {
244953
+ const char = annotation[i];
244954
+ const delimited = endOfDelimited(annotation, i);
244955
+ if (delimited > i) {
244956
+ out += annotation.slice(i, delimited);
244957
+ i = delimited;
244958
+ continue;
244959
+ }
244960
+ if (VALUE_START.test(char)) {
244961
+ const match = BARE_FILTER_LITERAL.exec(annotation.slice(i + 1));
244962
+ if (match) {
244963
+ const body = match[3].replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
244964
+ out += `${char}${match[1]}"f'${body}'"`;
244965
+ i += 1 + match[0].length;
244966
+ continue;
244967
+ }
244968
+ }
244969
+ out += char;
244970
+ i += 1;
244971
+ }
244972
+ return out;
244973
+ }
244974
+ function endOfDelimited(text, start) {
244975
+ const char = text[start];
244976
+ if (char === "`") {
244977
+ let i = start + 1;
244978
+ while (i < text.length) {
244979
+ if (text[i] === "\\") {
244980
+ i += 2;
244981
+ continue;
244982
+ }
244983
+ if (text[i] === "`")
244984
+ return i + 1;
244985
+ if (text[i] === `
244986
+ `)
244987
+ return text.length;
244988
+ i += 1;
244989
+ }
244990
+ return text.length;
244991
+ }
244992
+ if (text.startsWith("<<<", start)) {
244993
+ let lineStart = start + 3;
244994
+ for (;; ) {
244995
+ const newline = text.indexOf(`
244996
+ `, lineStart);
244997
+ const lineEnd = newline === -1 ? text.length : newline;
244998
+ if (text.slice(lineStart, lineEnd).trim() === ">>>")
244999
+ return lineEnd;
245000
+ if (newline === -1)
245001
+ return text.length;
245002
+ lineStart = newline + 1;
245003
+ }
245004
+ }
245005
+ if (char === '"' || char === "'") {
245006
+ const triple = char.repeat(3);
245007
+ if (text.startsWith(triple, start)) {
245008
+ let i2 = start + triple.length;
245009
+ while (i2 < text.length) {
245010
+ if (text[i2] === "\\") {
245011
+ i2 += 2;
245012
+ continue;
245013
+ }
245014
+ if (text.startsWith(triple, i2))
245015
+ return i2 + triple.length;
245016
+ i2 += 1;
245017
+ }
245018
+ return text.length;
245019
+ }
245020
+ let i = start + 1;
245021
+ while (i < text.length) {
245022
+ if (text[i] === "\\") {
245023
+ i += 2;
245024
+ continue;
245025
+ }
245026
+ if (text[i] === char)
245027
+ return i + 1;
245028
+ if (text[i] === `
245029
+ `)
245030
+ return i;
245031
+ i += 1;
245032
+ }
245033
+ return text.length;
245034
+ }
245035
+ return start;
245036
+ }
245037
+ function unwrapFilterLiteral(value) {
245038
+ const match = /^f(['"])([\s\S]*)\1$/.exec(value);
245039
+ return match ? match[2] : value;
245040
+ }
245041
+ function parseMotly(texts) {
245042
+ const onRoute = texts.filter(onMotlyRoute);
245043
+ const motly = onRoute.filter((text) => !hasEnvReference(text));
245044
+ const envDropped = onRoute.length - motly.length;
245045
+ const envErrors = envDropped > 0 ? [ENV_REFERENCE_DROPPED] : [];
245046
+ if (motly.length === 0)
245047
+ return { tag: undefined, errors: envErrors };
245048
+ const direct = parseGuarded(motly);
245049
+ if (direct.messages.length === 0)
245050
+ return { tag: direct.tag, errors: envErrors };
245051
+ const rescued = motly.map((text) => {
245052
+ if (parseGuarded([text]).messages.length === 0)
245053
+ return text;
245054
+ const rewritten = quoteFilterLiterals(text);
245055
+ return parseGuarded([rewritten]).messages.length === 0 ? rewritten : text;
245056
+ });
245057
+ const after = parseGuarded(rescued);
245058
+ return { tag: after.tag, errors: [...envErrors, ...after.messages] };
245059
+ }
245060
+ function tagText(tag, ...path5) {
245061
+ try {
245062
+ return tag?.text(...path5);
245063
+ } catch {
245064
+ return;
245065
+ }
245066
+ }
245067
+ function tagNumeric(tag, ...path5) {
245068
+ try {
245069
+ const raw = tagText(tag, ...path5);
245070
+ if (raw === undefined)
245071
+ return;
245072
+ const DECIMAL = /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/;
245073
+ const text = raw.trim();
245074
+ if (!DECIMAL.test(text))
245075
+ return;
245076
+ const value = Number(text);
245077
+ return Number.isFinite(value) ? value : undefined;
245078
+ } catch {
245079
+ return;
245080
+ }
245081
+ }
245082
+ function motlyTag(texts) {
245083
+ return parseMotly(texts).tag;
245084
+ }
245085
+ function motlyParseErrors(texts) {
245086
+ return parseMotly(texts).errors;
245087
+ }
245088
+ function readAutorun(tag) {
245089
+ return tagText(tag, "autorun") !== "false";
245090
+ }
245091
+ function scalarTypeOf(tag) {
245092
+ try {
245093
+ return tag?.scalarType();
245094
+ } catch {
245095
+ return;
245096
+ }
245097
+ }
245098
+ function authoredDay(isoText) {
245099
+ const value = new Date(isoText);
245100
+ if (Number.isNaN(value.getTime()))
245101
+ return isoText.slice(0, 10);
245102
+ const atUtcMidnight = value.getUTCHours() === 0 && value.getUTCMinutes() === 0 && value.getUTCSeconds() === 0 && value.getUTCMilliseconds() === 0;
245103
+ const year = atUtcMidnight ? value.getUTCFullYear() : value.getFullYear();
245104
+ const month = atUtcMidnight ? value.getUTCMonth() : value.getMonth();
245105
+ const day = atUtcMidnight ? value.getUTCDate() : value.getDate();
245106
+ return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
245107
+ }
245108
+ function readStartingGivens(tag, declaredType) {
245109
+ const entries = tag?.tag("givens");
245110
+ if (!entries)
245111
+ return;
245112
+ const collected = {};
245113
+ for (const [name, value] of entries.entries()) {
245114
+ const tag2 = value;
245115
+ const text = tagText(tag2);
245116
+ if (text === undefined)
245117
+ continue;
245118
+ const declared = declaredType(name);
245119
+ collected[name] = scalarTypeOf(tag2) === "date" && declared !== "timestamp" && declared !== "timestamptz" ? authoredDay(text) : declared?.startsWith("filter<") ? unwrapFilterLiteral(text) : text;
245120
+ }
245121
+ return Object.keys(collected).length > 0 ? collected : undefined;
245122
+ }
245123
+ var BUILT_IN_PROTO_GETTER, MAX_ANNOTATION_CHARS = 8192, ANNOTATION_TOO_LONG, ENV_REFERENCE_DROPPED = "annotation dropped for carrying an @env. reference", UNSAFE_TO_PARSE = "annotation could not be parsed safely";
245124
+ var init_motly = __esm(() => {
245125
+ BUILT_IN_PROTO_GETTER = Object.getOwnPropertyDescriptor(Object.prototype, "__proto__")?.get;
245126
+ ANNOTATION_TOO_LONG = `annotation exceeds ${MAX_ANNOTATION_CHARS} characters and was not parsed`;
245127
+ });
245128
+
245129
+ // src/service/given.ts
245130
+ function presentText(tag, ...path5) {
245131
+ const raw = tagText(tag, ...path5);
245132
+ return raw !== undefined && raw.trim() !== "" ? raw : undefined;
245133
+ }
245134
+ function readGivenControlSpec(annotationTexts2) {
245135
+ const spec = {};
245136
+ const tag = motlyTag(annotationTexts2);
245137
+ if (!tag)
245138
+ return spec;
245139
+ const label = presentText(tag, "label");
245140
+ if (label !== undefined)
245141
+ spec.label = label;
245142
+ const description = presentText(tag, "description");
245143
+ if (description !== undefined)
245144
+ spec.description = description;
245145
+ const control = tagText(tag, "control");
245146
+ if (control === "select" || control === "multiselect") {
245147
+ spec.control = control;
245148
+ }
245149
+ const rangeMin = tagNumeric(tag, "range_min");
245150
+ if (rangeMin !== undefined)
245151
+ spec.rangeMin = rangeMin;
245152
+ const rangeMax = tagNumeric(tag, "range_max");
245153
+ if (rangeMax !== undefined)
245154
+ spec.rangeMax = rangeMax;
245155
+ const suggest = tag.tag("suggest");
245156
+ if (suggest) {
245157
+ const parsed = {};
245158
+ const query = presentText(suggest, "query");
245159
+ if (query !== undefined)
245160
+ parsed.query = query;
245161
+ const source = presentText(suggest, "source");
245162
+ if (source !== undefined)
245163
+ parsed.source = source;
245164
+ const dimension = presentText(suggest, "dimension");
245165
+ if (dimension !== undefined)
245166
+ parsed.dimension = dimension;
245167
+ const runnable = parsed.query !== undefined || parsed.source !== undefined && parsed.dimension !== undefined;
245168
+ if (runnable)
245169
+ spec.suggest = parsed;
245170
+ }
245171
+ return spec;
245172
+ }
245173
+ function malloyGivenToApi(given) {
245174
+ const type = given.type;
245175
+ const renderedType = type.type === "filter expression" ? `filter<${type.filterType}>` : type.type;
245176
+ const allNotes = given.annotations.forRoute(undefined);
245177
+ return {
245178
+ name: given.name,
245179
+ type: renderedType,
245180
+ annotations: allNotes.filter((note) => !isReservedRoute(note.route)).map((note) => note.text),
245181
+ ...readGivenControlSpec(allNotes.map((note) => note.text)),
245182
+ default: given._internal?.defaultText
245183
+ };
245184
+ }
245185
+ var init_given = __esm(() => {
245186
+ init_annotations();
245187
+ init_motly();
245188
+ });
245189
+
245190
+ // src/service/dashboard.ts
245191
+ import { isSourceDef } from "@malloydata/malloy";
245192
+ function filterPublisherOwnedRenderLogs(logs) {
245193
+ return logs.filter((log) => {
245194
+ const match = UNKNOWN_RENDER_TAG.exec(log.message ?? "");
245195
+ if (!match)
245196
+ return true;
245197
+ return !PUBLISHER_OWNED_TAGS.includes(match[1].split(".")[0]);
245198
+ });
245199
+ }
245200
+ function isDashboardModelPath(modelPath) {
245201
+ if (!modelPath.endsWith(MODEL_FILE_SUFFIX2))
245202
+ return false;
245203
+ const segments = modelPath.split("/");
245204
+ return segments.length === 2 && segments[0] === DASHBOARDS_DIR;
245205
+ }
245206
+ function dashboardSlug(modelPath) {
245207
+ const basename = modelPath.slice(modelPath.lastIndexOf("/") + 1);
245208
+ return basename.slice(0, -MODEL_FILE_SUFFIX2.length);
245209
+ }
245210
+ function matchesDocumentedDashboardName(slug) {
245211
+ return /^[a-zA-Z0-9_-]+$/.test(slug);
245212
+ }
245213
+ function readArtifactTag(tag, declaredType) {
245214
+ const artifact = tag.tag("artifact");
245215
+ if (!artifact)
245216
+ return;
245217
+ const givens = readStartingGivens(artifact, declaredType);
245218
+ const autorun = readAutorun(artifact);
245219
+ return {
245220
+ title: tagText(artifact, "title"),
245221
+ tiles: artifact.array("tiles")?.map((tile) => tagText(tile)).filter((tile) => tile !== undefined),
245222
+ dashboardColumns: positiveInteger(tagNumeric(artifact, "dashboard_columns")) ?? positiveInteger(tagNumeric(tag.tag("dashboard"), "columns")),
245223
+ givens,
245224
+ autorun
245225
+ };
245226
+ }
245227
+ function positiveInteger(value) {
245228
+ return value !== undefined && Number.isInteger(value) && value >= 1 ? value : undefined;
245229
+ }
245230
+ function normalizeTileExpression(tile) {
245231
+ return tile.split("->").map((part) => part.trim().replace(/\s+/g, " ")).join(" -> ").replace(/\s+/g, " ");
245232
+ }
245233
+ function collectGivenRefs(value, into) {
245234
+ if (Array.isArray(value)) {
245235
+ for (const item of value)
245236
+ collectGivenRefs(item, into);
245237
+ return;
245238
+ }
245239
+ if (value === null || typeof value !== "object")
245240
+ return;
245241
+ const node = value;
245242
+ if (node.node === "given" && typeof node.refName === "string") {
245243
+ into.add(node.refName);
245244
+ }
245245
+ for (const child of Object.values(node))
245246
+ collectGivenRefs(child, into);
245247
+ }
245248
+ function readDashboardModelFacts(modelPath, modelDef, surfacedGivenNames) {
245249
+ const registry = modelDef.givens ?? {};
245250
+ const surfaced = new Set(surfacedGivenNames);
245251
+ const givens = new Map;
245252
+ for (const given of Object.values(registry)) {
245253
+ if (!surfaced.has(given.name))
245254
+ continue;
245255
+ const type = given.type;
245256
+ const isFilter = type.type === "filter expression";
245257
+ givens.set(given.name, {
245258
+ name: given.name,
245259
+ type: isFilter ? `filter<${type.filterType}>` : type.type,
245260
+ default: given.defaultText,
245261
+ annotations: (given.annotations?.blockNotes ?? []).concat(given.annotations?.notes ?? []).map((note) => note.text)
245262
+ });
245263
+ }
245264
+ const isNamedQuery = (obj) => obj.type === "query";
245265
+ const queries = Object.values(modelDef.contents).filter(isNamedQuery).map((query) => ({
245266
+ name: query.as || query.name,
245267
+ annotations: [
245268
+ ...query.annotations?.blockNotes ?? [],
245269
+ ...query.annotations?.notes ?? []
245270
+ ].map((note) => note.text),
245271
+ givens: (query.givenUsage ?? []).map((usage) => registry[usage.id]?.name).filter((name) => name !== undefined)
245272
+ }));
245273
+ const viewGivens = new Map;
245274
+ const sourceFields = new Map;
245275
+ const drills = [];
245276
+ for (const obj of Object.values(modelDef.contents)) {
245277
+ if (!isSourceDef(obj))
245278
+ continue;
245279
+ const sourceName = obj.as || obj.name;
245280
+ const fieldNames = new Set;
245281
+ sourceFields.set(sourceName, fieldNames);
245282
+ const sourceGivens = new Set;
245283
+ collectGivenRefs(obj.filterList, sourceGivens);
245284
+ for (const field of obj.fields) {
245285
+ const fieldName = field.as || field.name;
245286
+ fieldNames.add(fieldName);
245287
+ if (field.type === "turtle") {
245288
+ const refs = new Set(sourceGivens);
245289
+ collectGivenRefs(field.pipeline, refs);
245290
+ viewGivens.set(normalizeTileExpression(`${sourceName} -> ${fieldName}`), Array.from(refs));
245291
+ continue;
245292
+ }
245293
+ const drill = readDrillTag([
245294
+ ...field.annotations?.blockNotes ?? [],
245295
+ ...field.annotations?.notes ?? []
245296
+ ].map((note) => note.text));
245297
+ if (drill) {
245298
+ drills.push({ ...drill, source: sourceName, dimension: fieldName });
245299
+ }
245300
+ }
245301
+ }
245302
+ return {
245303
+ modelPath,
245304
+ modelAnnotations: ownModelNotes(modelDef),
245305
+ queries,
245306
+ givens,
245307
+ viewGivens,
245308
+ sourceFields,
245309
+ drills
245310
+ };
245311
+ }
245312
+ function readDrillTag(annotations) {
245313
+ const drill = motlyTag(annotations)?.tag("drill");
245314
+ if (!drill)
245315
+ return;
245316
+ const to = tagTextArray(drill, "to") ?? [tagText(drill, "to")].filter(isString2);
245317
+ const given = tagText(drill, "given");
245318
+ return given === undefined ? { to } : { to, given };
245319
+ }
245320
+ function isString2(value) {
245321
+ return value !== undefined;
245322
+ }
245323
+ function tagTextArray(tag, ...path5) {
245324
+ const items = tag?.array(...path5);
245325
+ return items?.map((item) => tagText(item)).filter(isString2);
245326
+ }
245327
+ function resolveTileGivens(tile, facts) {
245328
+ return referencedTileGivens(tile, facts)?.filter((name) => facts.givens.has(name));
245329
+ }
245330
+ function referencedTileGivens(tile, facts) {
245331
+ const normalized = normalizeTileExpression(tile);
245332
+ return facts.viewGivens.get(normalized) ?? facts.queries.find((query) => query.name === normalized)?.givens;
245333
+ }
245334
+ function buildGivenSpecs(names, declarations) {
245335
+ const specs = [];
245336
+ for (const name of new Set(names)) {
245337
+ const declaration = declarations.get(name);
245338
+ if (!declaration)
245339
+ continue;
245340
+ specs.push(givenSpec(declaration));
245341
+ }
245342
+ return specs;
245343
+ }
245344
+ function givenSpec(declaration) {
245345
+ return {
245346
+ name: declaration.name,
245347
+ type: declaration.type,
245348
+ default: declaration.type.startsWith("filter<") && declaration.default !== undefined ? unwrapFilterLiteral(declaration.default) : declaration.default,
245349
+ annotations: declaration.annotations.filter((text) => /^##?\(/.test(text)),
245350
+ ...readGivenControlSpec(declaration.annotations)
245351
+ };
245352
+ }
245353
+ function factsCarryArtifactTag(facts) {
245354
+ if (motlyTag(facts.modelAnnotations)?.tag("artifact"))
245355
+ return true;
245356
+ return facts.queries.some((query) => Boolean(motlyTag(query.annotations)?.tag("artifact")));
245357
+ }
245358
+ function buildDashboardManifest(facts) {
245359
+ const name = dashboardSlug(facts.modelPath);
245360
+ const base = {
245361
+ name,
245362
+ entryFile: facts.modelPath
245363
+ };
245364
+ const modelTag = motlyTag(facts.modelAnnotations);
245365
+ const declaredType = (name2) => facts.givens.get(name2)?.type;
245366
+ const composite = modelTag ? readArtifactTag(modelTag, declaredType) : undefined;
245367
+ if (composite?.tiles?.length) {
245368
+ const tiles = composite.tiles.map((query) => {
245369
+ const givenNames = resolveTileGivens(query, facts);
245370
+ return givenNames ? { query, givenNames } : { query };
245371
+ });
245372
+ const doc = docCommentTitleAndDescription(facts.modelAnnotations, composite.title);
245373
+ return {
245374
+ ...base,
245375
+ title: doc.title ?? name,
245376
+ description: doc.description,
245377
+ tiles,
245378
+ dashboardColumns: composite.dashboardColumns,
245379
+ startingGivens: composite.givens,
245380
+ autorun: composite.autorun,
245381
+ givens: buildGivenSpecs(tiles.some((tile) => tile.givenNames === undefined) ? Array.from(facts.givens.keys()) : tiles.flatMap((tile) => tile.givenNames ?? []), facts.givens)
245382
+ };
245383
+ }
245384
+ for (const query of facts.queries) {
245385
+ const tag = motlyTag(query.annotations);
245386
+ const artifact = tag ? readArtifactTag(tag, declaredType) : undefined;
245387
+ if (!artifact)
245388
+ continue;
245389
+ const doc = docCommentTitleAndDescription(query.annotations, artifact.title);
245390
+ return {
245391
+ ...base,
245392
+ title: doc.title ?? name,
245393
+ description: doc.description,
245394
+ query: query.name,
245395
+ dashboardColumns: artifact.dashboardColumns,
245396
+ startingGivens: artifact.givens,
245397
+ autorun: artifact.autorun,
245398
+ givens: buildGivenSpecs(query.givens, facts.givens)
245399
+ };
245400
+ }
245401
+ return;
245402
+ }
245403
+ function plainTileParts(tile) {
245404
+ const piped = /^(\w+)\s*->\s*(\w+)$/.exec(tile.trim());
245405
+ if (piped)
245406
+ return { source: piped[1], view: piped[2] };
245407
+ const bare = /^\w+$/.exec(tile.trim());
245408
+ return bare ? { name: tile.trim() } : undefined;
245409
+ }
245410
+ function lintDashboard(facts, manifest) {
245411
+ const findings = [];
245412
+ const slug = manifest.name;
245413
+ const add = (message, severity = "warn") => findings.push({ subject: slug, message, severity });
245414
+ const ownAnnotations = manifest.tiles ? facts.modelAnnotations : facts.queries.find((query) => query.name === manifest.query)?.annotations ?? [];
245415
+ const ownTag = motlyTag(ownAnnotations);
245416
+ const artifactTag = ownTag?.tag("artifact");
245417
+ const lintTargets = new Set([...facts.modelAnnotations, ...ownAnnotations]);
245418
+ for (const message of new Set(motlyParseErrors([...lintTargets]))) {
245419
+ add(`Annotation ${describeParseFailure(message)}, so that line had no ` + `effect on this dashboard.`);
245420
+ }
245421
+ for (const [tag, key, written] of [
245422
+ [artifactTag, "dashboard_columns", "dashboard_columns"],
245423
+ [ownTag?.tag("dashboard"), "columns", "# dashboard { columns=… }"]
245424
+ ]) {
245425
+ if (!tag?.has(key))
245426
+ continue;
245427
+ if (positiveInteger(tagNumeric(tag, key)) !== undefined)
245428
+ continue;
245429
+ const raw = tagText(tag, key);
245430
+ add(`${written} must be a positive integer, got ` + `${raw === undefined ? "a value that could not be read" : JSON.stringify(raw)}. ` + (manifest.dashboardColumns === undefined ? `The grid falls back to the renderer default.` : `The grid uses ${manifest.dashboardColumns} instead.`));
245431
+ }
245432
+ for (const { query: tile } of manifest.tiles ?? []) {
245433
+ const parts = plainTileParts(tile);
245434
+ if (!parts)
245435
+ continue;
245436
+ const resolved = referencedTileGivens(tile, facts) !== undefined || "name" in parts && facts.queries.some((query) => query.name === parts.name);
245437
+ if (!resolved) {
245438
+ const detail = "source" in parts ? facts.sourceFields.has(parts.source) ? `source "${parts.source}" has no view "${parts.view}"` : `no source "${parts.source}" in this file` : `no query named "${parts.name}" in this file`;
245439
+ add(`tile "${tile}" does not resolve: ${detail}.`, "error");
245440
+ }
245441
+ const unbindable = (referencedTileGivens(tile, facts) ?? []).filter((name) => !facts.givens.has(name));
245442
+ for (const name of unbindable) {
245443
+ add(`tile "${tile}" filters by given "${name}", which this file does ` + `not import, so no control is shown for it and it stays at its ` + `default. Add it to an import in ${facts.modelPath}.`);
245444
+ }
245445
+ }
245446
+ if (manifest.query !== undefined) {
245447
+ const query = facts.queries.find((q) => q.name === manifest.query);
245448
+ for (const name of query?.givens ?? []) {
245449
+ if (facts.givens.has(name))
245450
+ continue;
245451
+ add(`query "${manifest.query}" filters by given "${name}", which this ` + `file does not import, so no control is shown for it and it ` + `stays at its default. Add it to an import in ` + `${facts.modelPath}.`);
245452
+ }
245453
+ }
245454
+ for (const name of Object.keys(manifest.startingGivens ?? {})) {
245455
+ if (facts.givens.has(name))
245456
+ continue;
245457
+ add(`'artifact { givens { ${name}=… } }' sets a starting value for given ` + `"${name}", which this file does not import, so the value is ` + `dropped at query time and the dashboard opens at the ` + `declaration's default. Add it to an import in ${facts.modelPath}.`);
245458
+ }
245459
+ for (const spec of manifest.givens) {
245460
+ const suggest = spec.suggest;
245461
+ if (!suggest) {
245462
+ const declared = motlyTag(facts.givens.get(spec.name)?.annotations ?? [])?.tag("suggest");
245463
+ if (declared) {
245464
+ const has = (key) => tagText(declared, key) !== undefined;
245465
+ const missing = has("dimension") ? `it names a dimension but no query= or source= to read it from` : has("source") ? `source= alone names no column to read options from` : `it names neither a query= nor a source=`;
245466
+ add(`given "${spec.name}" declares a suggest that cannot fetch ` + `options: ${missing}. Use query=, source= with dimension=, ` + `or query= with dimension=. No control options are offered ` + `for it.`, "error");
245467
+ }
245468
+ continue;
245469
+ }
245470
+ if (suggest.query !== undefined && !facts.queries.some((query) => query.name === suggest.query)) {
245471
+ add(`given "${spec.name}" suggests options from query ` + `"${suggest.query}", which this file does not define.`, "error");
245472
+ }
245473
+ if (suggest.source !== undefined) {
245474
+ const fields = facts.sourceFields.get(suggest.source);
245475
+ if (!fields) {
245476
+ add(`given "${spec.name}" suggests options from source ` + `"${suggest.source}", which this file does not define.`, "error");
245477
+ } else if (suggest.dimension !== undefined && !fields.has(suggest.dimension)) {
245478
+ add(`given "${spec.name}" suggests options from ` + `"${suggest.source} -> ${suggest.dimension}", but that ` + `source has no field "${suggest.dimension}".`, "error");
245479
+ }
245480
+ }
245481
+ }
245482
+ return findings;
245483
+ }
245484
+ function describeParseFailure(message) {
245485
+ if (message === UNSAFE_TO_PARSE) {
245486
+ return `was refused rather than parsed (${message}), which can be caused by something outside this file`;
245487
+ }
245488
+ if (message === ENV_REFERENCE_DROPPED) {
245489
+ return `was dropped rather than parsed (${message}), so nothing on that line took effect`;
245490
+ }
245491
+ if (message === ANNOTATION_TOO_LONG) {
245492
+ return `was refused rather than parsed (${message})`;
245493
+ }
245494
+ return `does not parse (${message})`;
245495
+ }
245496
+ function lintUndiscoveredDashboard(facts) {
245497
+ const subject = dashboardSlug(facts.modelPath);
245498
+ const messages = [
245499
+ ...motlyParseErrors(facts.modelAnnotations),
245500
+ ...facts.queries.flatMap((query) => motlyParseErrors(query.annotations))
245501
+ ].map((message) => `Tag ${describeParseFailure(message)}, so the whole tag is discarded ` + `and this file is treated as a shared include rather than a dashboard.`);
245502
+ const findings = Array.from(new Set(messages), (message) => ({
245503
+ subject,
245504
+ message,
245505
+ severity: "error"
245506
+ }));
245507
+ if (findings.length > 0)
245508
+ return findings;
245509
+ const modelArtifact = motlyTag(facts.modelAnnotations)?.tag("artifact");
245510
+ if (modelArtifact) {
245511
+ const tiles = modelArtifact.array("tiles");
245512
+ findings.push({
245513
+ subject,
245514
+ message: (tiles === undefined ? `Model-level '## artifact' declares no tiles=, ` : `Model-level '## artifact' declares an empty tiles=, `) + `so this file produces no dashboard. A composite dashboard needs ` + `tiles=["source -> view", …]; for a single-query dashboard put ` + `the '# artifact' tag on the query instead.`,
245515
+ severity: "error"
245516
+ });
245517
+ }
245518
+ return findings;
245519
+ }
245520
+ function lintGivenTags(facts) {
245521
+ const findings = new Map;
245522
+ for (const file of facts) {
245523
+ for (const [name, declaration] of file.givens) {
245524
+ for (const message of motlyParseErrors(declaration.annotations)) {
245525
+ findings.set(`${name}|${message}`, {
245526
+ subject: name,
245527
+ message: `given "${name}" has an annotation that ` + `${describeParseFailure(message)}, so the whole line is ` + `discarded and the given ` + `loses any label, control, range or suggest it declared. ` + `It still accepts values; only its presentation is lost.`,
245528
+ severity: "error"
245529
+ });
245530
+ }
245531
+ }
245532
+ }
245533
+ return Array.from(findings.values());
245534
+ }
245535
+ function lintDrillTargets(facts, knownSlugs) {
245536
+ const findings = new Map;
245537
+ for (const file of facts) {
245538
+ for (const drill of file.drills) {
245539
+ const where = `${drill.source}.${drill.dimension}`;
245540
+ if (drill.to.length === 0) {
245541
+ findings.set(where, {
245542
+ subject: where,
245543
+ message: `# drill on ${where} names no destination (to=…).`,
245544
+ severity: "error"
245545
+ });
245546
+ continue;
245547
+ }
245548
+ for (const destination of drill.to) {
245549
+ if (destination === "self" || knownSlugs.has(destination))
245550
+ continue;
245551
+ findings.set(`${where}|${destination}`, {
245552
+ subject: where,
245553
+ message: `# drill on ${where} targets "${destination}", which is not ` + `a dashboard in this package.`,
245554
+ severity: "error"
245555
+ });
245556
+ }
245557
+ }
245558
+ }
245559
+ return Array.from(findings.values());
245560
+ }
245561
+ function drillGivenName(drill) {
245562
+ return drill.given ?? drill.dimension;
245563
+ }
245564
+ function lintSelfDrills(facts) {
245565
+ const surfaced = new Set;
245566
+ for (const file of facts) {
245567
+ for (const name of file.givens.keys())
245568
+ surfaced.add(name.toLowerCase());
245569
+ }
245570
+ const findings = new Map;
245571
+ for (const file of facts) {
245572
+ for (const drill of file.drills) {
245573
+ if (!drill.to.includes("self"))
245574
+ continue;
245575
+ const given = drillGivenName(drill);
245576
+ if (surfaced.has(given.toLowerCase()))
245577
+ continue;
245578
+ const where = `${drill.source}.${drill.dimension}`;
245579
+ findings.set(where, {
245580
+ subject: where,
245581
+ message: `# drill on ${where} has to=self, but no model in this ` + `package declares a given "${given}" for the clicked value to ` + `filter on, so the drill cannot fire anywhere. Declare it, or ` + `point the tag at a given that exists with given=.`,
245582
+ severity: "error"
245583
+ });
245584
+ }
245585
+ }
245586
+ return Array.from(findings.values());
245587
+ }
245588
+ var DASHBOARDS_DIR = "dashboards", COMPONENT_FILE_SUFFIXES, MODEL_FILE_SUFFIX2 = ".malloy", PUBLISHER_OWNED_TAGS, UNKNOWN_RENDER_TAG;
245589
+ var init_dashboard = __esm(() => {
245590
+ init_annotations();
245591
+ init_given();
245592
+ init_motly();
245593
+ COMPONENT_FILE_SUFFIXES = [".jsx", ".tsx"];
245594
+ PUBLISHER_OWNED_TAGS = ["artifact", "drill"];
245595
+ UNKNOWN_RENDER_TAG = /^Unknown render tag '([^']+)'/;
245596
+ });
245597
+
244757
245598
  // ../../node_modules/pump/index.js
244758
245599
  var require_pump = __commonJS((exports, module) => {
244759
245600
  var once = require_once2();
@@ -248056,79 +248897,6 @@ function filterFreshManifest(entries, now) {
248056
248897
  return { manifest, nextStaleSince };
248057
248898
  }
248058
248899
 
248059
- // src/service/annotations.ts
248060
- import { Annotations } from "@malloydata/malloy";
248061
- function isReservedRoute(route) {
248062
- return route === "" || !/[\p{L}\p{N}]/u.test(route);
248063
- }
248064
- function ownModelAnnotations(modelDef) {
248065
- return foldModelAnnotations(modelDef, (id) => id === modelDef.modelID || id.startsWith("internal://"));
248066
- }
248067
- function modelAnnotations(modelDef) {
248068
- return foldModelAnnotations(modelDef, () => true);
248069
- }
248070
- function foldModelAnnotations(modelDef, admits) {
248071
- const registry = modelDef.modelAnnotations ?? {};
248072
- const visited = new Set;
248073
- const order = [];
248074
- const visit = (id) => {
248075
- if (!admits(id))
248076
- return;
248077
- if (visited.has(id))
248078
- return;
248079
- visited.add(id);
248080
- const entry = registry[id];
248081
- if (!entry)
248082
- return;
248083
- for (const dep of entry.inheritsFrom)
248084
- visit(dep);
248085
- order.push(id);
248086
- };
248087
- visit(modelDef.modelID);
248088
- let folded;
248089
- for (const id of order) {
248090
- const own = registry[id].ownNotes;
248091
- if (!own.notes?.length && !own.blockNotes?.length)
248092
- continue;
248093
- folded = {
248094
- notes: own.notes,
248095
- blockNotes: own.blockNotes,
248096
- inherits: folded
248097
- };
248098
- }
248099
- return folded ?? {};
248100
- }
248101
- function ownModelNotes(modelDef) {
248102
- const registry = modelDef.modelAnnotations ?? {};
248103
- const isSameDocument = (id) => id === modelDef.modelID || id.startsWith("internal://");
248104
- const seen = new Set;
248105
- const texts = [];
248106
- const visit = (id) => {
248107
- if (seen.has(id) || !isSameDocument(id))
248108
- return;
248109
- seen.add(id);
248110
- const entry = registry[id];
248111
- if (!entry)
248112
- return;
248113
- for (const dep of entry.inheritsFrom)
248114
- visit(dep);
248115
- texts.push(...ownLevelNoteTexts(entry.ownNotes));
248116
- };
248117
- visit(modelDef.modelID);
248118
- return texts;
248119
- }
248120
- function annotationTexts(annote) {
248121
- const texts = new Annotations(annote).texts();
248122
- return texts.length > 0 ? texts : undefined;
248123
- }
248124
- function ownLevelNoteTexts(annote) {
248125
- return ownLevelNotes(annote).map((note) => note.text);
248126
- }
248127
- function ownLevelNotes(annote) {
248128
- return [...annote?.blockNotes ?? [], ...annote?.notes ?? []];
248129
- }
248130
- var init_annotations = () => {};
248131
-
248132
248900
  // src/service/incremental_declaration.ts
248133
248901
  function safeTag(source) {
248134
248902
  try {
@@ -248485,6 +249253,19 @@ function readPreaggregateAnnotation(measure) {
248485
249253
  continue;
248486
249254
  declared = true;
248487
249255
  const grainText = tag.text("preaggregate", "grain") ?? tag.text("grain");
249256
+ const namespaceText = tag.text("preaggregate", "namespace") ?? tag.text("namespace");
249257
+ let namespace;
249258
+ if (namespaceText !== undefined) {
249259
+ const candidate = namespaceText.trim();
249260
+ if (!isSpliceableNamespace(candidate)) {
249261
+ errors2.push({
249262
+ kind: "invalid_namespace",
249263
+ message: `Measure \`${name}\` declares \`#@ preaggregate namespace="${candidate}"\`, which is not a plain namespace. Use letters, digits, underscore, dollar or hyphen per part, dot-separated for a qualified one (\`analytics\`, \`my-project.analytics\`) — the rollup's own table name is generated and appended, so a namespace needing quotes cannot be joined to it.`
249264
+ });
249265
+ continue;
249266
+ }
249267
+ namespace = candidate;
249268
+ }
248488
249269
  if (grainText === undefined) {
248489
249270
  errors2.push({
248490
249271
  kind: "missing_grain",
@@ -248500,7 +249281,11 @@ function readPreaggregateAnnotation(measure) {
248500
249281
  });
248501
249282
  continue;
248502
249283
  }
248503
- grains.set(dimensions.join("\x00"), { dimensions, text: grainText });
249284
+ grains.set(dimensions.join("\x00"), {
249285
+ dimensions,
249286
+ text: grainText,
249287
+ namespace
249288
+ });
248504
249289
  }
248505
249290
  return {
248506
249291
  declared,
@@ -248511,6 +249296,7 @@ function readPreaggregateAnnotation(measure) {
248511
249296
  }
248512
249297
  var NEGATION;
248513
249298
  var init_preaggregation_annotation = __esm(() => {
249299
+ init_preaggregation_synthesis();
248514
249300
  NEGATION = /#@\s*-\s*preaggregate\b/;
248515
249301
  });
248516
249302
 
@@ -248524,7 +249310,7 @@ function walk(node, found) {
248524
249310
  found.aggregates.push({ function: node.function });
248525
249311
  break;
248526
249312
  case "filteredExpr":
248527
- found.filtered = true;
249313
+ found.filtered += 1;
248528
249314
  break;
248529
249315
  case "all":
248530
249316
  case "exclude":
@@ -248577,7 +249363,7 @@ function classifyMeasureAdditivity(measure) {
248577
249363
  }
248578
249364
  const found = {
248579
249365
  aggregates: [],
248580
- filtered: false,
249366
+ filtered: 0,
248581
249367
  ungrouped: false,
248582
249368
  analytic: false
248583
249369
  };
@@ -248588,8 +249374,13 @@ function classifyMeasureAdditivity(measure) {
248588
249374
  if (found.analytic) {
248589
249375
  return nonAdditive("analytic", `Measure \`${name}\` contains a window function, which is computed across result rows rather than within a group, so it cannot be pre-aggregated. Remove \`#@ preaggregate\` from it.`);
248590
249376
  }
248591
- if (found.filtered) {
248592
- return nonAdditive("filtered_aggregate", `Measure \`${name}\` applies a filter to its aggregate, which this version cannot pre-aggregate. Define the aggregate as its own measure and apply the filter in a view, or remove \`#@ preaggregate\` from it.`);
249377
+ let aggregateRoot = measure.e;
249378
+ if (found.filtered > 0) {
249379
+ const root = measure.e;
249380
+ if (found.filtered !== 1 || root.node !== "filteredExpr" || root.kids.e.node !== "aggregate" || !root.kids.filterList.every((c) => c.expressionType === "scalar")) {
249381
+ return nonAdditive("filtered_aggregate", `Measure \`${name}\` applies a filter this version cannot pre-aggregate: a filter is supported only when written directly on the measure's single aggregate, as \`amount.sum() { where: … }\` (several conditions go in ONE \`where:\`, comma-separated, not chained). If the filter sits under a wrapper or refines a derived measure, pre-aggregate the bare filtered aggregate as its own measure and do the rest in a view, or remove \`#@ preaggregate\` from it.`);
249382
+ }
249383
+ aggregateRoot = root.kids.e;
248593
249384
  }
248594
249385
  if (found.aggregates.length === 0) {
248595
249386
  return nonAdditive("no_aggregate_found", `Measure \`${name}\` does not compute a single plain aggregate, and only \`sum\`, \`count\`, \`min\` and \`max\` can be re-aggregated from a stored partial. If it is derived from other measures, pre-aggregate those instead and compute this one in a view.`);
@@ -248597,10 +249388,10 @@ function classifyMeasureAdditivity(measure) {
248597
249388
  if (found.aggregates.length > 1) {
248598
249389
  return nonAdditive("multiple_aggregates", `Measure \`${name}\` combines ${found.aggregates.length} aggregates, and a ratio or difference of aggregates cannot be re-aggregated from a single stored column. Pre-aggregate each aggregate as its own measure and combine them in a view.`);
248599
249390
  }
248600
- if (measure.e.node !== "aggregate") {
249391
+ if (aggregateRoot.node !== "aggregate") {
248601
249392
  return nonAdditive("aggregate_not_at_root", `Measure \`${name}\` wraps its aggregate in a further expression, so re-aggregating a stored partial would not reproduce it. Pre-aggregate the bare aggregate as its own measure and do the rest in a view.`);
248602
249393
  }
248603
- const fn = measure.e.function;
249394
+ const fn = aggregateRoot.function;
248604
249395
  if (!isAdditiveAggregate(fn)) {
248605
249396
  const hint = fn === "avg" ? " Pre-aggregate a `sum` and a `count` instead, and divide them in a view." : fn === "distinct" ? " A distinct count cannot be merged from per-group partials." : "";
248606
249397
  return nonAdditive("unsupported_aggregate", `Measure \`${name}\` uses \`${fn}\`, which cannot be re-aggregated from a stored partial; only \`sum\`, \`count\`, \`min\` and \`max\` can.${hint}`);
@@ -248618,6 +249409,7 @@ var init_preaggregation_classifier = __esm(() => {
248618
249409
  });
248619
249410
 
248620
249411
  // src/service/preaggregation_synthesis.ts
249412
+ import { Annotations as Annotations3 } from "@malloydata/malloy";
248621
249413
  import { createHash as createHash2 } from "node:crypto";
248622
249414
  function grainDigest(grainDimensions) {
248623
249415
  return createHash2("sha256").update(grainDimensions.join("\x00")).digest("hex").slice(0, 8);
@@ -248626,10 +249418,40 @@ function rollupSourceName(baseSourceName, grainDimensions) {
248626
249418
  const slug = grainDimensions.join("_").slice(0, NAME_SLUG_LIMIT);
248627
249419
  return `${baseSourceName}__preagg__${slug}__${grainDigest(grainDimensions)}`;
248628
249420
  }
249421
+ function isSpliceableNamespace(namespace) {
249422
+ return namespace.split(".").every((s) => NAMESPACE_SEGMENT.test(s));
249423
+ }
248629
249424
  function baseAlias(baseSourceName) {
248630
249425
  return `${baseSourceName}${BASE_ALIAS_SUFFIX}`;
248631
249426
  }
249427
+ function persistNamespace(persistName) {
249428
+ if (!persistName)
249429
+ return;
249430
+ const lastDot = persistName.lastIndexOf(".");
249431
+ if (lastDot <= 0)
249432
+ return;
249433
+ if (persistName.slice(lastDot + 1).trim() === "")
249434
+ return;
249435
+ const candidate = persistName.slice(0, lastDot);
249436
+ return isSpliceableNamespace(candidate) ? candidate : undefined;
249437
+ }
249438
+ function basePersistNamespace(source) {
249439
+ if (!source.annotations)
249440
+ return;
249441
+ try {
249442
+ const tag = new Annotations3(source.annotations).parseAsTag("@").tag;
249443
+ if (!tag.has("persist"))
249444
+ return;
249445
+ const storage = tag.text("persist", "storage") ?? tag.text("storage");
249446
+ if (storage !== undefined && storage.trim() !== "")
249447
+ return;
249448
+ return persistNamespace(tag.text("persist", "name") ?? tag.text("name"));
249449
+ } catch {
249450
+ return;
249451
+ }
249452
+ }
248632
249453
  function planSourcePreaggregation(baseSourceName, source) {
249454
+ const inheritedNamespace = basePersistNamespace(source);
248633
249455
  const byGrain = new Map;
248634
249456
  for (const field of source.fields ?? []) {
248635
249457
  const declaration = readPreaggregateAnnotation(field);
@@ -248650,12 +249472,14 @@ function planSourcePreaggregation(baseSourceName, source) {
248650
249472
  partialName: `${name}${PARTIAL_SUFFIX}`,
248651
249473
  reaggregate: additivity.reaggregate
248652
249474
  });
249475
+ entry.namespace ??= grain.namespace;
248653
249476
  byGrain.set(key, entry);
248654
249477
  }
248655
249478
  }
248656
- return [...byGrain.values()].map(({ grainDimensions, measures }) => ({
249479
+ return [...byGrain.values()].map(({ grainDimensions, measures, namespace }) => ({
248657
249480
  baseSourceName,
248658
249481
  rollupSourceName: rollupSourceName(baseSourceName, grainDimensions),
249482
+ namespace: namespace ?? inheritedNamespace,
248659
249483
  grainDimensions,
248660
249484
  measures: [...measures].sort((a, b) => a.name.localeCompare(b.name))
248661
249485
  })).sort((a, b) => a.rollupSourceName.localeCompare(b.rollupSourceName));
@@ -248673,7 +249497,8 @@ function emitRollup(plan) {
248673
249497
  `);
248674
249498
  const merged = plan.measures.map((m) => ` ${m.name} is ${m.partialName}.${m.reaggregate}()`).join(`
248675
249499
  `);
248676
- return `#@ persist
249500
+ const persist = plan.namespace ? `#@ persist name="${plan.namespace}.${plan.rollupSourceName}"` : "#@ persist";
249501
+ return `${persist}
248677
249502
  source: ${plan.rollupSourceName} is ${alias} -> {
248678
249503
  group_by:
248679
249504
  ${plan.grainDimensions.map((d) => ` ${d}`).join(`
@@ -248706,10 +249531,11 @@ ${plans.map(emitRollup).join(`
248706
249531
  ${composites}
248707
249532
  `;
248708
249533
  }
248709
- var BASE_ALIAS_SUFFIX = "__preagg_base", PARTIAL_SUFFIX = "__partial", NAME_SLUG_LIMIT = 40;
249534
+ var BASE_ALIAS_SUFFIX = "__preagg_base", PARTIAL_SUFFIX = "__partial", NAME_SLUG_LIMIT = 40, NAMESPACE_SEGMENT;
248710
249535
  var init_preaggregation_synthesis = __esm(() => {
248711
249536
  init_preaggregation_annotation();
248712
249537
  init_preaggregation_classifier();
249538
+ NAMESPACE_SEGMENT = /^[A-Za-z_][A-Za-z0-9_$-]*$/;
248713
249539
  });
248714
249540
 
248715
249541
  // src/service/preaggregation_compile.ts
@@ -248758,7 +249584,7 @@ var init_preaggregation_compile = __esm(() => {
248758
249584
  });
248759
249585
 
248760
249586
  // src/service/build_plan.ts
248761
- import { Annotations as Annotations3 } from "@malloydata/malloy";
249587
+ import { Annotations as Annotations4 } from "@malloydata/malloy";
248762
249588
  function deriveColumns(persistSource) {
248763
249589
  try {
248764
249590
  return persistSource._explore.intrinsicFields.filter((f) => f.isAtomicField()).map((f) => ({
@@ -248957,7 +249783,7 @@ function detectDroppedPersistSources(malloyModel, recognizedNames) {
248957
249783
  continue;
248958
249784
  let isPersist = false;
248959
249785
  try {
248960
- isPersist = new Annotations3(def.annotations).parseAsTag("@").tag.has("persist");
249786
+ isPersist = new Annotations4(def.annotations).parseAsTag("@").tag.has("persist");
248961
249787
  } catch {
248962
249788
  continue;
248963
249789
  }
@@ -249335,21 +250161,6 @@ var init_filter = __esm(() => {
249335
250161
  };
249336
250162
  });
249337
250163
 
249338
- // src/service/given.ts
249339
- function malloyGivenToApi(given) {
249340
- const type = given.type;
249341
- const renderedType = type.type === "filter expression" ? `filter<${type.filterType}>` : type.type;
249342
- return {
249343
- name: given.name,
249344
- type: renderedType,
249345
- annotations: given.annotations.forRoute(undefined).filter((note) => !isReservedRoute(note.route)).map((note) => note.text),
249346
- default: given._internal?.defaultText
249347
- };
249348
- }
249349
- var init_given = __esm(() => {
249350
- init_annotations();
249351
- });
249352
-
249353
250164
  // src/service/model_limits.ts
249354
250165
  function resolveModelQueryRowLimit(userLimit, { defaultLimit, maxRows }) {
249355
250166
  const requested = userLimit && userLimit > 0 ? userLimit : defaultLimit;
@@ -249497,6 +250308,8 @@ function validateSourcePreaggregation(sourceName, source) {
249497
250308
  });
249498
250309
  }
249499
250310
  const declaredFields = [];
250311
+ const namespacesByGrain = new Map;
250312
+ const grainTextByKey = new Map;
249500
250313
  for (const field of fields) {
249501
250314
  const name = fieldName(field);
249502
250315
  const declaration = readPreaggregateAnnotation(field);
@@ -249556,8 +250369,26 @@ function validateSourcePreaggregation(sourceName, source) {
249556
250369
  if (violation)
249557
250370
  violations.push(violation);
249558
250371
  }
250372
+ if (grain.namespace !== undefined && additivity.additive) {
250373
+ const key = grain.dimensions.join("\x00");
250374
+ const named = namespacesByGrain.get(key) ?? new Map;
250375
+ if (!named.has(grain.namespace))
250376
+ named.set(grain.namespace, name);
250377
+ namespacesByGrain.set(key, named);
250378
+ grainTextByKey.set(key, grain.text);
250379
+ }
249559
250380
  }
249560
250381
  }
250382
+ for (const [key, named] of namespacesByGrain) {
250383
+ if (named.size < 2)
250384
+ continue;
250385
+ const choices = [...named.entries()].map(([ns, measure]) => `\`${ns}\` (on \`${measure}\`)`).join(", ");
250386
+ violations.push({
250387
+ code: "conflicting_namespace",
250388
+ sourceName,
250389
+ message: `Measures on \`${sourceName}\` declare \`#@ preaggregate\` at the grain \`${grainTextByKey.get(key)}\` with different namespaces: ${choices}. One grain is one rollup table, so it can only be created in one of them. Give every measure at this grain the same \`namespace=\`, or move one to a different grain.`
250390
+ });
250391
+ }
249561
250392
  const fanout = declaredFields.length > 0 ? fields.filter(isFanoutJoin) : [];
249562
250393
  if (fanout.length > 0) {
249563
250394
  const joins = fanout.map((f) => `\`${fieldName(f)}\``).join(", ");
@@ -249586,7 +250417,7 @@ var init_preaggregation_validation = __esm(() => {
249586
250417
  });
249587
250418
 
249588
250419
  // src/service/gate_registry_walk.ts
249589
- import { isSourceDef } from "@malloydata/malloy";
250420
+ import { isSourceDef as isSourceDef2 } from "@malloydata/malloy";
249590
250421
  function resolveDeclaredSource(struct, modelDef) {
249591
250422
  if (!modelDef)
249592
250423
  return { kind: "none" };
@@ -249598,7 +250429,7 @@ function resolveDeclaredSource(struct, modelDef) {
249598
250429
  const declared = entry.type === "source_registry_reference" ? modelDef.contents[entry.name] : entry;
249599
250430
  if (declared === struct)
249600
250431
  continue;
249601
- if (!declared || !isSourceDef(declared)) {
250432
+ if (!declared || !isSourceDef2(declared)) {
249602
250433
  sawBrokenEntry = true;
249603
250434
  continue;
249604
250435
  }
@@ -249633,7 +250464,7 @@ function resolveQuerySourceBase(struct, modelDef) {
249633
250464
  return;
249634
250465
  const ref = duck.query?.structRef;
249635
250466
  const base = typeof ref === "string" ? modelDef?.contents[ref] : ref;
249636
- return base && isSourceDef(base) ? base : undefined;
250467
+ return base && isSourceDef2(base) ? base : undefined;
249637
250468
  }
249638
250469
  function resolveCompositeResolvedBase(struct) {
249639
250470
  const duck = struct;
@@ -249693,7 +250524,7 @@ var init_gate_registry_walk = __esm(() => {
249693
250524
  // src/service/source_extraction.ts
249694
250525
  import {
249695
250526
  isJoined as isJoined2,
249696
- isSourceDef as isSourceDef2
250527
+ isSourceDef as isSourceDef3
249697
250528
  } from "@malloydata/malloy";
249698
250529
  function joinFieldNamesUnresolvableDeclaration(field, modelDef) {
249699
250530
  const ids = [field.referenceID, field.sourceID].filter((id) => !!id);
@@ -249704,7 +250535,7 @@ function joinFieldNamesUnresolvableDeclaration(field, modelDef) {
249704
250535
  if (!entry)
249705
250536
  continue;
249706
250537
  const declared = entry.type === "source_registry_reference" ? modelDef.contents[entry.name] : entry;
249707
- if (declared && isSourceDef2(declared))
250538
+ if (declared && isSourceDef3(declared))
249708
250539
  return false;
249709
250540
  }
249710
250541
  return true;
@@ -249717,7 +250548,7 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
249717
250548
  const nearMissAuthorize = [];
249718
250549
  const sweptStructs = [];
249719
250550
  for (const obj of Object.values(modelDef.contents)) {
249720
- if (!isSourceDef2(obj))
250551
+ if (!isSourceDef3(obj))
249721
250552
  continue;
249722
250553
  const struct = obj;
249723
250554
  sweptStructs.push(obj);
@@ -249735,7 +250566,7 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
249735
250566
  const entry = value.entry;
249736
250567
  if (entry.type === "source_registry_reference")
249737
250568
  continue;
249738
- if (!isSourceDef2(entry))
250569
+ if (!isSourceDef3(entry))
249739
250570
  continue;
249740
250571
  sweptStructs.push(entry);
249741
250572
  for (const note of ownLevelNotes(entry.annotations)) {
@@ -249760,7 +250591,7 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
249760
250591
  if (containsAuthorizeAnnotationTag((modelAnnotations(modelDef).notes ?? []).map((note) => note.text))) {
249761
250592
  misplacedAuthorize.push({ kind: "file" });
249762
250593
  }
249763
- const sources = Object.values(modelDef.contents).filter((obj) => isSourceDef2(obj)).map((sourceObj) => {
250594
+ const sources = Object.values(modelDef.contents).filter((obj) => isSourceDef3(obj)).map((sourceObj) => {
249764
250595
  const struct = sourceObj;
249765
250596
  const sourceName = struct.as || struct.name;
249766
250597
  const annotations = annotationTexts(struct.annotations);
@@ -249818,7 +250649,7 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
249818
250649
  continue;
249819
250650
  }
249820
250651
  const fieldName2 = field.as || field.name;
249821
- if (isJoined2(field) && isSourceDef2(field)) {
250652
+ if (isJoined2(field) && isSourceDef3(field)) {
249822
250653
  const joinedStruct = field;
249823
250654
  if (joinFieldNamesUnresolvableDeclaration(joinedStruct, modelDef)) {
249824
250655
  continue;
@@ -249879,10 +250710,10 @@ import {
249879
250710
  API,
249880
250711
  FixedConnectionMap,
249881
250712
  InMemoryURLReader as InMemoryURLReader2,
249882
- isSourceDef as isSourceDef3,
250713
+ isSourceDef as isSourceDef4,
249883
250714
  MalloyConfig as MalloyConfig2,
249884
250715
  MalloyError as MalloyError2,
249885
- Annotations as Annotations4,
250716
+ Annotations as Annotations5,
249886
250717
  modelDefToModelInfo,
249887
250718
  Runtime as Runtime2
249888
250719
  } from "@malloydata/malloy";
@@ -249970,8 +250801,10 @@ var init_model = __esm(() => {
249970
250801
  init_annotations();
249971
250802
  init_build_plan();
249972
250803
  init_authorize();
250804
+ init_dashboard();
249973
250805
  init_filter();
249974
250806
  init_given();
250807
+ init_motly();
249975
250808
  init_model_limits();
249976
250809
  init_json_utils();
249977
250810
  init_query_metadata();
@@ -250158,7 +250991,7 @@ var init_model = __esm(() => {
250158
250991
  if (!modelDef)
250159
250992
  return byName;
250160
250993
  for (const entry of Object.values(modelDef.contents)) {
250161
- if (!isSourceDef3(entry))
250994
+ if (!isSourceDef4(entry))
250162
250995
  continue;
250163
250996
  const name = entry.as ?? entry.name;
250164
250997
  byName.set(name, this.collectEntryPointGates(entry, modelDef, new Set, true));
@@ -250179,14 +251012,14 @@ var init_model = __esm(() => {
250179
251012
  try {
250180
251013
  const structs = [];
250181
251014
  for (const obj of Object.values(modelDef.contents)) {
250182
- if (isSourceDef3(obj))
251015
+ if (isSourceDef4(obj))
250183
251016
  structs.push(obj);
250184
251017
  }
250185
251018
  for (const value of Object.values(modelDef.sourceRegistry ?? {})) {
250186
251019
  const entry = value.entry;
250187
251020
  if (entry.type === "source_registry_reference")
250188
251021
  continue;
250189
- if (isSourceDef3(entry))
251022
+ if (isSourceDef4(entry))
250190
251023
  structs.push(entry);
250191
251024
  }
250192
251025
  structs.push(...derivedStructsReachable(structs, modelDef));
@@ -250518,7 +251351,7 @@ var init_model = __esm(() => {
250518
251351
  }
250519
251352
  if (struct.sourceID) {
250520
251353
  for (const [key, value] of Object.entries(modelDef.contents)) {
250521
- if (isSourceDef3(value) && value.sourceID === struct.sourceID) {
251354
+ if (isSourceDef4(value) && value.sourceID === struct.sourceID) {
250522
251355
  return key;
250523
251356
  }
250524
251357
  }
@@ -250533,7 +251366,7 @@ var init_model = __esm(() => {
250533
251366
  if (ownNotes.length === 0)
250534
251367
  return;
250535
251368
  for (const value of Object.values(modelDef.contents)) {
250536
- if (!isSourceDef3(value) || value === struct || exclude.has(value)) {
251369
+ if (!isSourceDef4(value) || value === struct || exclude.has(value)) {
250537
251370
  continue;
250538
251371
  }
250539
251372
  const candidateNotes = [
@@ -250576,7 +251409,7 @@ var init_model = __esm(() => {
250576
251409
  const copy = structuredClone(modelDef);
250577
251410
  for (const { graftTarget, condition } of grafts) {
250578
251411
  const target = copy.contents[graftTarget];
250579
- if (!target || !isSourceDef3(target)) {
251412
+ if (!target || !isSourceDef4(target)) {
250580
251413
  throw new Error(`graft target "${graftTarget}" is not a source in this model`);
250581
251414
  }
250582
251415
  target.filterList = [...target.filterList ?? [], condition];
@@ -250605,7 +251438,7 @@ var init_model = __esm(() => {
250605
251438
  if (duck.type === "query_source" && modelDef) {
250606
251439
  const ref = duck.query?.structRef;
250607
251440
  const base = typeof ref === "string" ? modelDef.contents[ref] : ref;
250608
- if (base && isSourceDef3(base)) {
251441
+ if (base && isSourceDef4(base)) {
250609
251442
  return this.filterListContainsCode(base, modelDef, code, depth + 1);
250610
251443
  }
250611
251444
  }
@@ -250820,6 +251653,11 @@ var init_model = __esm(() => {
250820
251653
  getQueries() {
250821
251654
  return this.curateForDiscovery(this.queries);
250822
251655
  }
251656
+ getDashboardModelFacts() {
251657
+ if (!this.modelDef)
251658
+ return;
251659
+ return readDashboardModelFacts(this.modelPath, this.modelDef, (this.givens ?? []).map((given) => given.name).filter((name) => name !== undefined));
251660
+ }
250823
251661
  hasEmptyDiscoverySurface() {
250824
251662
  if (!this.discoveryCurationEnabled)
250825
251663
  return false;
@@ -251005,9 +251843,37 @@ var init_model = __esm(() => {
251005
251843
  throw new ModelNotFoundError(`${this.modelPath} is not a valid model name. Model files must end in .malloy.`);
251006
251844
  }
251007
251845
  }
251008
- getNotebookError() {
251846
+ getCompilationError() {
251009
251847
  return this.compilationError;
251010
251848
  }
251849
+ getNotebookError() {
251850
+ return this.getCompilationError();
251851
+ }
251852
+ getNotebookListing() {
251853
+ if (this.modelType !== "notebook")
251854
+ return {};
251855
+ const annotations = this.modelDef ? ownModelNotes(this.modelDef) : [];
251856
+ const doc = docCommentTitleAndDescription(annotations, tagText(motlyTag(annotations), "title"));
251857
+ return {
251858
+ title: doc.title ?? this.firstMarkdownHeading(),
251859
+ description: doc.description
251860
+ };
251861
+ }
251862
+ firstMarkdownHeading() {
251863
+ for (const cell of this.runnableNotebookCells ?? []) {
251864
+ if (cell.type !== "markdown")
251865
+ continue;
251866
+ for (const line of cell.text.split(`
251867
+ `)) {
251868
+ const trimmed2 = line.trim();
251869
+ if (trimmed2.length === 0)
251870
+ continue;
251871
+ const heading = /^#{1,6}\s+(.+)$/.exec(trimmed2);
251872
+ return heading ? heading[1] : undefined;
251873
+ }
251874
+ }
251875
+ return;
251876
+ }
251011
251877
  async getNotebook() {
251012
251878
  if (this.compilationError) {
251013
251879
  throw this.compilationError;
@@ -251466,7 +252332,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
251466
252332
  if (!this.modelDef)
251467
252333
  return;
251468
252334
  try {
251469
- return new Annotations4(ownModelAnnotations(this.modelDef)).parseAsTag().tag;
252335
+ return new Annotations5(ownModelAnnotations(this.modelDef)).parseAsTag().tag;
251470
252336
  } catch {
251471
252337
  return;
251472
252338
  }
@@ -251476,12 +252342,12 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
251476
252342
  return;
251477
252343
  try {
251478
252344
  const entry = this.modelDef.contents?.[sourceName];
251479
- if (!entry || !isSourceDef3(entry))
252345
+ if (!entry || !isSourceDef4(entry))
251480
252346
  return;
251481
252347
  const def = entry;
251482
252348
  if (!def.annotations)
251483
252349
  return;
251484
- return new Annotations4(def.annotations).parseAsTag("@").tag;
252350
+ return new Annotations5(def.annotations).parseAsTag("@").tag;
251485
252351
  } catch {
251486
252352
  return;
251487
252353
  }
@@ -251514,6 +252380,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
251514
252380
  };
251515
252381
  });
251516
252382
  const allAnnotations = this.modelDef ? ownModelNotes(this.modelDef) : [];
252383
+ const notebookTag = motlyTag(allAnnotations);
251517
252384
  const notebook = {
251518
252385
  type: "notebook",
251519
252386
  packageName: this.packageName,
@@ -251523,6 +252390,8 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
251523
252390
  sources: this.modelDef && this.sources,
251524
252391
  queries: this.modelDef && this.queries,
251525
252392
  annotations: allAnnotations,
252393
+ autorun: readAutorun(notebookTag),
252394
+ startingGivens: readStartingGivens(notebookTag, (name) => (this.givens ?? []).find((g) => g.name === name)?.type),
251526
252395
  notebookCells
251527
252396
  };
251528
252397
  return notebook;
@@ -253933,7 +254802,7 @@ var require_luxon = __commonJS((exports) => {
253933
254802
  return defaultZone2;
253934
254803
  } else if (input instanceof Zone) {
253935
254804
  return input;
253936
- } else if (isString2(input)) {
254805
+ } else if (isString3(input)) {
253937
254806
  const lowered = input.toLowerCase();
253938
254807
  if (lowered === "default")
253939
254808
  return defaultZone2;
@@ -254308,7 +255177,7 @@ var require_luxon = __commonJS((exports) => {
254308
255177
  function isInteger(o) {
254309
255178
  return typeof o === "number" && o % 1 === 0;
254310
255179
  }
254311
- function isString2(o) {
255180
+ function isString3(o) {
254312
255181
  return typeof o === "string";
254313
255182
  }
254314
255183
  function isDate2(o) {
@@ -274205,6 +275074,30 @@ class ConnectionController {
274205
275074
  }
274206
275075
  }
274207
275076
 
275077
+ // src/controller/dashboard.controller.ts
275078
+ init_errors();
275079
+
275080
+ class DashboardController {
275081
+ environmentStore;
275082
+ constructor(environmentStore) {
275083
+ this.environmentStore = environmentStore;
275084
+ }
275085
+ async listDashboards(environmentName, packageName) {
275086
+ const environment = await this.environmentStore.getEnvironment(environmentName, false);
275087
+ const p = await environment.getPackage(packageName, false);
275088
+ return p.listDashboards();
275089
+ }
275090
+ async getDashboard(environmentName, packageName, dashboardName) {
275091
+ const environment = await this.environmentStore.getEnvironment(environmentName, false);
275092
+ const p = await environment.getPackage(packageName, false);
275093
+ const dashboard = p.getDashboard(dashboardName);
275094
+ if (!dashboard) {
275095
+ throw new DashboardNotFoundError(`Dashboard ${dashboardName} does not exist in package ${packageName}`);
275096
+ }
275097
+ return dashboard;
275098
+ }
275099
+ }
275100
+
274208
275101
  // src/controller/database.controller.ts
274209
275102
  class DatabaseController {
274210
275103
  environmentStore;
@@ -274421,6 +275314,8 @@ init_errors();
274421
275314
  init_logger();
274422
275315
  init_query_metadata();
274423
275316
  var import_render_validator = __toESM(require_dist11(), 1);
275317
+ init_dashboard();
275318
+
274424
275319
  class QueryController {
274425
275320
  environmentStore;
274426
275321
  constructor(environmentStore) {
@@ -274471,7 +275366,7 @@ class QueryController {
274471
275366
  }
274472
275367
  }
274473
275368
  }, compactJson ? "compact" : "full", bypassAuthorize), getQueryTimeoutMs());
274474
- const renderLogs = import_render_validator.validateRenderTags(result);
275369
+ const renderLogs = filterPublisherOwnedRenderLogs(import_render_validator.validateRenderTags(result));
274475
275370
  return {
274476
275371
  result: serializedResult,
274477
275372
  resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}/models/${modelPath}/query`,
@@ -283132,6 +284027,7 @@ class CronEvaluator {
283132
284027
  }
283133
284028
 
283134
284029
  // src/service/package.ts
284030
+ init_dashboard();
283135
284031
  init_quoting();
283136
284032
  init_model();
283137
284033
 
@@ -283199,6 +284095,8 @@ class Package {
283199
284095
  sourceEligibility = undefined;
283200
284096
  incrementalPolicySources = [];
283201
284097
  renderTagWarnings = [];
284098
+ dashboardWarnings = [];
284099
+ dashboards = new Map;
283202
284100
  manifestWarnings = [];
283203
284101
  static meter = publisherMeter();
283204
284102
  static packageLoadHistogram = this.meter.createHistogram("malloy_package_load_duration", {
@@ -283405,6 +284303,7 @@ class Package {
283405
284303
  });
283406
284304
  const pkg = new Package(environmentName, packageName, packagePath, packageConfig, databases, models, malloyConfig);
283407
284305
  pkg.renderTagWarnings = renderTagWarnings;
284306
+ await pkg.discoverDashboards();
283408
284307
  pkg.manifestWarnings = outcome.packageMetadata.manifestWarnings ?? [];
283409
284308
  pkg.wireFreshnessResolvers();
283410
284309
  try {
@@ -283543,6 +284442,7 @@ class Package {
283543
284442
  }
283544
284443
  const allWarnings = [
283545
284444
  ...this.renderTagWarnings,
284445
+ ...this.dashboardWarnings,
283546
284446
  ...this.storageWarnings(),
283547
284447
  ...this.droppedPersistWarnings(),
283548
284448
  ...this.emptyDiscoveryWarnings(),
@@ -283954,6 +284854,7 @@ class Package {
283954
284854
  this.packageMetadata.manifestLocation = outcome.packageMetadata.manifestLocation ?? null;
283955
284855
  this.applyDiscoveryPolicyToModels();
283956
284856
  this.applyQueryBoundaryToModels();
284857
+ await this.discoverDashboards();
283957
284858
  this.recordManifestBinding(entries);
283958
284859
  this.wireFreshnessResolvers();
283959
284860
  const invalidMsg = this.formatInvalidExplores();
@@ -283994,15 +284895,271 @@ class Package {
283994
284895
  }));
283995
284896
  return values;
283996
284897
  }
284898
+ isQueryableEntryPoint(modelPath) {
284899
+ if (!this.queryBoundaryActive())
284900
+ return true;
284901
+ const exploreSet = this.exploreSet();
284902
+ return exploreSet ? exploreSet.has(modelPath) : true;
284903
+ }
284904
+ servableEntryPoints() {
284905
+ if (!this.queryBoundaryActive())
284906
+ return () => true;
284907
+ const exploreSet = this.exploreSet();
284908
+ return (modelPath) => exploreSet ? exploreSet.has(modelPath) : true;
284909
+ }
284910
+ queryBoundaryActive() {
284911
+ if (this.packageMetadata.queryableSources === "all")
284912
+ return false;
284913
+ return this.exploresDeclared();
284914
+ }
284915
+ async claimsToBeADashboard(modelPath) {
284916
+ try {
284917
+ const source = await fs7.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
284918
+ return /^[ \t]*##?[ \t]*artifact\b/m.test(source);
284919
+ } catch {
284920
+ return false;
284921
+ }
284922
+ }
284923
+ async discoverDashboards() {
284924
+ const discovered = new Map;
284925
+ const factsByPath = new Map;
284926
+ const allFacts = new Map;
284927
+ const heldBack = [];
284928
+ const unconventionalSlugs = [];
284929
+ const droppedByError = [];
284930
+ const dashboardSlugs = new Set;
284931
+ for (const modelPath of Array.from(this.models.keys()).sort()) {
284932
+ const model = this.models.get(modelPath);
284933
+ if (!model)
284934
+ continue;
284935
+ let facts;
284936
+ try {
284937
+ facts = model.getDashboardModelFacts();
284938
+ if (facts)
284939
+ allFacts.set(modelPath, facts);
284940
+ } catch (err) {
284941
+ logger.warn("Reading a model's dashboard facts failed", {
284942
+ packageName: this.packageName,
284943
+ modelPath,
284944
+ error: errMessage(err)
284945
+ });
284946
+ }
284947
+ if (!isDashboardModelPath(modelPath))
284948
+ continue;
284949
+ const name = dashboardSlug(modelPath);
284950
+ let manifest;
284951
+ if (facts) {
284952
+ try {
284953
+ manifest = buildDashboardManifest(facts);
284954
+ } catch (err) {
284955
+ logger.warn("Dashboard discovery failed", {
284956
+ packageName: this.packageName,
284957
+ modelPath,
284958
+ error: errMessage(err)
284959
+ });
284960
+ if (factsCarryArtifactTag(facts)) {
284961
+ droppedByError.push({ modelPath, name });
284962
+ dashboardSlugs.add(name);
284963
+ }
284964
+ continue;
284965
+ }
284966
+ if (!manifest) {
284967
+ factsByPath.set(modelPath, facts);
284968
+ continue;
284969
+ }
284970
+ } else {
284971
+ const error = model.getCompilationError();
284972
+ if (!error || !await this.claimsToBeADashboard(modelPath)) {
284973
+ if (!error && await this.claimsToBeADashboard(modelPath)) {
284974
+ logger.warn("Dashboard file produced no facts and no error", {
284975
+ packageName: this.packageName,
284976
+ modelPath
284977
+ });
284978
+ droppedByError.push({ modelPath, name });
284979
+ dashboardSlugs.add(name);
284980
+ }
284981
+ continue;
284982
+ }
284983
+ manifest = {
284984
+ name,
284985
+ title: name,
284986
+ autorun: true,
284987
+ entryFile: modelPath,
284988
+ givens: [],
284989
+ error: error.message
284990
+ };
284991
+ }
284992
+ dashboardSlugs.add(name);
284993
+ if (!this.isQueryableEntryPoint(modelPath)) {
284994
+ heldBack.push({ modelPath, name });
284995
+ continue;
284996
+ }
284997
+ if (!matchesDocumentedDashboardName(name)) {
284998
+ unconventionalSlugs.push({ modelPath, name });
284999
+ }
285000
+ discovered.set(name, manifest);
285001
+ if (facts)
285002
+ factsByPath.set(modelPath, facts);
285003
+ }
285004
+ this.dashboards = discovered;
285005
+ this.dashboardWarnings = await this.lintDashboards(factsByPath, allFacts, heldBack, unconventionalSlugs, dashboardSlugs, droppedByError);
285006
+ for (const warning of this.dashboardWarnings) {
285007
+ logger.warn("Dashboard lint", {
285008
+ packageName: this.packageName,
285009
+ model: warning.model,
285010
+ detail: warning.message
285011
+ });
285012
+ }
285013
+ }
285014
+ async lintDashboards(factsByPath, allFacts, heldBack, unconventionalSlugs, knownSlugs, droppedByError) {
285015
+ const warnings = [];
285016
+ const withheldDrills = new Map;
285017
+ try {
285018
+ for (const { modelPath, name } of droppedByError) {
285019
+ warnings.push({
285020
+ model: modelPath,
285021
+ subject: name,
285022
+ message: `"${modelPath}" carries an artifact tag but could not be ` + `read, so it is not served and there is no dashboard ` + `"${name}". The cause is in the server log. Reload the ` + `package to try again.`,
285023
+ severity: "error"
285024
+ });
285025
+ }
285026
+ for (const { modelPath, name } of unconventionalSlugs) {
285027
+ warnings.push({
285028
+ model: modelPath,
285029
+ subject: name,
285030
+ message: `is served, but "${name}" is outside the conventional ` + `dashboard name shape (letters, digits, "-" and "_"). This ` + `server routes it and its published URL carries the name ` + `encoded, so the dashboard works. The convention is worth ` + `keeping anyway: a name outside it has to be percent-encoded ` + `by every caller that builds the URL by hand. Rename the ` + `file if that matters to yours.`,
285031
+ severity: "warn"
285032
+ });
285033
+ }
285034
+ for (const { modelPath, name } of heldBack) {
285035
+ for (const file of allFacts.values()) {
285036
+ for (const drill of file.drills) {
285037
+ if (!drill.to.includes(name))
285038
+ continue;
285039
+ const where = `${drill.source}.${drill.dimension}`;
285040
+ withheldDrills.set(`${where}|${name}`, {
285041
+ subject: where,
285042
+ message: `# drill on ${where} targets "${name}", which IS a ` + `dashboard in this package but is not served (see the ` + `finding on "${modelPath}"), so the click has nowhere ` + `to land.`,
285043
+ severity: "error"
285044
+ });
285045
+ }
285046
+ }
285047
+ warnings.push({
285048
+ model: modelPath,
285049
+ subject: name,
285050
+ message: `is a dashboard, but "${modelPath}" is not listed in ` + `'explores' and this package sets ` + `queryableSources: "declared", so its query would be ` + `refused. It is not served. Add it to 'explores', or set ` + `queryableSources: "all" to keep the curated surface for ` + `discovery only. Listing it is not always sufficient on its ` + `own: the queryable sources are the union of every listed ` + `file's export closure, so a tile reading a source that only ` + `an UNLISTED file exports is still refused. List that file ` + `too, or re-export the source from one already listed.`,
285051
+ severity: "warn"
285052
+ });
285053
+ }
285054
+ for (const [modelPath, facts] of factsByPath) {
285055
+ const manifest = this.dashboards.get(dashboardSlug(modelPath));
285056
+ const findings = manifest ? lintDashboard(facts, manifest) : lintUndiscoveredDashboard(facts);
285057
+ for (const finding of findings) {
285058
+ warnings.push({ model: modelPath, ...finding });
285059
+ }
285060
+ }
285061
+ const drillFacts = Array.from(allFacts.values());
285062
+ for (const finding of withheldDrills.values()) {
285063
+ warnings.push(finding);
285064
+ }
285065
+ for (const finding of lintDrillTargets(drillFacts, knownSlugs)) {
285066
+ warnings.push(finding);
285067
+ }
285068
+ for (const finding of lintSelfDrills(drillFacts)) {
285069
+ warnings.push(finding);
285070
+ }
285071
+ for (const finding of lintGivenTags(drillFacts)) {
285072
+ warnings.push(finding);
285073
+ }
285074
+ for (const finding of await this.unsupportedComponentWarnings()) {
285075
+ warnings.push(finding);
285076
+ }
285077
+ } catch (err) {
285078
+ logger.warn("Dashboard lint failed", {
285079
+ packageName: this.packageName,
285080
+ error: errMessage(err)
285081
+ });
285082
+ warnings.push({
285083
+ message: `Dashboard lint stopped early, so this list is incomplete: an ` + `unknown subset of the curation, dashboard, drill, given and ` + `unsupported-component checks did not run. Treat a missing ` + `finding of any of those kinds as unknown rather than clean, ` + `including the absence of a "held back from the listing" ` + `finding. Reload the package to run the lint again. The ` + `dashboards themselves are unaffected, because they are ` + `discovered before the lint runs. An operator can find the ` + `cause in the server log under "Dashboard lint failed".`,
285084
+ severity: "warn"
285085
+ });
285086
+ }
285087
+ return warnings;
285088
+ }
285089
+ async unsupportedComponentWarnings() {
285090
+ const dir = safeJoinUnderRoot(this.packagePath, DASHBOARDS_DIR);
285091
+ let entries;
285092
+ try {
285093
+ entries = await fs7.readdir(dir);
285094
+ } catch {
285095
+ return [];
285096
+ }
285097
+ const warnings = [];
285098
+ for (const entry of entries.sort()) {
285099
+ const suffix = COMPONENT_FILE_SUFFIXES.find((s) => entry.endsWith(s));
285100
+ if (!suffix)
285101
+ continue;
285102
+ const base = entry.slice(0, -suffix.length);
285103
+ warnings.push({
285104
+ model: `${DASHBOARDS_DIR}/${entry}`,
285105
+ subject: base,
285106
+ message: `Custom dashboard components are not supported, so ` + `"${DASHBOARDS_DIR}/${entry}" is ignored. Any ` + `${DASHBOARDS_DIR}/${base}${MODEL_FILE_SUFFIX} beside it still ` + `renders from its tags. For a page that runs its own code, use ` + `an HTML data app in the package's public/ directory.`,
285107
+ severity: "warn"
285108
+ });
285109
+ }
285110
+ return warnings;
285111
+ }
285112
+ listDashboards() {
285113
+ const servable = this.servableEntryPoints();
285114
+ return Array.from(this.dashboards.values()).filter((manifest) => servable(manifest.entryFile)).map((manifest) => ({
285115
+ resource: this.dashboardResource(manifest.name),
285116
+ packageName: this.packageName,
285117
+ name: manifest.name,
285118
+ path: manifest.entryFile,
285119
+ title: manifest.title,
285120
+ description: manifest.description,
285121
+ error: manifest.error
285122
+ }));
285123
+ }
285124
+ getDashboard(name) {
285125
+ const manifest = this.dashboards.get(name);
285126
+ if (manifest && !this.isQueryableEntryPoint(manifest.entryFile)) {
285127
+ return;
285128
+ }
285129
+ if (!manifest)
285130
+ return;
285131
+ return {
285132
+ resource: this.dashboardResource(manifest.name),
285133
+ packageName: this.packageName,
285134
+ name: manifest.name,
285135
+ path: manifest.entryFile,
285136
+ title: manifest.title,
285137
+ description: manifest.description,
285138
+ error: manifest.error,
285139
+ query: manifest.query,
285140
+ tiles: manifest.tiles,
285141
+ dashboardColumns: manifest.dashboardColumns,
285142
+ startingGivens: manifest.startingGivens,
285143
+ autorun: manifest.autorun,
285144
+ givens: manifest.givens
285145
+ };
285146
+ }
285147
+ dashboardResource(name) {
285148
+ return `${API_PREFIX}/environments/${this.environmentName}/packages/${this.packageName}/dashboards/${encodeURIComponent(name)}`;
285149
+ }
283997
285150
  async listNotebooks() {
283998
285151
  return await Promise.all(Array.from(this.models.keys()).filter((modelPath) => {
283999
285152
  return modelPath.endsWith(NOTEBOOK_FILE_SUFFIX);
284000
285153
  }).map(async (modelPath) => {
284001
- const error = this.models.get(modelPath)?.getNotebookError();
285154
+ const model = this.models.get(modelPath);
285155
+ const error = model?.getNotebookError();
285156
+ const listing = model?.getNotebookListing() ?? {};
284002
285157
  return {
284003
285158
  environmentName: this.environmentName,
284004
285159
  packageName: this.packageName,
284005
285160
  path: modelPath,
285161
+ title: listing.title,
285162
+ description: listing.description,
284006
285163
  error: error?.message
284007
285164
  };
284008
285165
  }));
@@ -290026,6 +291183,7 @@ function registerDocsSearchTool(mcpServer, _environmentStore) {
290026
291183
  // src/mcp/tools/execute_query_tool.ts
290027
291184
  init_config();
290028
291185
  init_logger();
291186
+ init_dashboard();
290029
291187
 
290030
291188
  // src/mcp/query_envelope.ts
290031
291189
  init_json_utils();
@@ -290204,7 +291362,7 @@ function registerExecuteQueryTool(mcpServer, environmentStore) {
290204
291362
  queryCorrelationId
290205
291363
  } = 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());
290206
291364
  const { validateRenderTags: validateRenderTags2 } = await Promise.resolve().then(() => __toESM(require_dist11(), 1));
290207
- const renderLogs = validateRenderTags2(result);
291365
+ const renderLogs = filterPublisherOwnedRenderLogs(validateRenderTags2(result));
290208
291366
  const resultUri = buildMalloyUri({
290209
291367
  environment: environmentName,
290210
291368
  package: packageName,
@@ -291316,6 +292474,7 @@ Every skill in this deployment, by what it is for. Start at a driver; it routes
291316
292474
  | Skill | Use when... |
291317
292475
  |-------|-------------|
291318
292476
  | \`skill:malloy-publish\` | Moving a finished model into a served package (local-to-served handoff) |
292477
+ | \`skill:malloy-dashboards\` | Building a dashboard: a tagged \`.malloy\` file in a package's \`dashboards/\` directory, with filter controls and drill-through |
291319
292478
  | \`skill:malloy-html-data-apps\` | Building an in-package HTML data app (a \`public/\` directory the package serves) |
291320
292479
  | \`skill:malloy-html-data-app-runtime\` | Writing the JavaScript that drives that app |
291321
292480
  | \`skill:malloy-html-data-app-embedding\` | Embedding a served page into a host application |
@@ -291667,7 +292826,408 @@ Use \`+\` to modify existing views: \`run: source -> my_view + { limit: 15, wher
291667
292826
 
291668
292827
  Step complete. Output: analysis \`.malloy\` file with views, insights, and reusable building blocks. For chart/renderer details, see \`skill:malloy-gotchas-rendering\` or call \`search_malloy_docs\`. To formalize into a model, hand off to the modeling skill (\`skill:malloy-model\`).
291669
292828
 
291670
- Publishing is out of scope for now: open-source Publisher serves the model from disk, and self-hosters publish via git plus their host's publish path.` }, { name: "malloy-charts", description: 'Chart selection guidance and renderer reference for Malloy views. Use when choosing visualization types, adding chart annotations, user asks "what chart should I use", "how should I visualize this", or when deciding between bar_chart, line_chart, scatter_chart, etc.', body: '# Chart Selection for Malloy\n\n> Malloy uses Vega-Lite under the hood. `#` tags control visualization. Call `search_malloy_docs` with topic "rendering" for the full tag reference (or see https://docs.malloydata.dev/documentation/visualizations/overview).\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## Decision Tree: Which Chart?\n\n| Data Shape | Default Choice |\n|-----------|---------------|\n| Aggregates only (no group_by) | `# big_value` |\n| 1 time column + 1 measure | `# line_chart` |\n| 1 category + 1 measure | `# bar_chart` |\n| 2 numeric columns | `# scatter_chart` |\n| Geographic (US states) + 1 measure | `# shape_map` |\n| Route data (lat/lon pairs) | `# segment_map` |\n| Multiple perspectives | `# dashboard` with `nest:` |\n| Nested query to pivot | `# pivot` |\n| Filtered aggregates side-by-side | `# flatten` |\n| Detailed rows | Default table (no annotation) |\n\n| Goal | Renderer |\n|------|---------|\n| Compare categories | `# bar_chart` (sort by value, limit ~15) |\n| Show composition | `# bar_chart.stack` |\n| Trend over time | `# line_chart` |\n| Highlight KPIs | `# big_value` with `# label` |\n| Correlation | `# scatter_chart` |\n| Compare dimensions | `# dashboard` (nest chart views) |\n| Before/after | `# transpose` or `# pivot` |\n| Multiple metrics per category | Default table, `# flatten`, or `y=[\'a\',\'b\']` |\n\n**Constraints:**\n- ONE aggregate per chart view (charts render only the first; use `y=[\'a\',\'b\']` for multi-measure)\n- No fixed scale on measure definitions: use `# currency` not `# currency=usd0m`\n- One tag per line\n- Alias joined fields in `group_by` before `order_by`\n- Define measures in source, not in views\n\n\n## Chart Types\n\n### `# bar_chart`\n\n**Data shape:** `group_by` = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# bar_chart\nview: by_carrier is { group_by: carrier, aggregate: flight_count, order_by: flight_count desc, limit: 10 }\n\n# bar_chart.stack\nview: by_region is { group_by: category, region, aggregate: revenue }\n\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: category, aggregate: revenue, cost }\n```\n\n**Key properties:** `.stack`, `.size` (spark/xs/sm/md/lg/xl/2xl), `.x`, `.x.limit`, `.y` (supports `y=[\'a\',\'b\']`), `.series`, `.series.limit` (default 20), `.title`, `.subtitle`, `.x.independent`, `.y.independent`\n\n**Field role tags:** `# x`, `# y`, `# series` on individual fields to assign roles explicitly.\n\n### `# line_chart`\n\n**Data shape:** `group_by` (temporal/numeric) = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# line_chart\nview: trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n\n# line_chart { size=spark }\nview: mini_trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n```\n\n**Key properties:** `.zero_baseline`, `.interpolate` (e.g., `step`), `.size`, `.y` (supports `y=[\'a\',\'b\']`), `.series.limit` (default 12), `.title`, `.subtitle`\n\n### `# scatter_chart`\n\n**Data shape:** Fields by position: x, y, color, size (bubble), shape.\n\n```malloy\n# scatter_chart\nview: correlation is { group_by: customer_id, aggregate: avg_price, total_quantity }\n```\n\n### `# shape_map`\n\nChoropleth. US states only. Fields: state name, value.\n\n```malloy\n# shape_map\nview: by_state is { group_by: state, aggregate: revenue }\n```\n\n### `# segment_map`\n\nRoute map. US only. Fields: start_lat, start_lon, end_lat, end_lon, color.\n\n\n## Layout Types\n\n### `# big_value`\n\nKPI cards. Aggregates only, no `group_by`.\n\n```malloy\n# big_value\nview: summary is {\n aggregate:\n # label="Revenue"\n # currency\n revenue\n # label="Orders"\n # number=auto\n order_count\n}\n```\n\n**Properties:** `.size`, `.sparkline=<nested_view_name>`, `.comparison_field`, `.comparison_label`, `.down_is_good`\n\n### `# dashboard`\n\nCard-based multi-tile layout. Apply to a view whose body is a nested query; the view\'s own fields lay out automatically:\n\n- `group_by` dimensions -> a row header (repeats once per row; omit for a single block)\n- `aggregate` measures -> KPI cards, one per measure\n- each `nest:` -> a tile, rendered by the tag above it (`# table` default, or `# bar_chart` / `# line_chart` / `# big_value`)\n\n**Two modes.** Flex (default): tiles flow and wrap; `# break` forces a new row. Columns: `# dashboard { columns=N }` lays tiles into N equal columns, `# colspan=n` widens a tile, `# break` starts a new row, overflow wraps.\n\n```malloy\n// Flex: measures become KPI cards, the nest becomes a tile\n# dashboard\nview: overview is {\n group_by: category\n # currency\n aggregate:\n avg_retail is retail_price.avg()\n sum_retail is retail_price.sum()\n nest:\n # bar_chart\n by_brand is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 10 }\n}\n\n// Columns: # colspan widens tiles, # break ends a row\n# dashboard { columns=12 }\nview: layout is {\n group_by: category\n # currency\n aggregate:\n # colspan=4\n avg_retail is retail_price.avg()\n # colspan=4\n sum_retail is retail_price.sum()\n # colspan=4\n max_retail is retail_price.max()\n nest:\n # break\n # colspan=6\n # bar_chart\n # subtitle="Top brands"\n by_brand_chart is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 8 }\n # colspan=6\n by_brand_table is { group_by: brand, aggregate: product_count is count(), limit: 8 }\n}\n```\n\n**Tags:** `# dashboard { columns=N }` (columns mode), `{ gap=PX }` (tile spacing, default 16; never a mode), `{ table.max_height=PX|none }` (cap table tiles). On a measure or nest: `# colspan=N` (columns mode only), `# break` (both modes), `# subtitle="..."` (tile), `# borderless` (drop card chrome), `# label="..."` (card title).\n\nFor rich KPI cards (sparklines, comparison deltas, several metrics on one card) nest a `# big_value` view instead of relying on the dashboard\'s own measures:\n\n```malloy\n# dashboard\nview: kpis is {\n group_by: category\n nest:\n # big_value\n revenue_card is {\n aggregate:\n # label="Revenue"\n # currency\n # big_value { sparkline=trend }\n total_revenue is retail_price.sum()\n # line_chart { size=spark y.independent=true }\n # hidden\n nest: trend is { group_by: bucket is floor(id / 100)::number, aggregate: total_revenue is retail_price.sum(), order_by: bucket, limit: 20 }\n }\n}\n```\n\n**Rules:** `# dashboard` needs a nested-query view (no effect on a scalar). `# colspan` works only in columns mode and is ignored (warns) in flex. `columns` is any positive integer; a `# colspan` over the column count clamps to a full row. Style tiles via the instance theme, or theme the views inside with `# theme.*` (see Theming below).\n\n### `# pivot`\n\nPivot nested results into columns. Max 30 pivot columns.\n\n```malloy\nview: sales is {\n group_by: product, aggregate: total\n nest: # pivot\n by_quarter is { group_by: quarter, aggregate: revenue }\n}\n```\n\n### `# transpose`\n\nSwap rows/columns. Good for period comparisons.\n\n```malloy\n# transpose\nview: comparison is {\n aggregate:\n # label="This Month"\n current_revenue\n # label="Last Month"\n prior_revenue\n}\n```\n\n### `# list` / `# list_detail`\n\nList renders as comma-separated values. List_detail shows `value (detail)` pairs.\n\n### `# flatten`\n\nCollapse nested record into parent table as columns. Use for side-by-side filtered aggregates:\n\n```malloy\nview: segments is {\n group_by: product, aggregate: total_revenue\n nest: # flatten\n enterprise is { where: segment = \'Enterprise\', aggregate: # label="Enterprise" revenue }\n nest: # flatten\n smb is { where: segment = \'SMB\', aggregate: # label="SMB" revenue }\n}\n```\n\n### `# table`\n\nDefault (implicit). Use explicitly for `.size=fill` property.\n\n\n## Field Formatting Tags\n\n| Tag | Use For | Shorthand |\n|-----|---------|-----------|\n| `# number` | Numeric formatting | `=auto` (K/M/B), `=id` (no commas), `=1k`, `=1m` |\n| `# percent` | Percentages | (none needed) |\n| `# currency` | Money | `=usd2m` (USD, 2 decimals, millions); scale only in views |\n| `# duration` | Time durations | `=seconds`, `=minutes`, `=hours`, `=days` |\n| `# data_volume` | Storage sizes | `=bytes`, `=kb`, `=mb`, `=gb` |\n| `# link` | Hyperlinks | `.url_template="https://example.com/$$"` |\n| `# image` | Inline images | `.height=40px`, `.width=100px` |\n\n**Currency codes:** `usd` ($), `eur`, `gbp`. **Scale:** K/M/B/T/Q or `auto`.\n**Number suffix styles:** `word` ("42.5 million"), `letter` ("42.5M"), `scientific`.\n\n## Utility Tags\n\n| Tag | Purpose |\n|-----|---------|\n| `# hidden` | Hide from output (still usable for sorting/references) |\n| `# label="..."` | Override display name |\n| `# description="..."` | Tooltip text |\n| `# tooltip` | Include nested view in chart tooltip |\n| `# break` | Force new dashboard row |\n| `# column { width=sm }` | Table column width |\n\n## Model-Level Defaults\n\n```malloy\n## viz.line_chart.defaults.y.independent=true\n## viz.bar_chart.defaults.stack\n```\n\n## Theming\n\nPublisher styles charts and tables from one structured theme. The instance sets it (in `publisher.config.json`\'s `theme` block or the **Settings, then Theme** editor); a model overrides it per result with `# theme.*` annotations, or model-wide with `## theme.*`. Per-chart annotations use the same nested `palette.*` / `font.*` vocabulary as the config, not flat key names, and they win over the instance theme for the keys they set. The forms:\n\n| Annotation | Controls | Modes |\n|-----------|----------|-------|\n| `# theme.palette.series` | Categorical series colors (array) | shared |\n| `# theme.palette.background.{light,dark}` | Chart canvas + table background | per-mode |\n| `# theme.palette.tableHeader.{light,dark}` | Table header text color | per-mode |\n| `# theme.palette.tableHeaderBackground.{light,dark}` | Table header row background | per-mode |\n| `# theme.palette.tableBody.{light,dark}` | Table body text color | per-mode |\n| `# theme.palette.tile.{light,dark}` | Dashboard tile background | per-mode |\n| `# theme.palette.tileTitle.{light,dark}` | Dashboard tile title color | per-mode |\n| `# theme.palette.mapColor.{light,dark}` | Choropleth gradient (`# shape_map` / `# segment_map`) | per-mode |\n| `# theme.font.family` | Font for all rendered text | shared |\n| `# theme.font.size` | Table font size (px) | shared |\n\nThe seven `palette.*` color keys each take a `.light` and/or `.dark` variant so dark mode gets its own value. `palette.series`, `font.family`, and `font.size` are single values shared across modes.\n\n```malloy\n// Model-wide defaults (## applies to every view in the model):\n## theme.palette.series = ["#14b3cb", "#e47404", "#1474a4"]\n## theme.font.family = "Inter, sans-serif"\n\n// Per-view override (# applies to this result only; beats the instance theme):\n# theme.palette.background.light = "#fafafa"\n# theme.palette.background.dark = "#111111"\n# theme.palette.tableHeader.dark = "#94a3b8"\nview: revenue_by_month is {\n group_by: month\n aggregate: revenue\n}\n```\n\n**Precedence**, highest to lowest, per key: `# theme.*` on the view, then `## theme.*` model default, then the instance theme, then Publisher\'s built-in defaults. A per-chart annotation overrides the instance for the keys it sets; unset keys fall through to the instance. (This is the reverse of a bare `@malloydata/render` embed, where the embedder wins: Publisher reads the annotation itself and layers it on top.)\n\nQuote values that contain spaces or a leading `#`. The light/dark default (`defaultMode`) and the toggle lock (`allowUserToggle`) are instance-only: set them in the config `theme` block or the editor, not as annotations. The malloy-gotchas-rendering skill lists the annotation forms that look valid but do nothing.\n\n\n## Advanced Patterns\n\n### Sparklines in KPI Cards\n\n```malloy\n# big_value { sparkline=trend }\nview: revenue_kpi is {\n aggregate: # label="Revenue" # currency revenue\n nest: # line_chart { size=spark } # hidden\n trend is { group_by: order_date, aggregate: revenue, order_by: order_date }\n}\n```\n\n### KPIs with Comparison Deltas\n\n```malloy\n# big_value { comparison_field=prior_month comparison_label="vs Last Month" }\nview: rev_delta is {\n aggregate: # label="Revenue" # currency revenue, # hidden prior_month\n}\n```\n\nUse `down_is_good=true` for metrics where decrease is positive (churn, defects).\n\n### Inline Mini-Charts in Table Rows\n\n```malloy\nview: carriers is {\n group_by: carrier, aggregate: flight_count\n nest: # line_chart { size=spark }\n trend is { group_by: month, aggregate: flight_count, order_by: month }\n}\n```\n\n### Multi-Measure Series\n\n```malloy\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: quarter, aggregate: revenue, cost }\n```\n\n### Hierarchical Drill-Down\n\n```malloy\n# list_detail\nview: explorer is {\n group_by: region, aggregate: revenue\n nest: # bar_chart\n by_category is { group_by: category, aggregate: revenue, order_by: revenue desc, limit: 10 }\n}\n```\n\n### Distribution (Histogram)\n\nThere is no auto-binning function: `autobin(...)` does not exist and fails with `Unknown function \'autobin\'`. Bin by arithmetic, choosing the width from the column\'s actual range: query `min`, `max` and a few percentiles first, and say in the view\'s doc where the width came from. A bin width nobody derived is a business decision in disguise.\n\n```malloy\n# bar_chart\nview: price_dist is {\n group_by: bucket is floor(price / 20) * 20 // 20 is the bin width, from the observed range\n aggregate: order_count is count()\n order_by: bucket\n}\n```\n\n\n## Patterns for Missing Chart Types\n\n| Desired | Malloy Approximation |\n|---------|---------------------|\n| Pie/donut | `# bar_chart` sorted by value |\n| Treemap | Nested table with `order_by: desc` |\n| Heatmap | `# pivot` with color values |\n| Stacked area | `# line_chart` with series (overlaid lines) |\n| Funnel | `# bar_chart` with ordered stages |\n| Gauge/bullet | `# big_value` with `.comparison_field` |\n\n\n## Chart Annotations on Queries with `nest:`\n\nA top-level chart tag (e.g., `# bar_chart`) renders only the outer query; any `nest:` views are silently hidden from the rendering (still in raw data). To show nests, use `# dashboard` on the outer query with chart tags on each nest. Otherwise, drop the `nest:`.\n\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Two aggregates in chart | ONE aggregate, or use `y=[\'a\',\'b\']` |\n| `# currency=usd0m` on measure | `# currency` (no scale) on defs; scale only in views |\n| Chart annotation on `nest:` line | Put on the **view definition** |\n| Tags on same line | One tag per line |\n| Sparkline not showing | Add `# hidden` to nested view AND reference in `.sparkline=` |\n| Pivot > 30 columns | Filter/limit the nested group_by |\n\nNOTE: The term \'constructor\' is a reserved term in Vega-Lite. If the word \'constructor\' appears in the query, it will cause the rendering to fail. Never use it in a query and avoid using it as a dimension in a model.\n\nFor more patterns, call `search_malloy_docs` with topics like "bar charts", "line charts", "dashboards", "histograms", "percent of total", "comparing timeframes", or "pivots".\n\n## Further Reading\n\n- [Visualizations Overview](https://docs.malloydata.dev/documentation/visualizations/overview) - Official docs\n- [Bar Charts](https://docs.malloydata.dev/documentation/visualizations/bar_charts) - Stacked, grouped, series\n- [Bump Charts Blog](https://docs.malloydata.dev/blog/2023-10-26-malloy-bump-chart/) - Ranking over time\n- [Dataviz is Hierarchical](https://docs.malloydata.dev/blog/2024-02-29-hierarchical-viz/) - Nested data visualization philosophy' }, { name: "malloy-debug", description: 'Fix Malloy compile errors and understand error messages. Use when encountering errors in .malloy files, user says "fix this error", "malloy error", "compile error", "syntax error", or sees 20+ cascading errors.', body: `# Debugging Malloy Errors
292829
+ Publishing is out of scope for now: open-source Publisher serves the model from disk, and self-hosters publish via git plus their host's publish path.` }, { name: "malloy-charts", description: 'Chart selection guidance and renderer reference for Malloy views. Use when choosing visualization types, adding chart annotations, user asks "what chart should I use", "how should I visualize this", or when deciding between bar_chart, line_chart, scatter_chart, etc.', body: '# Chart Selection for Malloy\n\n> Malloy uses Vega-Lite under the hood. `#` tags control visualization. Call `search_malloy_docs` with topic "rendering" for the full tag reference (or see https://docs.malloydata.dev/documentation/visualizations/overview).\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## Decision Tree: Which Chart?\n\n| Data Shape | Default Choice |\n|-----------|---------------|\n| Aggregates only (no group_by) | `# big_value` |\n| 1 time column + 1 measure | `# line_chart` |\n| 1 category + 1 measure | `# bar_chart` |\n| 2 numeric columns | `# scatter_chart` |\n| Geographic (US states) + 1 measure | `# shape_map` |\n| Route data (lat/lon pairs) | `# segment_map` |\n| Multiple perspectives | `# dashboard` with `nest:` |\n| Nested query to pivot | `# pivot` |\n| Filtered aggregates side-by-side | `# flatten` |\n| Detailed rows | Default table (no annotation) |\n\n| Goal | Renderer |\n|------|---------|\n| Compare categories | `# bar_chart` (sort by value, limit ~15) |\n| Show composition | `# bar_chart.stack` |\n| Trend over time | `# line_chart` |\n| Highlight KPIs | `# big_value` with `# label` |\n| Correlation | `# scatter_chart` |\n| Compare dimensions | `# dashboard` (nest chart views) |\n| Before/after | `# transpose` or `# pivot` |\n| Multiple metrics per category | Default table, `# flatten`, or `y=[\'a\',\'b\']` |\n\n**Constraints:**\n- ONE aggregate per chart view (charts render only the first; use `y=[\'a\',\'b\']` for multi-measure)\n- No fixed scale on measure definitions: use `# currency` not `# currency=usd0m`\n- One tag per line\n- Alias joined fields in `group_by` before `order_by`\n- Define measures in source, not in views\n\n\n## Chart Types\n\n### `# bar_chart`\n\n**Data shape:** `group_by` = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# bar_chart\nview: by_carrier is { group_by: carrier, aggregate: flight_count, order_by: flight_count desc, limit: 10 }\n\n# bar_chart.stack\nview: by_region is { group_by: category, region, aggregate: revenue }\n\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: category, aggregate: revenue, cost }\n```\n\n**Key properties:** `.stack`, `.size` (spark/xs/sm/md/lg/xl/2xl), `.x`, `.x.limit`, `.y` (supports `y=[\'a\',\'b\']`), `.series`, `.series.limit` (default 20), `.title`, `.subtitle`, `.x.independent`, `.y.independent`\n\n**Field role tags:** `# x`, `# y`, `# series` on individual fields to assign roles explicitly.\n\n### `# line_chart`\n\n**Data shape:** `group_by` (temporal/numeric) = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# line_chart\nview: trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n\n# line_chart { size=spark }\nview: mini_trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n```\n\n**Key properties:** `.zero_baseline`, `.interpolate` (e.g., `step`), `.size`, `.y` (supports `y=[\'a\',\'b\']`), `.series.limit` (default 12), `.title`, `.subtitle`\n\n### `# scatter_chart`\n\n**Data shape:** Fields by position: x, y, color, size (bubble), shape.\n\n```malloy\n# scatter_chart\nview: correlation is { group_by: customer_id, aggregate: avg_price, total_quantity }\n```\n\n### `# shape_map`\n\nChoropleth. US states only. Fields: state name, value.\n\n```malloy\n# shape_map\nview: by_state is { group_by: state, aggregate: revenue }\n```\n\n### `# segment_map`\n\nRoute map. US only. Fields: start_lat, start_lon, end_lat, end_lon, color.\n\n\n## Layout Types\n\n### `# big_value`\n\nKPI cards. Aggregates only, no `group_by`.\n\n```malloy\n# big_value\nview: summary is {\n aggregate:\n # label="Revenue"\n # currency\n revenue\n # label="Orders"\n # number=auto\n order_count\n}\n```\n\n**Properties:** `.size`, `.sparkline=<nested_view_name>`, `.comparison_field`, `.comparison_label`, `.down_is_good`\n\n### `# dashboard`\n\nCard-based multi-tile layout. Apply to a view whose body is a nested query; the view\'s own fields lay out automatically:\n\n- `group_by` dimensions -> a row header (repeats once per row; omit for a single block)\n- `aggregate` measures -> KPI cards, one per measure\n- each `nest:` -> a tile, rendered by the tag above it (`# table` default, or `# bar_chart` / `# line_chart` / `# big_value`)\n\n**Two modes.** Flex (default): tiles flow and wrap; `# break` forces a new row. Columns: `# dashboard { columns=N }` lays tiles into N equal columns, `# colspan=n` widens a tile, `# break` starts a new row, overflow wraps.\n\n```malloy\n// Flex: measures become KPI cards, the nest becomes a tile\n# dashboard\nview: overview is {\n group_by: category\n # currency\n aggregate:\n avg_retail is retail_price.avg()\n sum_retail is retail_price.sum()\n nest:\n # bar_chart\n by_brand is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 10 }\n}\n\n// Columns: # colspan widens tiles, # break ends a row\n# dashboard { columns=12 }\nview: layout is {\n group_by: category\n # currency\n aggregate:\n # colspan=4\n avg_retail is retail_price.avg()\n # colspan=4\n sum_retail is retail_price.sum()\n # colspan=4\n max_retail is retail_price.max()\n nest:\n # break\n # colspan=6\n # bar_chart\n # subtitle="Top brands"\n by_brand_chart is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 8 }\n # colspan=6\n by_brand_table is { group_by: brand, aggregate: product_count is count(), limit: 8 }\n}\n```\n\n**Tags:** `# dashboard { columns=N }` (columns mode), `{ gap=PX }` (tile spacing, default 16; never a mode), `{ table.max_height=PX|none }` (cap table tiles). On a measure or nest: `# colspan=N` (columns mode only), `# break` (both modes), `# subtitle="..."` (tile), `# borderless` (drop card chrome), `# label="..."` (card title).\n\nFor rich KPI cards (sparklines, comparison deltas, several metrics on one card) nest a `# big_value` view instead of relying on the dashboard\'s own measures:\n\n```malloy\n# dashboard\nview: kpis is {\n group_by: category\n nest:\n # big_value\n revenue_card is {\n aggregate:\n # label="Revenue"\n # currency\n # big_value { sparkline=trend }\n total_revenue is retail_price.sum()\n # line_chart { size=spark y.independent=true }\n # hidden\n nest: trend is { group_by: bucket is floor(id / 100)::number, aggregate: total_revenue is retail_price.sum(), order_by: bucket, limit: 20 }\n }\n}\n```\n\n**Rules:** `# dashboard` needs a nested-query view (no effect on a scalar). `# colspan` works only in columns mode and is ignored (warns) in flex. `columns` is any positive integer; a `# colspan` over the column count clamps to a full row. Style tiles via the instance theme, or theme the views inside with `# theme.*` (see Theming below).\n\n### `# pivot`\n\nPivot nested results into columns. Max 30 pivot columns.\n\n```malloy\nview: sales is {\n group_by: product, aggregate: total\n nest: # pivot\n by_quarter is { group_by: quarter, aggregate: revenue }\n}\n```\n\n### `# transpose`\n\nSwap rows/columns. Good for period comparisons.\n\n```malloy\n# transpose\nview: comparison is {\n aggregate:\n # label="This Month"\n current_revenue\n # label="Last Month"\n prior_revenue\n}\n```\n\n### `# list` / `# list_detail`\n\nList renders as comma-separated values. List_detail shows `value (detail)` pairs.\n\n### `# flatten`\n\nCollapse nested record into parent table as columns. Use for side-by-side filtered aggregates:\n\n```malloy\nview: segments is {\n group_by: product, aggregate: total_revenue\n nest: # flatten\n enterprise is { where: segment = \'Enterprise\', aggregate: # label="Enterprise" revenue }\n nest: # flatten\n smb is { where: segment = \'SMB\', aggregate: # label="SMB" revenue }\n}\n```\n\n### `# table`\n\nDefault (implicit). Use explicitly for `.size=fill` property.\n\n\n## Field Formatting Tags\n\n| Tag | Use For | Shorthand |\n|-----|---------|-----------|\n| `# number` | Numeric formatting | `=auto` (K/M/B), `=id` (no commas), `=1k`, `=1m` |\n| `# percent` | Percentages | (none needed) |\n| `# currency` | Money | `=usd2m` (USD, 2 decimals, millions); scale only in views |\n| `# duration` | Time durations | `=seconds`, `=minutes`, `=hours`, `=days` |\n| `# data_volume` | Storage sizes | `=bytes`, `=kb`, `=mb`, `=gb` |\n| `# link` | Hyperlinks | `.url_template="https://example.com/$$"` |\n| `# image` | Inline images | `.height=40px`, `.width=100px` |\n\n**Currency codes:** `usd` ($), `eur`, `gbp`. **Scale:** K/M/B/T/Q or `auto`.\n**Number suffix styles:** `word` ("42.5 million"), `letter` ("42.5M"), `scientific`.\n\n## Utility Tags\n\n| Tag | Purpose |\n|-----|---------|\n| `# hidden` | Hide from output (still usable for sorting/references) |\n| `# label="..."` | Override display name |\n| `# description="..."` | Tooltip text |\n| `# tooltip` | Include nested view in chart tooltip |\n| `# break` | Force new dashboard row |\n| `# column { width=sm }` | Table column width |\n\n## Model-Level Defaults\n\n```malloy\n## viz.line_chart.defaults.y.independent=true\n## viz.bar_chart.defaults.stack\n```\n\n## Theming\n\nPublisher styles charts and tables from one structured theme. The instance sets it (in `publisher.config.json`\'s `theme` block or the **Settings, then Theme** editor); a model overrides it per result with `# theme.*` annotations, or model-wide with `## theme.*`. Per-chart annotations use the same nested `palette.*` / `font.*` vocabulary as the config, not flat key names, and they win over the instance theme for the keys they set. The forms:\n\n| Annotation | Controls | Modes |\n|-----------|----------|-------|\n| `# theme.palette.series` | Categorical series colors (array) | shared |\n| `# theme.palette.background.{light,dark}` | Chart canvas + table background | per-mode |\n| `# theme.palette.tableHeader.{light,dark}` | Table header text color | per-mode |\n| `# theme.palette.tableHeaderBackground.{light,dark}` | Table header row background | per-mode |\n| `# theme.palette.tableBody.{light,dark}` | Table body text color | per-mode |\n| `# theme.palette.tile.{light,dark}` | Dashboard tile background | per-mode |\n| `# theme.palette.tileTitle.{light,dark}` | Dashboard tile title color | per-mode |\n| `# theme.palette.mapColor.{light,dark}` | Choropleth gradient (`# shape_map` / `# segment_map`) | per-mode |\n| `# theme.font.family` | Font for all rendered text | shared |\n| `# theme.font.size` | Table font size (px) | shared |\n\nThe seven `palette.*` color keys each take a `.light` and/or `.dark` variant so dark mode gets its own value. `palette.series`, `font.family`, and `font.size` are single values shared across modes.\n\n```malloy\n// Model-wide defaults (## applies to every view in the model):\n## theme.palette.series = ["#14b3cb", "#e47404", "#1474a4"]\n## theme.font.family = "Inter, sans-serif"\n\n// Per-view override (# applies to this result only; beats the instance theme):\n# theme.palette.background.light = "#fafafa"\n# theme.palette.background.dark = "#111111"\n# theme.palette.tableHeader.dark = "#94a3b8"\nview: revenue_by_month is {\n group_by: month\n aggregate: revenue\n}\n```\n\n**Precedence**, highest to lowest, per key: `# theme.*` on the view, then `## theme.*` model default, then the instance theme, then Publisher\'s built-in defaults. A per-chart annotation overrides the instance for the keys it sets; unset keys fall through to the instance. (This is the reverse of a bare `@malloydata/render` embed, where the embedder wins: Publisher reads the annotation itself and layers it on top.)\n\nQuote values that contain spaces or a leading `#`. The light/dark default (`defaultMode`) and the toggle lock (`allowUserToggle`) are instance-only: set them in the config `theme` block or the editor, not as annotations. The malloy-gotchas-rendering skill lists the annotation forms that look valid but do nothing.\n\n\n## Advanced Patterns\n\n### Sparklines in KPI Cards\n\n```malloy\n# big_value { sparkline=trend }\nview: revenue_kpi is {\n aggregate: # label="Revenue" # currency revenue\n nest: # line_chart { size=spark } # hidden\n trend is { group_by: order_date, aggregate: revenue, order_by: order_date }\n}\n```\n\n### KPIs with Comparison Deltas\n\n```malloy\n# big_value { comparison_field=prior_month comparison_label="vs Last Month" }\nview: rev_delta is {\n aggregate: # label="Revenue" # currency revenue, # hidden prior_month\n}\n```\n\nUse `down_is_good=true` for metrics where decrease is positive (churn, defects).\n\n### Inline Mini-Charts in Table Rows\n\n```malloy\nview: carriers is {\n group_by: carrier, aggregate: flight_count\n nest: # line_chart { size=spark }\n trend is { group_by: month, aggregate: flight_count, order_by: month }\n}\n```\n\n### Multi-Measure Series\n\n```malloy\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: quarter, aggregate: revenue, cost }\n```\n\n### Hierarchical Drill-Down\n\n```malloy\n# list_detail\nview: explorer is {\n group_by: region, aggregate: revenue\n nest: # bar_chart\n by_category is { group_by: category, aggregate: revenue, order_by: revenue desc, limit: 10 }\n}\n```\n\n### Distribution (Histogram)\n\nThere is no auto-binning function: `autobin(...)` does not exist and fails with `Unknown function \'autobin\'`. Bin by arithmetic, choosing the width from the column\'s actual range: query `min`, `max` and a few percentiles first, and say in the view\'s doc where the width came from. A bin width nobody derived is a business decision in disguise.\n\n```malloy\n# bar_chart\nview: price_dist is {\n group_by: bucket is floor(price / 20) * 20 // 20 is the bin width, from the observed range\n aggregate: order_count is count()\n order_by: bucket\n}\n```\n\n\n## Patterns for Missing Chart Types\n\n| Desired | Malloy Approximation |\n|---------|---------------------|\n| Pie/donut | `# bar_chart` sorted by value |\n| Treemap | Nested table with `order_by: desc` |\n| Heatmap | `# pivot` with color values |\n| Stacked area | `# line_chart` with series (overlaid lines) |\n| Funnel | `# bar_chart` with ordered stages |\n| Gauge/bullet | `# big_value` with `.comparison_field` |\n\n\n## Chart Annotations on Queries with `nest:`\n\nA top-level chart tag (e.g., `# bar_chart`) renders only the outer query; any `nest:` views are silently hidden from the rendering (still in raw data). To show nests, use `# dashboard` on the outer query with chart tags on each nest. Otherwise, drop the `nest:`.\n\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Two aggregates in chart | ONE aggregate, or use `y=[\'a\',\'b\']` |\n| `# currency=usd0m` on measure | `# currency` (no scale) on defs; scale only in views |\n| Chart annotation on `nest:` line | Put on the **view definition** |\n| Tags on same line | One tag per line |\n| Sparkline not showing | Add `# hidden` to nested view AND reference in `.sparkline=` |\n| Pivot > 30 columns | Filter/limit the nested group_by |\n\nNOTE: The term \'constructor\' is a reserved term in Vega-Lite. If the word \'constructor\' appears in the query, it will cause the rendering to fail. Never use it in a query and avoid using it as a dimension in a model.\n\nFor more patterns, call `search_malloy_docs` with topics like "bar charts", "line charts", "dashboards", "histograms", "percent of total", "comparing timeframes", or "pivots".\n\n## Further Reading\n\n- [Visualizations Overview](https://docs.malloydata.dev/documentation/visualizations/overview) - Official docs\n- [Bar Charts](https://docs.malloydata.dev/documentation/visualizations/bar_charts) - Stacked, grouped, series\n- [Bump Charts Blog](https://docs.malloydata.dev/blog/2023-10-26-malloy-bump-chart/) - Ranking over time\n- [Dataviz is Hierarchical](https://docs.malloydata.dev/blog/2024-02-29-hierarchical-viz/) - Nested data visualization philosophy' }, { name: "malloy-dashboards", description: "Build or modify a Malloy Publisher dashboard, a tagged .malloy file in a package's dashboards/ directory, with auto-rendered filter controls, a grid layout, and # drill click-through. Use when the user asks for a dashboard, a filterable operational view, or drill-through between views, and no code is wanted.", body: `# Publisher Dashboards
292830
+
292831
+ > A \`dashboards/*.malloy\` file **is** a dashboard. It imports the model, either declares one query and applies its own filtering or names views that already do, and tags the layout. Publisher discovers it at package load, renders the filter controls from the givens it references, offers \`# drill\` click-through between pages, and serves it at \`/<env>/<pkg>/dashboards/<name>\`. No code, no build step.
292832
+
292833
+ ## When this is the right tool
292834
+
292835
+ | The user wants | Use |
292836
+ | ---------------------------------------------------- | ------------------------------------------------ |
292837
+ | A recurring, at-a-glance view behind shared filters | this skill (a dashboard) |
292838
+ | A narrative, with prose between the numbers | a notebook (\`skill:malloy-notebooks\`) |
292839
+ | Custom design, branding, or interactions beyond tags | an HTML data app (\`skill:malloy-html-data-apps\`) |
292840
+ | The model itself: sources, measures, joins | \`skill:malloy-modeling\` |
292841
+
292842
+ Notebooks and dashboards run the same engine, so **interactivity is not the axis**: both get filter
292843
+ controls, URL-addressable state, Apply batching, and \`# drill\`. Pick on the shape of the document.
292844
+ Scanned at a glance is a dashboard; read top to bottom is a notebook.
292845
+
292846
+ ## Build sequence
292847
+
292848
+ 1. **READ THE MODEL FIRST.** Get the real source, view, dimension, and given names from the package:
292849
+ \`malloy_getContext\` if you have it, otherwise the REST model endpoint or the \`.malloy\` files.
292850
+ Never guess a name. A guessed field in a query fails the whole package load, not just that one
292851
+ dashboard; a guessed tile or suggest source is quieter, and only shows up in the package warnings.
292852
+ 2. **DECIDE THE FORM** (below): single-query if the page is one filtered result with parts;
292853
+ composite if the views already exist and the job is choosing which to show together.
292854
+ 3. **DECLARE THE GIVENS** the dashboard will filter by, in the model (usually \`givens.malloy\`), with
292855
+ their control tags: see "Filter controls" below for the syntax and what each tag renders as. Skip
292856
+ if they already exist, since a given is a model concern and dashboards share them.
292857
+ 4. **COMPOSE THE FILE** for \`dashboards/\`, following the template below, but do not save it yet.
292858
+ Import every given it filters by, and every source or query any of those givens names in a
292859
+ \`suggest\`. Both are per-file, and getting the suggest wrong does not error: the control still
292860
+ looks like a picker but has no options, and says so underneath, "Could not load the options for
292861
+ this control". The package warnings name it too.
292862
+ 5. **COMPILE IT** with \`malloy_compile\` (or \`POST …/models/<path>/compile\`), against the source text,
292863
+ before you save, at the path the file will have. **Editing one that already exists needs
292864
+ \`"scope": "file"\`**, which compiles your source AS that file; the default appends it instead, so
292865
+ every imported name and the query name collide with the saved copy and you get a wall of
292866
+ already-defined errors that reads as broken Malloy rather than a wrong scope. **Editing a shared
292867
+ include wants \`"scope": "package"\`**, which recompiles every file as saved: \`file\` only checks the
292868
+ one you are editing, so renaming a source in \`_shared.malloy\` passes it while breaking every
292869
+ composite that imports it. A clean compile is not a working dashboard: some tag mistakes surface
292870
+ at step 6, and some only when you look at the page in step 7. (The third scope, \`append\`, is the
292871
+ default and is what a not-yet-saved file gets.)
292872
+ 6. **SAVE IT, RELOAD, AND READ THE MANIFEST AND THE WARNINGS.** \`malloy_reloadPackage\`, or
292873
+ \`GET …/packages/<pkg>?reload=true\`. Check the status the reload returns as well as the warnings:
292874
+ a 424 means the package did not load and your edit is not live. **The \`warnings\` key is absent
292875
+ when there are none**, so an empty response is the pass, not a sign you are reading the wrong
292876
+ field. Then read \`GET …/packages/<pkg>/dashboards/<name>\`: its \`givens\` are exactly the controls
292877
+ that will render, which catches a given you imported but never referenced before you open the page,
292878
+ and its \`query\` is the name to run in step 7. See "Read the lint" below.
292879
+ 7. **OPEN IT AND LOOK.** Not optional; see "What 'done' means".
292880
+
292881
+ ## The two forms
292882
+
292883
+ **Single-query:** one query whose result is the dashboard. Reach for this by default.
292884
+
292885
+ \`\`\`malloy
292886
+ ##! experimental.givens
292887
+ import { order_items, products } from '../storefront.malloy'
292888
+ import { CATEGORY, MIN_SALE } from '../givens.malloy'
292889
+
292890
+ #" Revenue and margin at a glance, and where they come from.
292891
+ # artifact { title="Business Overview" } dashboard { columns=12 }
292892
+ query: overview is order_items -> {
292893
+ where: products.category ~ $CATEGORY and sale_price ~ $MIN_SALE
292894
+
292895
+ aggregate:
292896
+ # label="Revenue"
292897
+ # currency
292898
+ # colspan=3
292899
+ total_sales
292900
+ # label="Gross margin"
292901
+ # currency
292902
+ # colspan=3
292903
+ total_margin
292904
+ # label="Orders"
292905
+ # colspan=3
292906
+ order_count
292907
+ # label="Avg order value"
292908
+ # currency
292909
+ # colspan=3
292910
+ avg_order_value
292911
+
292912
+ nest:
292913
+ # break
292914
+ # colspan=6
292915
+ # label="Revenue by month"
292916
+ sales_by_month
292917
+ # colspan=6
292918
+ # label="Revenue by state"
292919
+ sales_by_state
292920
+ nest:
292921
+ # colspan=12
292922
+ # label="Category performance"
292923
+ by_category
292924
+ }
292925
+ \`\`\`
292926
+
292927
+ The \`#"\` line above the tag is a doc comment, and it is the page's description. If you leave \`title=\`
292928
+ off the artifact tag, it becomes the title instead, so write it as one, not as a sentence about the
292929
+ page. **It belongs to the query, so it only works on this form.** Putting a \`#"\` above a
292930
+ model-level \`##\` tag fails the whole package load with "Object annotation not connected to any
292931
+ object", and a composite has no description as a result.
292932
+
292933
+ **Composite:** a list of views that already exist, each run separately into one grid. The tag is
292934
+ model-level (\`##\`) because there is no query of its own to hang a \`#\` tag on.
292935
+
292936
+ \`\`\`malloy
292937
+ ##! experimental.givens
292938
+ ## artifact { title="Seasonality" tiles=["scoped_sales -> sales_by_month", "scoped_sales -> sales_by_year", "scoped_sales -> seasonality"] dashboard_columns=3 }
292939
+ import { scoped_sales } from './_shared.malloy'
292940
+ import { products } from '../storefront.malloy'
292941
+ import { CATEGORY, SINCE } from '../givens.malloy'
292942
+ \`\`\`
292943
+
292944
+ A composite has no query, so the filtering it applies must live in what it composes: a source that
292945
+ already has the givens applied. Put it in an untagged \`dashboards/_shared.malloy\`, which discovery
292946
+ treats as a shared include rather than a dashboard. **It has to apply every given the composite
292947
+ imports**: a given the composite imports but nothing references gets no control, silently, at reload
292948
+ 200 with no warning. Note \`SINCE\` is a \`date\` rather than a \`filter<>\`, so it compares with \`>=\`
292949
+ rather than \`~\`. Save the include before you compile the dashboard that imports it, since an importer
292950
+ compiled against a sibling that is not on disk fails with an \`import-error\`.
292951
+
292952
+ \`\`\`malloy
292953
+ ##! experimental.givens
292954
+ import { order_items } from '../storefront.malloy'
292955
+ import { CATEGORY, SINCE } from '../givens.malloy'
292956
+
292957
+ source: scoped_sales is order_items extend {
292958
+ where: products.category ~ $CATEGORY and created_at >= $SINCE
292959
+ }
292960
+ \`\`\`
292961
+
292962
+ Its tiles are equal-width (there is no per-tile colspan), and each one takes a single column, so
292963
+ **\`dashboard_columns\` is how many tiles you want per row**, not a number of twelfths. Three tiles want
292964
+ \`dashboard_columns=3\`. Setting it to 12 out of habit gives you twelve columns and three tiles a
292965
+ twelfth of the page wide. Use the single-query form when one tile deserves more room than the others.
292966
+
292967
+ **Write \`dashboard_columns=N\` on a composite and \`# dashboard { columns=N }\` on a single query.**
292968
+ A composite forgives the mix, since both spellings feed the manifest field it
292969
+ lays out from, but **a single query tagged \`dashboard_columns\` silently loses its whole layout**, and
292970
+ the manifest reports the count either way: see "Losing the grid". A composite's tiles also keep their
292971
+ own field names on their axes and column headers, and the Layout section's remedy, inlining the view,
292972
+ is not open to you here: naming existing views is the whole point of the form. \`tiles=\` on a
292973
+ single-query artifact tag is dropped the same way, silently.
292974
+
292975
+ ## Layout: the four tags that make a page line up
292976
+
292977
+ This section is the **single-query** form only. A composite has no colspans and counts tiles per row,
292978
+ above. Cards and tiles share one grid, so copy this recipe, and use the same count on every
292979
+ single-query dashboard in the package so they read as one product:
292980
+
292981
+ 1. **\`columns=12\`** on the \`# dashboard\` tag. Twelve divides by 2, 3, 4 and 6, so a row is even with
292982
+ three cards or four.
292983
+ 2. **A \`# colspan\` on every card and tile, summing to 12 per row.** Four cards at 3, three at 4, two
292984
+ tiles at 6, a full-width table at 12. Omit them and every item falls to a single column, a twelfth
292985
+ of the width, which is too narrow for a line chart to draw in at all.
292986
+ 3. **\`# break\` on the first tile after the cards.** Otherwise it flows into the columns left beside
292987
+ the cards and the next tile wraps. Not needed per row: once a row sums to 12 the next item wraps
292988
+ on its own.
292989
+ 4. **\`# label="…"\` on every nest and every aggregate**, including the aggregates inside a table
292990
+ nest, whose column headers are field names too. The heading is otherwise the view's or field's
292991
+ name, and a wide table full of \`total_sales\` and \`order_item_count\` is the most visible thing
292992
+ between a rough page and a finished one. A view nested **by name** is the exception: you can label
292993
+ the tile, but its own field names still reach the chart axes and the column headers, so inline it
292994
+ if you want those labelled too.
292995
+
292996
+ Then the traps:
292997
+
292998
+ - **No \`# size=fill\` on a dashboard tile.** Inside a dashboard it measures against the container the
292999
+ whole grid was handed, not the tile, so it yields a chart thousands of pixels tall. Tiles already
293000
+ size to their colspan.
293001
+ - **A KPI card's label is one line that ellipses** rather than wrapping, so a long label in a narrow
293002
+ card is truncated with no other sign. Widen the card or shorten the label.
293003
+ - **A ratio needs a number format.** \`order_count / customer_count\` renders as \`10.695\` on a card;
293004
+ \`# number="#,##0.0"\` is the precision it actually carries.
293005
+ - **A \`# shape_map\` legend is titled with the measure's field _name_, not its \`# label\`.** Rename it
293006
+ in the view: \`aggregate: revenue is total_sales\`. Renaming drops the measure's own format tags
293007
+ though, so \`# currency\` becomes plain digits unless you re-tag it at the rename. Prefer \`# label=\`
293008
+ anywhere the legend is not the problem.
293009
+ - **A series legend sizes itself from the longer of the series label and its widest value**, then
293010
+ truncates both. A 4-character label over 4-digit years clips to \`20…\`; \`# label="Order year"\`
293011
+ instead of \`"Year"\` buys the room. A legend showing \`…\` is this, not a data problem.
293012
+
293013
+ The last two are upstream renderer behavior, cheap to work around in the model.
293014
+
293015
+ The same tags govern a \`# dashboard\` **view** run in a notebook cell, since both surfaces render
293016
+ through the same code, so a view laid out this way looks the same in a cell as on a dashboard page.
293017
+ Height is the one thing the surface decides: a single-query dashboard renders at its natural height,
293018
+ a composite's tiles are each capped, and in a notebook a chart cell is capped and a table cell hugs
293019
+ its rows.
293020
+
293021
+ ## The rules that actually bite
293022
+
293023
+ - **The filename is the dashboard's name:** its URL slug, its listing name, and its \`# drill\`
293024
+ target. The query inside can be called anything, and sometimes must be (a query named \`regions\`
293025
+ collides with an imported \`regions\` source).
293026
+ - **Importing a given is what makes it bindable.** Malloy's given namespace is per-file. A given the
293027
+ dashboard file does not import gets no control and cannot be sent to it, even when the \`where:\`
293028
+ that references it lives up an import chain. A composite must import the givens its tiles use.
293029
+ - **A suggest's source or query has to resolve in the dashboard file too.** \`suggest { source=products … }\`
293030
+ means the dashboard imports \`products\`.
293031
+ - **A model-level \`##\` tag must be on one line.** Wrapping one always breaks it, but how you find
293032
+ out depends on what follows. If the continuation is not valid Malloy you get a compile error. If it
293033
+ happens to be, an \`import\` say, the file compiles clean, quietly stops being a dashboard and
293034
+ becomes a shared include, and only the package warnings tell you. Match on the shape rather than the
293035
+ words: the message may say a tag "does not parse", or was "refused" or "dropped rather than parsed",
293036
+ and on a file that still built it opens "Annotation" rather than "Tag". See "Losing the grid" below.
293037
+ - **In a \`# dashboard\` view, fields render by role.** A top-level \`aggregate:\` measure is a KPI card,
293038
+ so do not nest a \`# big_value\` view to get one. Each \`nest:\` is a tile. Give every KPI a
293039
+ \`# label=\`, or the card is headed \`total_sales\`.
293040
+ - **Only table cells are marked drillable.** See "Drill".
293041
+
293042
+ \`skill:malloy-gotchas-rendering\` covers the renderer tags in depth; \`skill:malloy-charts\` covers
293043
+ choosing them.
293044
+
293045
+ ## Filter controls
293046
+
293047
+ Controls come from the \`given:\` declarations the query references, and the tags on the declaration
293048
+ are the control contract, declared once and identical on every dashboard and in every notebook that
293049
+ uses them:
293050
+
293051
+ \`\`\`malloy
293052
+ ##! experimental.givens
293053
+
293054
+ # label="Category" control=select suggest { source=products dimension=category }
293055
+ given: CATEGORY :: filter<string> is f''
293056
+
293057
+ # label="Brand" control=multiselect suggest { query=brand_suggest dimension=brand }
293058
+ given: BRAND :: filter<string> is f''
293059
+
293060
+ # label="Minimum line total" range_min=0 range_max=250
293061
+ given: MIN_SALE :: filter<number> is f''
293062
+
293063
+ # label="Ordered since"
293064
+ given: SINCE :: date is @2023-01-01
293065
+ \`\`\`
293066
+
293067
+ A sixth tag, \`description\`, is part of the contract and has two spellings that do different things:
293068
+ \`# description="…"\` publishes to the API but Publisher's own UI does not render it, while
293069
+ \`#(description="…")\` renders as helper text under the control but complains about any multi-word
293070
+ value, that the prefix "is not a well-formed route", because a route ends at the first space. That
293071
+ complaint is a **compile** diagnostic on a compile that still succeeds, not a package warning, so
293072
+ step 6 will not show it. Pick by which reader you care about.
293073
+
293074
+ \`control=select\`/\`multiselect\` with a \`suggest\` renders a picker filled from the data;
293075
+ \`range_min\`/\`range_max\` on a \`filter<number>\` renders a slider; a \`date\` or \`timestamp\` renders a
293076
+ date picker. Which controls appear is per-dashboard, decided by which givens the query references.
293077
+ \`skill:malloy-modeling\` and \`docs/givens.md\` cover givens themselves.
293078
+
293079
+ Two per-dashboard options on the artifact tag:
293080
+
293081
+ - \`autorun=false\` batches control changes behind an Apply button. Add it once a page is slow enough
293082
+ that a reader notices two round trips.
293083
+ - \`givens { CATEGORY=f'Outerwear' }\` sets starting values, not a redeclaration. A URL parameter wins.
293084
+
293085
+ A notebook takes both at the file level, as \`## autorun=false\` and \`## givens { CATEGORY=f'Outerwear' }\`,
293086
+ and behaves identically.
293087
+
293088
+ ## Drill
293089
+
293090
+ \`# drill\` goes on a model **dimension**, never on a dashboard:
293091
+
293092
+ \`\`\`malloy
293093
+ # drill { to=["category", "self"] given=CATEGORY }
293094
+ dimension: category is products.category
293095
+ \`\`\`
293096
+
293097
+ \`to=<slug>\` navigates to that dashboard with the clicked value written into the named given;
293098
+ \`to=self\` filters in place; two or more destinations pop a menu.
293099
+
293100
+ Declaring it on the dimension is the point: every result that groups by it becomes clickable, in a
293101
+ dashboard tile and in a notebook cell alike. So when a view is meant to be drilled, group by the
293102
+ tagged dimension. Declaring \`dimension: category is products.category\` and grouping by \`category\`
293103
+ gives the identical output field name and the identical numbers, and carries the tag.
293104
+
293105
+ **Always write \`given=\`.** Without it the given name is the dimension name **verbatim**, so a
293106
+ \`dimension: category\` seeds a given called \`category\` rather than a declared \`given: CATEGORY\`. A
293107
+ \`to=self\` survives that, because a surface folds case when it looks up its own given. A \`to=<slug>\`
293108
+ does not: it navigates, still looks like it worked, and arrives as \`?category=…\`, which the
293109
+ destination drops by exact match, so you land on an unfiltered page. Nothing errors, and the lint
293110
+ folds case when it checks, so it stays green too. That silence is specific to a name that folds onto
293111
+ a declared given. A \`to=self\` whose name matches nothing at all is caught loudly and is not offered;
293112
+ a \`to=<slug>\` is not checked either way.
293113
+
293114
+ A drill only lands somewhere useful if the destination declares a control for the given being
293115
+ seeded. **No lint checks that.** It verifies that the target slug is a dashboard in the package, and
293116
+ for \`to=self\` that some model declares the given, and stops there. Nothing reads the destination's
293117
+ own givens, so click it and look.
293118
+
293119
+ Cells in a drillable **table** column show it: pointer cursor, and a blue underline on hover. They are
293120
+ in the tab order and carry a button role too, so a keyboard reaches them, focus is styled the way
293121
+ hover is, and Enter or Space fires the drill. Chart marks get no such affordance in either Publisher
293122
+ or Malloyyo, so a dashboard meant to be drilled wants at least one untagged (table) tile. A destination the
293123
+ surface cannot honor is not marked and not offered, which is why a \`to=self\` reads as plain text in a
293124
+ document that declares no control for its given.
293125
+
293126
+ **One thing quietly switches the marking off, per column: another tile rendering a column with the
293127
+ same header.** Put a "revenue by category" chart beside a drillable \`category\` table, which is the
293128
+ obvious thing to build, and that column's cells stop being marked. Marking matches columns by their
293129
+ rendered header text, so a name a non-drillable field also shows is dropped rather than risk painting
293130
+ a dead link. Only a **non-drillable** column suppresses: a second tile that groups by the same tagged
293131
+ dimension is fine, which is the arrangement the paragraph above already recommends. **Other drillable
293132
+ columns on the same page keep their marking**, so counting marked cells will not tell you: look at the
293133
+ column you care about. The clicks still work, so this is invisible
293134
+ unless you hover. Give the two different headings with \`# label=\`. A transposed table is never marked
293135
+ either, for a different reason.
293136
+
293137
+ ## Losing the grid
293138
+
293139
+ A single-query dashboard can come out with its layout wrong in two visibly different ways, and the
293140
+ reload is 200 and the manifest reports the column count you asked for in both.
293141
+
293142
+ **Not a dashboard at all: one plain nested table**, every \`# colspan\` and \`# break\` dropped. Either
293143
+ you tagged a single query with \`dashboard_columns=N\`, which only a composite reads, or a \`f'…'\`
293144
+ filter literal in a \`givens { … }\`
293145
+ block shares a line with \`# dashboard\`. For the second, put \`# dashboard\` on its own line: writing it
293146
+ first on the line does not help, a plain \`'Outerwear'\` or a bare date is fine, and the composite form
293147
+ is immune because its layout comes from the manifest rather than a re-parse.
293148
+
293149
+ **A dashboard, but nothing lines up**: you wrote \`# colspan\` without \`columns=N\`, so the items flow
293150
+ side by side at their natural widths instead of aligning to a grid.
293151
+
293152
+ To tell them apart, run the dashboard's own query, \`{"queryName": "<the manifest's query>"}\`, and read
293153
+ \`renderLogs\` on the response. Like \`warnings\`, the key is absent when there is nothing to say.
293154
+ Single-query dashboards have no \`tiles\` in their manifest, so there is no tile query to run:
293155
+
293156
+ | render log | what it means |
293157
+ |---|---|
293158
+ | \`Unknown render tag 'colspan'\` | the renderer never saw a \`# dashboard\` tag. It does **not** say which of the two causes; check both |
293159
+ | \`Ignored # colspan … only applies in columns mode\` | it saw the tag but there is no count |
293160
+
293161
+ Neither reaches the package warnings, so step 6 will not show either. A **wrapped \`##\` tag** is the
293162
+ one failure in this family that does: the file is absent from the listing and the package warnings
293163
+ say "Tag does not parse (Unclosed '{')".
293164
+
293165
+ ## Read the lint
293166
+
293167
+ Package warnings after a reload are the dashboard's test suite. Fix all of them:
293168
+
293169
+ - \`# drill … targets "x", which is not a dashboard in this package\`: a dead click.
293170
+ - \`to=self, but no model in this package declares a given "X"\`: the clicked value has nowhere to go.
293171
+ - \`given "X" suggests options from source "y", which this file does not define\`: the dropdown will
293172
+ be empty, so import it.
293173
+ - \`filters by given "X", which this file does not import, so no control is shown for it\`: the trap
293174
+ under "Importing a given is what makes it bindable", which the lint now names for you, with the file
293175
+ to fix.
293176
+ - A tile that does not resolve to a real view, or a non-positive \`dashboard_columns\`.
293177
+
293178
+ Findings carry a \`severity\`, but \`warn\` is the ordinary default and tells you nothing about how bad
293179
+ one is. Read the text, not the severity and not the count. One message is worth recognising because
293180
+ it changes what the rest of the list means: **"Dashboard lint stopped early, so this list is
293181
+ incomplete"**. A dashboard withheld from \`explores\` also loses its own findings, so a short list for a
293182
+ withheld file is not a clean bill of health.
293183
+
293184
+ **Read the status the reload itself returns, not the listing.** One dashboard that fails to compile
293185
+ fails the whole package load, and the reload answers **424** with the compile error. A package that
293186
+ was already serving then keeps serving its previous version, so the listing still answers 200 and
293187
+ looks perfectly healthy while your edit has silently not taken effect.
293188
+
293189
+ **If you did not catch the 424, \`GET /api/v0/status\` still knows.** A package serving an older model
293190
+ than its files appears in \`loadErrors\` with **\`stale: true\`**, the compile message, and the time it
293191
+ failed, and the entry clears on the next reload that compiles. That is the one check that works after
293192
+ the fact, so make it the first thing you run when a page will not change. A package that never loaded
293193
+ at all appears there too, without \`stale\`, and is absent from the listing entirely.
293194
+
293195
+ If the reload is 200 and the others are listed but yours is not, discovery skipped the file instead,
293196
+ usually a missing or misspelled \`# artifact\` tag, which is the same mechanism that deliberately skips
293197
+ an untagged shared include. There is a second cause if the package's \`publisher.json\` carries an
293198
+ \`explores\` list: a dashboard whose file is missing from it is withheld rather than served, and the
293199
+ warning says so and names the fix. The list is what matters, not the \`queryableSources\` setting, which
293200
+ is \`declared\` by default; a package with no \`explores\` list withholds nothing. Where there is one, a
293201
+ \`suggest\` source has to be queryable as well as resolvable, so it needs to be on the list too.
293202
+
293203
+ **A clean reload is not proof the tags are right.** The checks above read names and resolve them; the
293204
+ separate warning for a tag that does not *parse* is syntax only: it carries no
293205
+ position and says nothing about a name that does not resolve. It catches *a* malformed tag; its
293206
+ absence is not evidence there are none. That is why the last step is opening the page, not reading
293207
+ the warning list.
293208
+
293209
+ ## What "done" means
293210
+
293211
+ - Every source, view, and field name came from the model you read in step 1.
293212
+ - The reload returned **200**, not 424. A 424 means the page you are about to look at is the old one.
293213
+ - The package reloads with **zero** dashboard warnings, and your dashboard is in the listing at all.
293214
+ - **The page is a dashboard and its items line up.** This is the check the others cannot make for
293215
+ you: all of them pass both on a page that has become one plain nested table and on a dashboard whose
293216
+ items do not align. See "Losing the grid" for which you have.
293217
+ - You opened the page and every tile shows real numbers: not stuck loading, not an error, not an
293218
+ empty state you did not intend.
293219
+ - Each control renders as the widget you intended (a select shows options; a slider is a slider),
293220
+ and changing one changes the numbers.
293221
+ - If you added a \`# drill\`, you clicked it and landed where you meant to, with the given seeded.
293222
+ - On a single-query dashboard, every card and tile carries a colspan, each row's colspans sum to
293223
+ \`columns\`, and the rows end flush with each other. On a composite, the tiles fill the row rather
293224
+ than leaving a gap. Nothing is clipped, no tile is thousands of pixels tall, and no legend or card
293225
+ label ends in \`…\`.
293226
+
293227
+ ## Reference
293228
+
293229
+ - \`docs/dashboards.md\`: the full guide this skill condenses.
293230
+ - \`docs/givens.md\`: the givens the controls are generated from.` }, { name: "malloy-debug", description: 'Fix Malloy compile errors and understand error messages. Use when encountering errors in .malloy files, user says "fix this error", "malloy error", "compile error", "syntax error", or sees 20+ cascading errors.', body: `# Debugging Malloy Errors
291671
293231
 
291672
293232
  > **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.
291673
293233
 
@@ -292088,7 +293648,7 @@ view: overview is {
292088
293648
  - **Only seven palette keys take light/dark.** \`background\`, \`tableHeader\`, \`tableHeaderBackground\`, \`tableBody\`, \`tile\`, \`tileTitle\`, and \`mapColor\` each accept a \`.light\` and/or \`.dark\` variant. \`palette.series\`, \`font.family\`, and \`font.size\` are single values shared across modes; a \`.light\`/\`.dark\` on them does nothing.
292089
293649
  - **\`# theme.palette.mapColor.{light,dark}\` recolors choropleths only.** It sets the saturated end of the \`# shape_map\` / \`# segment_map\` gradient (per mode). Rect-mark heatmaps keep their built-in scheme.
292090
293650
  - **\`defaultMode\` and \`allowUserToggle\` are instance-only.** No per-chart annotation controls the light/dark default or the toggle lock; set them in the config \`theme\` block or the editor.
292091
- - **Environment-level theming is not applied yet.** Only the instance theme and the \`# theme.*\` / \`## theme.*\` per-chart annotations take effect today.` }, { name: "malloy-html-data-app-embedding", description: "Embed an in-package HTML data app into a host page or another application, including auto-sizing and auth. Read when embedding a Publisher page via Publisher.embed.", body: '# Embedding an HTML Data App\n\n> `Publisher.embed(selector, { src })` drops a package page into a host page as a sandboxed, auto-resizing iframe. Same-origin embeds authenticate with the browser\'s cookies; cross-origin embeds need a signed token.\n\n## The host-page pattern\n\n```html\n<script src="https://your-publisher/sdk/publisher.js"></script>\n<div id="dashboard"></div>\n<script>\n const handle = Publisher.embed("#dashboard", {\n src: "https://your-publisher/environments/demo/packages/sales/index.html",\n });\n // handle.destroy() removes the iframe and detaches its listeners.\n</script>\n```\n\n`embed(selector, options)` returns `{ iframe, destroy() }`. Options: `src` (required), `token` (a signed token for cross-origin auth, appended as `embed_token`), `height` (omit to auto-size; a number is treated as pixels), and `allow` (the iframe permissions policy).\n\n## Sizing and the resize contract\n\nOmit `height` and the frame auto-sizes. The embedded page measures its real content height and posts a `publisher:resize` message to the host, which resizes the iframe and accepts that message only from the iframe it created. You write none of this; it ships in `/sdk/publisher.js`, so the embedded page only has to load that script.\n\nDo not rely on `body { min-height: 100vh }` to drive the frame height. The runtime deliberately measures the content\'s bottom edge, not the viewport, to avoid a grow-forever loop.\n\n## Auth\n\n- Same-origin or same-tenant: pass no token. The browser\'s cookies authenticate the iframe.\n- Cross-origin: mint a short-lived signed token server-side and pass it as `options.token`. The runtime appends it to the iframe URL as `embed_token`; the embedded page must read it (from `location.search`) and call `Publisher.setToken(token)`. Because it rides in the URL, it can land in browser history, Referer headers, and server logs, so keep it short-lived and scoped to that one embed, and never put a long-lived or admin token in client HTML.\n\n## Guardrails (v1)\n\n- The iframe is sandboxed (`allow-scripts allow-same-origin allow-forms`). Design for that: no top-level navigation, no popups.\n- Embedded author JavaScript runs with the viewing user\'s data authority, so treat everything under `public/` as strictly first-party code: do not load untrusted third-party scripts, and do not move query results off to other hosts. Tighter per-embed isolation is planned.' }, { name: "malloy-html-data-app-runtime", description: "Write the JavaScript that drives an in-package HTML data app, calling Publisher.query, building queries from filter state, and handling results and errors. Read before writing the page's data code.", body: '# HTML Data App Runtime\n\n> `Publisher.query(modelPath, malloy)` returns an array of plain row objects. Build the Malloy string, let the model do the work, and render the rows with whatever front-end code you like.\n\nThe runtime loads from the root-relative `<script src="/sdk/publisher.js">` and adds one global, `window.Publisher`.\n\n## The query contract\n\n| Call | Returns | Use for |\n|---|---|---|\n| `Publisher.query(modelPath, malloy, opts?)` | `Promise<Array>` of rows | driving your own charts and tables |\n| `Publisher.queryFull(modelPath, malloy, opts?)` | `Promise<MalloyResult>` | handing to `<malloy-render>` |\n\n- `modelPath` is the model FILE path within the package, with `/` separators (`"subscriptions.malloy"`, `"models/events.malloy"`). It is not the source name.\n- `malloy` is any query string, written in standard Malloy. This skill covers only the JavaScript glue, not Malloy syntax.\n- `opts` (all optional): `sourceName`, `queryName`, `givens` (a `{ name: value }` map bound to the model\'s Malloy `given:` runtime parameters for this query; safe parameterization, values are bound by the runtime, not string-interpolated), `filterParams` (values for the model\'s legacy `#(filter)` source filters), `bypassFilters`, and `environment` / `package` (only if the page is served from outside `/environments/<env>/packages/<pkg>/`). `givens` and `filterParams` compose (both apply).\n\n## Structure the app as modules, not one inline script\n\nPast a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: put your shared libraries first as plain globals, then one ES-module entry point that `import`s your own files.\n\n```html\n<!-- Globals first: the runtime, then any vendored chart library. -->\n<script src="/sdk/publisher.js"></script>\n<script src="./vendor/chart.umd.js"></script>\n<!-- One module entry; it imports the rest. ES modules resolve with no bundler. -->\n<script type="module" src="./app.js"></script>\n```\n\nA separation that keeps each piece testable and changeable on its own:\n\n- **`format.js`**. Pure functions only: number/date formatting, a series-align-by-month helper, status thresholds. No DOM, no globals. This is the file `node --test` can cover directly.\n- **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library.\n- **`tiles.js`**. Your tiles as *data*: for each, its model/source/view (and target source, if any), plus a pure `build(rows)` that shapes query rows for the chart. This is the single source of truth for what each tile queries.\n- **`app.js`**. The thin entry point: reads `tiles.js`, runs the queries, wires results to the DOM. Adding a tile means adding a `tiles.js` entry, not editing `app.js`.\n\nDeclare each tile\'s source and view names once, in `tiles.js`, and have everything else (render code, any agent prompt, tests) read from there. A second copy of those names in another file is the classic drift bug, and a *derived* name (`okr_4_4_2_targets` invented from a tile code) is simply wrong: a target source may have an irregular name or not exist at all. Read the model; don\'t compute names.\n\n## Patterns that work\n\nThese run against the example `html-data-app` package (source `subscriptions`; views `plan_mix`, `mrr_by_industry`, `kpis`). Swap in your own model and view names.\n\nRun a named view:\n\n```js\nconst rows = await Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nRefine a view from UI state by appending a `where:`. Restrict the values to ones you control (for example a dropdown populated from the model\'s own distinct values) and escape each interpolated value with a backslash before quotes and backslashes (Malloy rejects the SQL-style `\'\'` doubling). An unescaped apostrophe in a value breaks out of the literal:\n\n```js\nfunction whereClause(state) {\n const q = (s) => s.replace(/\\\\/g, "\\\\\\\\").replace(/\'/g, "\\\\\'"); // backslash-escape for Malloy\n const parts = [];\n if (state.plan) parts.push(`plan = \'${q(state.plan)}\'`);\n if (state.industry) parts.push(`industry = \'${q(state.industry)}\'`);\n return parts.length ? `where: ${parts.join(", ")}` : "";\n}\nconst rows = await Publisher.query(\n "subscriptions.malloy",\n `run: subscriptions -> plan_mix + { ${whereClause(state)} }`,\n);\n```\n\nDo not interpolate free-text or otherwise untrusted input into the query string. Route parameterized input through `opts.givens` (or the legacy `opts.filterParams`) instead: those values are bound by the runtime as typed parameters, not string-interpolated, so they can\'t inject query syntax. (One nuance: a `filter<T>`-typed given takes Malloy filter syntax as its value, so validate it against a known set like any other input; scalar givens carry no syntax at all.) `opts.givens` is safe *parameterization*, not an authorization boundary: a client-supplied given is client-trusted unless a server upstream (a trusted gateway, or an operator\'s per-package config) strips or finalizes it. Where you must build query text from input, constrain it to a known set and escape it, or keep the filtering in model-defined views.\n\nKPI or single-row view. Destructure element zero:\n\n```js\nconst [kpis] = await Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis");\nel.textContent = kpis.active_mrr; // the result is an array; kpis.active_mrr, not rows.active_mrr\n```\n\nRefresh a dashboard. Fire the tiles together:\n\n```js\nconst [planMix, byIndustry, kpisRows] = await Promise.all([\n Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> mrr_by_industry"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis"),\n]);\n```\n\nPrefer defining the views in the model (one per tile, pre-aggregated and sorted) over building long query strings in JS.\n\nGet the numbers right. The fastest way to ship a wrong-but-convincing dashboard is to paper over missing data:\n\n- **Missing is not zero.** When you join two series (actuals to a separately-keyed target) and a key is absent, leave it `null` so the chart skips it. Do not `|| 0`, which plots a real-looking zero and reads as "we hit nothing that month." Align on a normalized key (`"YYYY-MM"`), and let the renderer omit null points:\n\n ```js\n // monthKey/monthLabel are your own format.js helpers: monthKey normalizes a\n // date to a "YYYY-MM" string; monthLabel formats it for display.\n // target may not cover every actual month; an absent month stays null, never 0.\n const target = new Map(planRows.map((r) => [monthKey(r.plan_month), Number(r.target_revenue)]));\n const data = actualRows.map((r) => ({\n label: monthLabel(r.order_month),\n actual: Number(r.revenue),\n target: target.has(monthKey(r.order_month)) ? target.get(monthKey(r.order_month)) : null,\n }));\n ```\n\n- **"Current" means latest non-null.** For a KPI scorecard, scan back to the last month that actually has data rather than reading the final row, which may be an incomplete current month.\n- **Guard division in Malloy, not after.** `avg(paid / nullif(active, 0))`. A `nullif` in the query beats catching `Infinity`/`NaN` in JS.\n- **Convert units explicitly.** If the model stores a 0 to 1 fraction and you show a percent, multiply once in `build()` and comment it. Mismatched units are a silent off-by-100.\n\nLoading, empty, and error states. Handle all three; a bare `.then()` that assumes rows leaves the page blank when the query is slow or fails:\n\n```js\nconst el = document.getElementById("out");\nel.textContent = "Loading...";\nPublisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix")\n .then((rows) => {\n if (!rows.length) { el.textContent = "No data."; return; }\n render(rows);\n })\n .catch((err) => {\n el.textContent = `Query failed (${err.status ?? ""}): ${err.response?.message ?? err.message}`;\n });\n```\n\nRender through `<malloy-render>`. `queryFull` returns the full Malloy result envelope (the JSON form of the server\'s result, not a live result object) to hand to the component:\n\n```js\nconst el = document.querySelector("malloy-render");\nel.result = await Publisher.queryFull("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nPublisher does not serve or bundle `<malloy-render>`; you must obtain a built component bundle matched to your model\'s Malloy version and vendor it into `public/` yourself, then confirm it accepts the envelope as-is. The shipped example renders rows with a plain chart library instead, so this path is not exercised there. A view tagged in the model (for example `# bar_chart`) drives how it draws.\n\nValidate every query before wiring it into render code, using whatever query tool your environment provides, or by POSTing the query to a running Publisher at `/api/v0/environments/<env>/packages/<pkg>/models/<modelPath>/query` with body `{"compactJson":true,"query":"..."}`, or by running `Publisher.query` once and logging the rows. Malloy names result columns after the `group_by` / `aggregate` field names (`group_by: plan` gives a `plan` column; `aggregate: account_count` gives an `account_count` column), so confirm those names against real output before you read them.\n\nIf you validate the rendered page in a headless browser (Playwright or Puppeteer), do not wait for network idle: `publisher.js` holds the live-reload SSE stream open, so the page never reaches it. Wait on `domcontentloaded` or `load` plus a content selector instead.\n\n## Context, auth, live reload (all automatic)\n\n- Context. A page served under `/environments/<env>/packages/<pkg>/...` infers its environment and package, so `query` needs no env or package args. Serving from elsewhere? Pass `opts.environment` and `opts.package`.\n- Auth. By default the runtime sends cookies (`credentials: include`), so a signed-in user is authenticated with no code. For a bearer token, call `Publisher.setToken(token)` first; `Publisher.setToken(null)` reverts to cookies.\n- Live reload. Under `--watch-env`, the page reloads on package changes by itself. Nothing to wire.\n\n## When the app fails\n\n| Symptom | Likely cause and fix |\n|---|---|\n| 404 or "model not found" | `modelPath` wrong. It is the file path (`"subscriptions.malloy"`), with `/` separators, not the source name. |\n| "source/view not defined" | View or source name guessed. Read the model (your environment\'s context tool, or open the `.malloy` file) and use the real names. |\n| Promise rejects, message starts `Publisher.query:` | Read `error.status` and `error.response` for the server\'s reason (compile error, missing required parameter, permission). |\n| Empty array when you expect rows | Filter value mismatch (case, spelling, type, or a non-ASCII character like `≤` or an en-dash in the literal). Copy the literal verbatim from the model, do not retype the user\'s paraphrase, and confirm it with a distinct-values query (`run: src -> { group_by: the_dimension }`). Quote strings, use `@` for dates. |\n| 400 on a given (ungated source) | An unknown given name (check spelling; names are case-sensitive), a required given left unset, or a value that doesn\'t fit the declared type. Malloy rejects it when preparing the query; supply declared givens via `opts.givens` with the right shape (see the givens type table). |\n| 403 on a query that should be allowed, when passing givens to a gated source | On a source with `#(authorize)`, a bad given (unknown name or wrong-typed value) fails closed in the authorize check, so it looks like access denied rather than validation. Check the given names and values against the model. |\n| KPI shows `undefined` | The result is an array. Read `rows[0].field` (or destructure `const [k] = ...`), not `rows.field`. |\n| Page loads in dev but is not listed or not served | The file is not under the package\'s `public/` directory. Publisher serves only `public/`; a page written anywhere else (for example `/tmp`) is never reachable at `/environments/<env>/packages/<pkg>/<file>`. |\n| Queries fail only when embedded cross-origin | Cookies are not sent cross-site. Serve same-origin, or pass a bearer token. |\n| No live reload | Watch mode is off. Start with `--watch-env <env>`; without it the events stream reports `mode: disabled` and never reloads. |' }, { name: "malloy-html-data-apps", description: "Build or modify an in-package HTML data app for a Malloy Publisher package (a public/ directory the package serves). Use when the user wants a hand-authored HTML dashboard or web page backed by a package's Malloy models, with no build step.", body: `# In-Package HTML Data Apps
293651
+ - **Environment-level theming is not applied yet.** Only the instance theme and the \`# theme.*\` / \`## theme.*\` per-chart annotations take effect today.` }, { name: "malloy-html-data-app-embedding", description: "Embed an in-package HTML data app into a host page or another application, including auto-sizing and auth. Read when embedding a Publisher page via Publisher.embed.", body: '# Embedding an HTML Data App\n\n> `Publisher.embed(selector, { src })` drops a package page into a host page as a sandboxed, auto-resizing iframe. Same-origin embeds authenticate with the browser\'s cookies; cross-origin embeds need a signed token.\n\n## The host-page pattern\n\n```html\n<script src="https://your-publisher/sdk/publisher.js"></script>\n<div id="dashboard"></div>\n<script>\n const handle = Publisher.embed("#dashboard", {\n src: "https://your-publisher/environments/demo/packages/sales/index.html",\n });\n // handle.destroy() removes the iframe and detaches its listeners.\n</script>\n```\n\n`embed(selector, options)` returns `{ iframe, destroy() }`. Options: `src` (required), `token` (a signed token for cross-origin auth, appended as `embed_token`), `height` (omit to auto-size; a number is treated as pixels), and `allow` (the iframe permissions policy).\n\n## Sizing and the resize contract\n\nOmit `height` and the frame auto-sizes. The embedded page measures its real content height and posts a `publisher:resize` message to the host, which resizes the iframe and accepts that message only from the iframe it created. You write none of this; it ships in `/sdk/publisher.js`, so the embedded page only has to load that script.\n\nDo not rely on `body { min-height: 100vh }` to drive the frame height. The runtime deliberately measures the content\'s bottom edge, not the viewport, to avoid a grow-forever loop.\n\n## Auth\n\n- Same-origin or same-tenant: pass no token. The browser\'s cookies authenticate the iframe.\n- Cross-origin: mint a short-lived signed token server-side and pass it as `options.token`. The runtime appends it to the iframe URL as `embed_token`; the embedded page must read it (from `location.search`) and call `Publisher.setToken(token)`. Because it rides in the URL, it can land in browser history, Referer headers, and server logs, so keep it short-lived and scoped to that one embed, and never put a long-lived or admin token in client HTML.\n\n## Guardrails (v1)\n\n- The iframe is sandboxed (`allow-scripts allow-same-origin allow-forms`). Design for that: no top-level navigation, no popups.\n- Embedded author JavaScript runs with the viewing user\'s data authority, so treat everything under `public/` as strictly first-party code: do not load untrusted third-party scripts, and do not move query results off to other hosts. Tighter per-embed isolation is planned.' }, { name: "malloy-html-data-app-runtime", description: "Write the JavaScript that drives an in-package HTML data app, calling Publisher.query, building queries from filter state, and handling results and errors. Read before writing the page's data code.", body: '# HTML Data App Runtime\n\n> `Publisher.query(modelPath, malloy)` returns an array of plain row objects. Build the Malloy string, let the model do the work, and render the rows with whatever front-end code you like.\n\nThe runtime loads from the root-relative `<script src="/sdk/publisher.js">` and adds one global, `window.Publisher`.\n\n## The query contract\n\n| Call | Returns | Use for |\n|---|---|---|\n| `Publisher.query(modelPath, malloy, opts?)` | `Promise<Array>` of rows | driving your own charts and tables |\n| `Publisher.queryFull(modelPath, malloy, opts?)` | `Promise<MalloyResult>` | handing to `<malloy-render>` |\n\n- `modelPath` is the model FILE path within the package, with `/` separators (`"subscriptions.malloy"`, `"models/events.malloy"`). It is not the source name.\n- `malloy` is any query string, written in standard Malloy. This skill covers only the JavaScript glue, not Malloy syntax.\n- `opts` (all optional): `sourceName`, `queryName` (`queryName` runs a saved query, with `sourceName` qualifying the source a view hangs off; `sourceName` on its own is a 400), `givens` (a `{ name: value }` map bound to the model\'s Malloy `given:` runtime parameters for this query; safe parameterization, values are bound by Publisher server-side, not string-interpolated), `filterParams` (values for the model\'s legacy `#(filter)` source filters), `bypassFilters`, and `environment` / `package` (only if the page is served from outside `/environments/<env>/packages/<pkg>/`). `givens` and `filterParams` compose (both apply).\n\n## Structure the app as modules, not one inline script\n\nPast a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: put your shared libraries first as plain globals, then one ES-module entry point that `import`s your own files.\n\n```html\n<!-- Globals first: the runtime, then any vendored chart library. -->\n<script src="/sdk/publisher.js"></script>\n<script src="./vendor/chart.umd.js"></script>\n<!-- One module entry; it imports the rest. ES modules resolve with no bundler. -->\n<script type="module" src="./app.js"></script>\n```\n\nA separation that keeps each piece testable and changeable on its own:\n\n- **`format.js`**. Pure functions only: number/date formatting, a series-align-by-month helper, status thresholds. No DOM, no globals. This is the file `node --test` can cover directly.\n- **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library.\n- **`tiles.js`**. Your tiles as *data*: for each, its model/source/view (and target source, if any), plus a pure `build(rows)` that shapes query rows for the chart. This is the single source of truth for what each tile queries.\n- **`app.js`**. The thin entry point: reads `tiles.js`, runs the queries, wires results to the DOM. Adding a tile means adding a `tiles.js` entry, not editing `app.js`.\n\nDeclare each tile\'s source and view names once, in `tiles.js`, and have everything else (render code, any agent prompt, tests) read from there. A second copy of those names in another file is the classic drift bug, and a *derived* name (`okr_4_4_2_targets` invented from a tile code) is simply wrong: a target source may have an irregular name or not exist at all. Read the model; don\'t compute names.\n\n## Patterns that work\n\nThese assume a `subscriptions.malloy` model whose `subscriptions` source defines the views `plan_mix`, `mrr_by_industry`, and `kpis`. The names are illustrative; swap in your own model and view names.\n\nRun a named view:\n\n```js\nconst rows = await Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nRefine a view from UI state by appending a `where:`. Restrict the values to ones you control (for example a dropdown populated from the model\'s own distinct values) and escape each interpolated value with a backslash before quotes and backslashes (Malloy rejects the SQL-style `\'\'` doubling). An unescaped apostrophe in a value breaks out of the literal:\n\n```js\nfunction whereClause(state) {\n const q = (s) => s.replace(/\\\\/g, "\\\\\\\\").replace(/\'/g, "\\\\\'"); // backslash-escape for Malloy\n const parts = [];\n if (state.plan) parts.push(`plan = \'${q(state.plan)}\'`);\n if (state.industry) parts.push(`industry = \'${q(state.industry)}\'`);\n return parts.length ? `where: ${parts.join(", ")}` : "";\n}\nconst rows = await Publisher.query(\n "subscriptions.malloy",\n `run: subscriptions -> plan_mix + { ${whereClause(state)} }`,\n);\n```\n\nDo not interpolate free-text or otherwise untrusted input into the query string. Route parameterized input through `opts.givens` (or the legacy `opts.filterParams`) instead: Publisher binds those values server-side as typed parameters, so they are never concatenated into query text and can\'t inject query syntax. (One nuance: a `filter<T>`-typed given takes Malloy filter syntax as its value, so validate it against a known set like any other input; scalar givens carry no syntax at all.) `opts.givens` is safe *parameterization*, not an authorization boundary: a client-supplied given is client-trusted unless a trusted tier upstream sets it from verified identity. Publisher has no per-package control that strips or finalizes one; identity-bound givens are a planned milestone, not a shipped feature. Where you must build query text from input, constrain it to a known set and escape it, or keep the filtering in model-defined views.\n\nKPI or single-row view. Destructure element zero:\n\n```js\nconst [kpis] = await Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis");\nel.textContent = kpis.active_mrr; // the result is an array; kpis.active_mrr, not rows.active_mrr\n```\n\nRefresh a dashboard. Fire the tiles together:\n\n```js\nconst [planMix, byIndustry, kpisRows] = await Promise.all([\n Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> mrr_by_industry"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis"),\n]);\n```\n\nPrefer defining the views in the model (one per tile, pre-aggregated and sorted) over building long query strings in JS.\n\nGet the numbers right. The fastest way to ship a wrong-but-convincing dashboard is to paper over missing data:\n\n- **Missing is not zero.** When you join two series (actuals to a separately-keyed target) and a key is absent, leave it `null` so the chart skips it. Do not `|| 0`, which plots a real-looking zero and reads as "we hit nothing that month." Align on a normalized key (`"YYYY-MM"`), and let the renderer omit null points:\n\n ```js\n // monthKey/monthLabel are your own format.js helpers: monthKey normalizes a\n // date to a "YYYY-MM" string; monthLabel formats it for display.\n // target may not cover every actual month; an absent month stays null, never 0.\n const target = new Map(planRows.map((r) => [monthKey(r.plan_month), Number(r.target_revenue)]));\n const data = actualRows.map((r) => ({\n label: monthLabel(r.order_month),\n actual: Number(r.revenue),\n target: target.has(monthKey(r.order_month)) ? target.get(monthKey(r.order_month)) : null,\n }));\n ```\n\n- **"Current" means latest non-null.** For a KPI scorecard, scan back to the last month that actually has data rather than reading the final row, which may be an incomplete current month.\n- **Guard division in Malloy, not after.** `avg(paid / nullif(active, 0))`. A `nullif` in the query beats catching `Infinity`/`NaN` in JS.\n- **Convert units explicitly.** If the model stores a 0 to 1 fraction and you show a percent, multiply once in `build()` and comment it. Mismatched units are a silent off-by-100.\n\nLoading, empty, and error states. Handle all three; a bare `.then()` that assumes rows leaves the page blank when the query is slow or fails:\n\n```js\nconst el = document.getElementById("out");\nel.textContent = "Loading...";\nPublisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix")\n .then((rows) => {\n if (!rows.length) { el.textContent = "No data."; return; }\n render(rows);\n })\n .catch((err) => {\n el.textContent = `Query failed (${err.status ?? ""}): ${err.response?.message ?? err.message}`;\n });\n```\n\nRender through `<malloy-render>`. `queryFull` returns the full Malloy result envelope (the JSON form of the server\'s result, not a live result object) to hand to the component:\n\n```js\nconst el = document.querySelector("malloy-render");\nel.result = await Publisher.queryFull("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nPublisher does not serve or bundle `<malloy-render>`; you must obtain a built component bundle matched to your model\'s Malloy version and vendor it into `public/` yourself, then confirm it accepts the envelope as-is. The `storefront` example draws with Chart.js and has no `<malloy-render>` element in its pages, so the component itself is not exercised there. A view tagged in the model (for example `# bar_chart`) drives how it draws.\n\nValidate every query before wiring it into render code, using whatever query tool your environment provides, or by POSTing the query to a running Publisher at `/api/v0/environments/<env>/packages/<pkg>/models/<modelPath>/query` with body `{"compactJson":true,"query":"..."}`, or by running `Publisher.query` once and logging the rows. Malloy names result columns after the `group_by` / `aggregate` field names (`group_by: plan` gives a `plan` column; `aggregate: account_count` gives an `account_count` column), so confirm those names against real output before you read them.\n\nIf you validate the rendered page in a headless browser (Playwright or Puppeteer), do not wait for network idle: `publisher.js` holds the live-reload SSE stream open, so the page never reaches it. Wait on `domcontentloaded` or `load` plus a content selector instead.\n\n## Context, auth, live reload (all automatic)\n\n- Context. A page served under `/environments/<env>/packages/<pkg>/...` infers its environment and package, so `query` needs no env or package args. Serving from elsewhere? Pass `opts.environment` and `opts.package`.\n- Auth. By default the runtime sends cookies (`credentials: include`), so a signed-in user is authenticated with no code. For a bearer token, call `Publisher.setToken(token)` first; `Publisher.setToken(null)` reverts to cookies.\n- Live reload. Under `--watch-env`, the page reloads on package changes by itself. Nothing to wire.\n\n## When the app fails\n\n| Symptom | Likely cause and fix |\n|---|---|\n| 404 or "model not found" | `modelPath` wrong. It is the file path (`"subscriptions.malloy"`), with `/` separators, not the source name. |\n| "source/view not defined" | View or source name guessed. Read the model (your environment\'s context tool, or open the `.malloy` file) and use the real names. |\n| Promise rejects, message starts `Publisher.query:` | Read `error.status` and `error.response` for the server\'s reason (compile error, missing required parameter, permission). |\n| Empty array when you expect rows | Filter value mismatch (case, spelling, type, or a non-ASCII character like `≤` or an en-dash in the literal). Copy the literal verbatim from the model, do not retype the user\'s paraphrase, and confirm it with a distinct-values query (`run: src -> { group_by: the_dimension }`). Quote strings, use `@` for dates. |\n| 400 on a given (ungated source) | An unknown given name (check spelling; names are case-sensitive), a required given left unset, or a value that doesn\'t fit the declared type. Malloy rejects it when preparing the query; supply declared givens via `opts.givens` with the right shape (see the givens type table). |\n| 403 on a query that should be allowed, when passing givens to a gated source | On a source with `#(authorize)`, a bad given (unknown name or wrong-typed value) fails closed in the authorize check, so it looks like access denied rather than validation. Check the given names and values against the model. |\n| KPI shows `undefined` | The result is an array. Read `rows[0].field` (or destructure `const [k] = ...`), not `rows.field`. |\n| Page loads in dev but is not listed or not served | The file is not under the package\'s `public/` directory. Publisher serves only `public/`; a page written anywhere else (for example `/tmp`) is never reachable at `/environments/<env>/packages/<pkg>/<file>`. |\n| Queries fail only when embedded cross-origin | Cookies are not sent cross-site. Serve same-origin, or pass a bearer token. |\n| No live reload | Watch mode is off. Start with `--watch-env <env>`; without it the events stream reports `mode: disabled` and never reloads. |' }, { name: "malloy-html-data-apps", description: "Build or modify an in-package HTML data app for a Malloy Publisher package (a public/ directory the package serves). Use when the user wants a hand-authored HTML dashboard or web page backed by a package's Malloy models, with no build step.", body: `# In-Package HTML Data Apps
292092
293652
 
292093
293653
  > A package becomes a web app by adding a \`public/\` directory. Publisher serves those files and gives the page \`Publisher.query(...)\` to run Malloy against the package's models. No build step, no npm, no framework.
292094
293654
 
@@ -292126,7 +293686,7 @@ The agent orchestrates these. Each query and chart step hands off to a focused s
292126
293686
  1. READ THE MODEL FIRST. Get the model's real source and view names, through your environment's context tool if it has one, or by opening the \`.malloy\` file directly. Never guess field or view names.
292127
293687
  2. SCAFFOLD the package (template below).
292128
293688
  3. WRITE THE QUERIES with \`skill:malloy-html-data-app-runtime\`. Validate each before pasting it into the page, using whatever query tool your environment provides or a running Publisher (see \`skill:malloy-html-data-app-runtime\`). Malloy syntax questions go to \`skill:malloy-queries\`.
292129
- 4. CHOOSE CHARTS with \`skill:malloy-charts\` when rendering through \`<malloy-render>\`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into \`public/\` and load it locally, not from a CDN. Two reasons: embedded author JavaScript runs with the viewing user's data authority, and a blocked CDN (agent sandboxes and many corporate networks block them) is easy to miss, because the script never runs and the charts come up empty. The \`html-data-app\` and \`storefront\` examples both ship their chart library in \`public/vendor/\` and load it from \`public/index.html\` as \`./vendor/chart.umd.js\`. Copy that, but resolve the path against the page's own directory: a page in a subdirectory (\`public/reports/index.html\`) needs \`../vendor/chart.umd.js\`. A wrong relative path 404s and leaves the charts blank, which is the failure you are trying to avoid.
293689
+ 4. CHOOSE CHARTS with \`skill:malloy-charts\` when rendering through \`<malloy-render>\`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into \`public/\` and load it locally, not from a CDN. Two reasons: embedded author JavaScript runs with the viewing user's data authority, and a blocked CDN (agent sandboxes and many corporate networks block them) is easy to miss, because the script never runs and the charts come up empty. The \`storefront\` example ships its chart library in \`public/vendor/\` and loads it from \`public/index.html\` as \`./vendor/chart.umd.js\`. Copy that, but resolve the path against the page's own directory: a page in a subdirectory (\`public/reports/index.html\`) needs \`../vendor/chart.umd.js\`. A wrong relative path 404s and leaves the charts blank, which is the failure you are trying to avoid.
292130
293690
  5. EMBED (optional) with \`skill:malloy-html-data-app-embedding\`.
292131
293691
  6. PREVIEW with the local authoring loop (below).
292132
293692
  7. VERIFY before you call it done (see "What 'done' means" below). This step is not optional.
@@ -292163,7 +293723,7 @@ You are building for someone who cannot tell a correct dashboard from a broken o
292163
293723
  { "name": "my-package", "version": "0.0.1", "description": "..." }
292164
293724
  \`\`\`
292165
293725
 
292166
- \`public/index.html\` is a NEW file you create (make the \`public/\` directory if it does not exist). Load the runtime root-relative, then query. The examples below use the shipped \`html-data-app\` package (source \`subscriptions\`); swap in your own model and a view it defines.
293726
+ \`public/index.html\` is a NEW file you create (make the \`public/\` directory if it does not exist). Load the runtime root-relative, then query. The examples below assume a \`subscriptions.malloy\` model with a \`subscriptions\` source; the names are illustrative, so swap in your own model and a view it defines.
292167
293727
 
292168
293728
  Start with the smallest page that proves the wiring, dumping the rows:
292169
293729
 
@@ -297623,7 +299183,12 @@ var ASSET_EXTENSIONS = new Set([
297623
299183
  "woff2",
297624
299184
  "xlsx"
297625
299185
  ]);
297626
- var SPA_OWNED_SEGMENTS = new Set(["data-apps", "pages", "workbook"]);
299186
+ var SPA_OWNED_SEGMENTS = new Set([
299187
+ "dashboards",
299188
+ "data-apps",
299189
+ "pages",
299190
+ "workbook"
299191
+ ]);
297627
299192
  function extensionOf(segment) {
297628
299193
  const dot = segment.lastIndexOf(".");
297629
299194
  if (dot <= 0 || dot === segment.length - 1)
@@ -297772,6 +299337,7 @@ var memoryGovernor = memoryGovernorConfig ? new PackageMemoryGovernor(memoryGove
297772
299337
  memoryGovernor?.start();
297773
299338
  environmentStore.setMemoryGovernor(memoryGovernor);
297774
299339
  var packageController = new PackageController(environmentStore);
299340
+ var dashboardController = new DashboardController(environmentStore);
297775
299341
  var databaseController = new DatabaseController(environmentStore);
297776
299342
  var queryController = new QueryController(environmentStore);
297777
299343
  var compileController = new CompileController(environmentStore);
@@ -298487,6 +300053,32 @@ app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/mode
298487
300053
  res.status(status).json(json);
298488
300054
  }
298489
300055
  });
300056
+ app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/dashboards`, async (req, res) => {
300057
+ if (req.query.versionId) {
300058
+ setVersionIdError2(res);
300059
+ return;
300060
+ }
300061
+ try {
300062
+ res.status(200).json(await dashboardController.listDashboards(req.params.environmentName, req.params.packageName));
300063
+ } catch (error) {
300064
+ logger.error(error);
300065
+ const { json, status } = internalErrorToHttpError(error);
300066
+ res.status(status).json(json);
300067
+ }
300068
+ });
300069
+ app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/dashboards/:dashboardName`, async (req, res) => {
300070
+ if (req.query.versionId) {
300071
+ setVersionIdError2(res);
300072
+ return;
300073
+ }
300074
+ try {
300075
+ res.status(200).json(await dashboardController.getDashboard(req.params.environmentName, req.params.packageName, req.params.dashboardName));
300076
+ } catch (error) {
300077
+ logger.error(error);
300078
+ const { json, status } = internalErrorToHttpError(error);
300079
+ res.status(status).json(json);
300080
+ }
300081
+ });
298490
300082
  app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/notebooks`, async (req, res) => {
298491
300083
  if (req.query.versionId) {
298492
300084
  setVersionIdError2(res);