@malloy-publisher/server 0.0.247 → 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 +411 -29
  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 +11914 -9572
  19. package/dist/server.mjs +2773 -302
  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();
@@ -246982,11 +247823,67 @@ var init_package_load_pool = __esm(() => {
246982
247823
  });
246983
247824
 
246984
247825
  // src/service/authorize.ts
247826
+ import { payloadOf, routeOf } from "@malloydata/malloy";
247827
+ function noteRoute(text) {
247828
+ return routeOf({ value: text.trimStart() });
247829
+ }
247830
+ function notePayload(text) {
247831
+ return payloadOf({ value: text.trimStart() }) ?? "";
247832
+ }
247833
+ function authorizeNoteContent(text) {
247834
+ return noteRoute(text) === AUTHORIZE_ROUTE ? notePayload(text) : undefined;
247835
+ }
246985
247836
  function assertNoCallerAuthorizeAnnotation(callerText) {
246986
247837
  if (!AUTHORIZE_ANNOTATION_ANYWHERE.test(callerText))
246987
247838
  return;
246988
247839
  throw new BadRequestError("An `authorize` annotation is not permitted in caller-submitted Malloy " + "text. Access gates are declared by the model author on the source; a " + "request cannot introduce, replace, or relax one. To validate a gate " + "you are authoring, save it to the package's model file and reload the " + "package — model load validates every `#(authorize)` annotation it " + "declares.");
246989
247840
  }
247841
+ function containsAuthorizeAnnotationTag(texts) {
247842
+ return texts.some((text) => authorizeNoteContent(text) !== undefined);
247843
+ }
247844
+ function collectAuthorizeNearMisses(texts) {
247845
+ const found = [];
247846
+ for (const text of texts) {
247847
+ const trimmed2 = text.trimStart();
247848
+ const route = noteRoute(trimmed2);
247849
+ if (route === AUTHORIZE_ROUTE)
247850
+ continue;
247851
+ const nearMiss = route === undefined ? MALFORMED_AUTHORIZE_ATTEMPT.test(trimmed2) : route === "" ? MOTLY_AUTHORIZE_PAYLOAD.test(notePayload(trimmed2)) : route.toLowerCase() === AUTHORIZE_ROUTE;
247852
+ if (!nearMiss)
247853
+ continue;
247854
+ found.push(trimmed2.split(/[\r\n]/, 1)[0]);
247855
+ }
247856
+ return found;
247857
+ }
247858
+ function assertNoAuthorizeNearMisses(found) {
247859
+ if (found.length === 0)
247860
+ return;
247861
+ const unique = [...new Set(found)];
247862
+ throw new ModelCompilationError({
247863
+ message: `These annotations are not \`authorize\` gates and nothing enforces ` + `them:
247864
+ ${unique.map((t) => ` - \`${t}\``).join(`
247865
+ `)}
247866
+ ` + `Malloy routes an annotation by its prefix, and only ` + `\`#(authorize)\` (or \`##(authorize)\`, or the block form ` + `\`#|(authorize)\`) reaches the authorize route — a space after the ` + `\`#\`, spaces inside the brackets, or anything trailing the closing ` + `bracket makes it a plain tag Malloy hands to something else. Write ` + `\`#(authorize) "<expression>"\` on the \`source:\` statement you mean ` + `to protect. This is refused rather than interpreted: guessing at the ` + `intent would let publisher start enforcing a filter on a package that ` + `has been serving every row.`
247867
+ });
247868
+ }
247869
+ function describeMisplacedAuthorizeAnnotation(f) {
247870
+ if (f.kind === "query")
247871
+ return `on query "${f.name}"`;
247872
+ if (f.kind === "file")
247873
+ return "at the file level (`##(authorize)`)";
247874
+ return `on field "${f.fieldName}" of source "${f.name}"`;
247875
+ }
247876
+ function assertNoMisplacedAuthorizeAnnotations(found) {
247877
+ if (found.length === 0)
247878
+ return;
247879
+ const positions = found.map((f) => ` - ${describeMisplacedAuthorizeAnnotation(f)}`).join(`
247880
+ `);
247881
+ throw new ModelCompilationError({
247882
+ message: `An \`#(authorize)\` annotation is never enforced at:
247883
+ ${positions}
247884
+ ` + `A gate only applies where model load looks for one — a \`source:\`'s ` + `own annotation, or one it inherits from an \`extend\`/query-source ` + `base. File-level \`##(authorize)\` is deprecated and no longer ` + `enforced anywhere, so it always lands here: declare \`#(authorize)\` ` + `on each \`source:\` it was meant to protect instead. Every other ` + `position above should move to the \`source:\` statement it is meant ` + `to protect.`
247885
+ });
247886
+ }
246990
247887
  function buildAuthorizeProbe(exprs, givenDecls = []) {
246991
247888
  const selects = exprs.map((expr, i) => `__auth_${i} is (${expr})`).join(`
246992
247889
  `);
@@ -247014,6 +247911,158 @@ function referencedGivenNames(expr) {
247014
247911
  }
247015
247912
  return names;
247016
247913
  }
247914
+ function classifyAuthorizeGate(condition, declaredTypes, declaredDefaults) {
247915
+ const fieldUsage = condition.refSummary?.fieldUsage;
247916
+ if (!Array.isArray(fieldUsage) || fieldUsage.length === 0) {
247917
+ return { shape: "given_only" };
247918
+ }
247919
+ const givenNames = [];
247920
+ const literalAtoms = [];
247921
+ let rejection;
247922
+ const reject = (cause, detail) => {
247923
+ rejection ??= { shape: "rejected", cause, detail };
247924
+ return false;
247925
+ };
247926
+ const givenOperand = (node) => {
247927
+ const n = asNode(node);
247928
+ return n && n.node === "given" && typeof n.refName === "string" ? n.refName : null;
247929
+ };
247930
+ const isLiteralOperand = (node) => {
247931
+ const n = asNode(node);
247932
+ return !!n && (n.node === "numberLiteral" || n.node === "stringLiteral" || n.node === "true" || n.node === "false");
247933
+ };
247934
+ const literalOperandText = (node) => {
247935
+ const n = asNode(node);
247936
+ if (!n)
247937
+ return null;
247938
+ if (n.node === "true" || n.node === "false")
247939
+ return n.node;
247940
+ if (n.node === "numberLiteral" && typeof n.literal === "string") {
247941
+ return n.literal;
247942
+ }
247943
+ if (n.node === "stringLiteral" && typeof n.literal === "string") {
247944
+ return `'${n.literal.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
247945
+ }
247946
+ return null;
247947
+ };
247948
+ const declaredTypeOf = (name) => {
247949
+ const declared = declaredTypes.get(name);
247950
+ if (declared === undefined) {
247951
+ reject("unreachable_given", `\`$${name}\` is not on this model's given surface, so the gate ` + `would bind its declaration default rather than the caller's ` + `value. Declare it here, importing it if it lives elsewhere ` + `(\`import { ${name} } from "…"\`)`);
247952
+ return null;
247953
+ }
247954
+ return declared;
247955
+ };
247956
+ const walk = (node, depth) => {
247957
+ if (depth > MAX_GATE_WALK_DEPTH) {
247958
+ return reject("unsupported_node", "the gate nests too deeply to read");
247959
+ }
247960
+ const n = asNode(node);
247961
+ if (!n || typeof n.node !== "string") {
247962
+ return reject("unsupported_node", "the gate has an unreadable shape");
247963
+ }
247964
+ const kind = n.node;
247965
+ if (BOOLEAN_NODES.has(kind)) {
247966
+ const kids = asNode(n.kids);
247967
+ if (!kids) {
247968
+ return reject("unsupported_node", `\`${kind}\` has no operands`);
247969
+ }
247970
+ return walk(kids.left, depth + 1) && walk(kids.right, depth + 1);
247971
+ }
247972
+ if (TRANSPARENT_NODES.has(kind)) {
247973
+ return walk(n.e, depth + 1);
247974
+ }
247975
+ if (kind === "inGiven") {
247976
+ if (n.not === true) {
247977
+ return reject("unsupported_node", "a negated membership test (`not in`) is not an access rule; " + "write the gate as the set of rows a caller MAY read");
247978
+ }
247979
+ const given = givenOperand(n.givenRef);
247980
+ if (given === null) {
247981
+ return reject("unsupported_node", "`in` must test membership of a declared given");
247982
+ }
247983
+ const declared = declaredTypeOf(given);
247984
+ if (declared === null)
247985
+ return false;
247986
+ if (!isArrayType(declared)) {
247987
+ return reject("scalar_given_rejects_in", `\`$${given}\` is declared \`${declared}\` (a scalar), so ` + `\`in $${given}\` is not a membership test. Compare it with ` + `\`=\` instead`);
247988
+ }
247989
+ givenNames.push(given);
247990
+ return walkFieldOperand(n.e);
247991
+ }
247992
+ if (SCALAR_COMPARISON_NODES.has(kind)) {
247993
+ const kids = asNode(n.kids);
247994
+ if (!kids) {
247995
+ return reject("unsupported_node", `\`${kind}\` has no operands`);
247996
+ }
247997
+ const left = givenOperand(kids.left);
247998
+ const right = givenOperand(kids.right);
247999
+ const given = left ?? right;
248000
+ if (given === null) {
248001
+ return reject("no_given_reference", `\`${kind}\` must compare a field against a given; a comparison ` + `against a constant is a fixed filter and belongs in the ` + `source's own \`where:\``);
248002
+ }
248003
+ const declared = declaredTypeOf(given);
248004
+ if (declared === null)
248005
+ return false;
248006
+ if (isArrayType(declared)) {
248007
+ return reject("array_given_needs_in", `\`$${given}\` is declared \`${declared}\` (an array), so ` + `comparing it with \`${kind}\` compiles and then fails in the ` + `warehouse. Write \`in $${given}\` — it is also the spelling ` + `that matches no rows when the array is empty`);
248008
+ }
248009
+ const otherSide = left === null ? kids.left : kids.right;
248010
+ if (isLiteralOperand(otherSide)) {
248011
+ givenNames.push(given);
248012
+ const literalText = literalOperandText(otherSide);
248013
+ if (literalText !== null) {
248014
+ literalAtoms.push(left !== null ? `$${given} ${kind} ${literalText}` : `${literalText} ${kind} $${given}`);
248015
+ }
248016
+ return true;
248017
+ }
248018
+ if (declaredDefaults.has(given)) {
248019
+ return reject("field_given_has_default", `\`${kind}\` compares row field data against \`$${given}\`, ` + `which is declared with a default (\`${declaredDefaults.get(given)}\`). ` + `A caller who supplies no value for \`$${given}\` gets that ` + `default, and the comparison then applies to every row — ` + `admitting rows it was meant to exclude. Declare \`$${given}\` ` + `with no default so a caller must supply one explicitly`);
248020
+ }
248021
+ givenNames.push(given);
248022
+ return walkFieldOperand(otherSide);
248023
+ }
248024
+ return reject("unsupported_node", `\`${kind}\` is not permitted in a gate; a row-level gate is a ` + `boolean combination of \`<field> <operator> $GIVEN\` comparisons`);
248025
+ };
248026
+ const walkFieldOperand = (node) => {
248027
+ const n = asNode(node);
248028
+ if (!n || n.node !== "field" || !Array.isArray(n.path)) {
248029
+ return reject("unsupported_node", `a gate compares a FIELD against a given; \`${asNode(node)?.node ?? "that operand"}\` is not a field reference`);
248030
+ }
248031
+ return true;
248032
+ };
248033
+ let ok;
248034
+ try {
248035
+ ok = walk(condition.e, 0);
248036
+ } catch {
248037
+ return {
248038
+ shape: "rejected",
248039
+ cause: "unsupported_node",
248040
+ detail: "the gate's compiled shape could not be read"
248041
+ };
248042
+ }
248043
+ if (!ok) {
248044
+ return rejection ?? {
248045
+ shape: "rejected",
248046
+ cause: "unsupported_node",
248047
+ detail: "the gate is not an allowed shape"
248048
+ };
248049
+ }
248050
+ if (givenNames.length === 0) {
248051
+ return {
248052
+ shape: "rejected",
248053
+ cause: "no_given_reference",
248054
+ detail: "a row-level gate must compare a field against a given; a gate " + "that references none is a fixed filter and belongs in the " + "source's own `where:`"
248055
+ };
248056
+ }
248057
+ return { shape: "row_level", givenNames, literalAtoms };
248058
+ }
248059
+ function isArrayType(declaredType) {
248060
+ const normalized = declaredType.trim().toLowerCase();
248061
+ return normalized === "array" || normalized.endsWith("[]");
248062
+ }
248063
+ function asNode(node) {
248064
+ return node !== null && typeof node === "object" ? node : null;
248065
+ }
247017
248066
  function inferGivenType(value) {
247018
248067
  if (typeof value === "string")
247019
248068
  return "string";
@@ -247056,9 +248105,8 @@ async function runProbe(executor, probeText, givens) {
247056
248105
  }
247057
248106
  async function evaluateAuthorize(executor, exprs, givens, declaredTypes, options) {
247058
248107
  const selfContainedFirst = options?.selfContainedFirst ?? false;
247059
- const ambientPrefix = options?.ambientPrefix ?? 0;
247060
- for (const [index, expr] of exprs.entries()) {
247061
- if (selfContainedFirst && index >= ambientPrefix) {
248108
+ for (const expr of exprs) {
248109
+ if (selfContainedFirst) {
247062
248110
  if (await evaluateSelfContainedFirst(executor, expr, givens, declaredTypes)) {
247063
248111
  return true;
247064
248112
  }
@@ -247106,27 +248154,123 @@ async function evaluateSelfContainedFirst(executor, expr, givens, declaredTypes)
247106
248154
  }
247107
248155
  }
247108
248156
  }
247109
- async function validateAuthorizeProbes(compiler, sources) {
247110
- for (const source of sources) {
247111
- const exprs = source.authorize;
247112
- if (!exprs || exprs.length === 0)
247113
- continue;
248157
+ function quoteMalloyIdentifier(name) {
248158
+ return "`" + name.replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
248159
+ }
248160
+ function buildRowLevelProbe(graftTarget, filterText) {
248161
+ return `run: ${quoteMalloyIdentifier(graftTarget)} extend { where: ${filterText} } -> { select: __authorize_probe is 1; limit: 1 }`;
248162
+ }
248163
+ function liftProbeFilterCondition(prepared, label, filterText) {
248164
+ const filterList = prepared._query?.structRef?.filterList;
248165
+ if (!Array.isArray(filterList) || filterList.length === 0) {
248166
+ throw new Error(`${label} carries no filter condition`);
248167
+ }
248168
+ const lifted = filterList[filterList.length - 1];
248169
+ if (lifted.code !== filterText) {
248170
+ throw new Error(`${label} carries the wrong condition — expected "${filterText}", got "${lifted.code ?? ""}"`);
248171
+ }
248172
+ if (!lifted.isSourceFilter) {
248173
+ throw new Error(`${label} carries a condition that is not a source filter`);
248174
+ }
248175
+ return lifted;
248176
+ }
248177
+ function gateFilterText(exprs) {
248178
+ return exprs.map((e) => `(${e})`).join(" or ");
248179
+ }
248180
+ async function liftRowLevelCondition(compiler, sourceName, exprs) {
248181
+ const filterText = gateFilterText(exprs);
248182
+ const prepared = await compiler.loadQuery(buildRowLevelProbe(sourceName, filterText)).getPreparedQuery();
248183
+ return liftProbeFilterCondition(prepared, `row-level probe for "${sourceName}"`, filterText);
248184
+ }
248185
+ async function runOneRowProbeOrThrow(compiler, sourceName, exprs) {
248186
+ try {
248187
+ await compiler.loadQuery(buildAuthorizeProbe(exprs)).getPreparedQuery();
248188
+ } catch (err) {
248189
+ const detail = err instanceof Error ? err.message : String(err);
248190
+ throw new ModelCompilationError({
248191
+ message: `Invalid #(authorize) annotation on source "${sourceName}" [${exprs.join(" | ")}]: ${detail}`
248192
+ });
248193
+ }
248194
+ }
248195
+ async function assertNoVacuousDefaultAtom(executor, sourceName, literalAtoms) {
248196
+ for (const atom of literalAtoms) {
248197
+ let vacuous;
247114
248198
  try {
247115
- await compiler.loadQuery(buildAuthorizeProbe(exprs)).getPreparedQuery();
248199
+ vacuous = await runProbe(executor, buildAuthorizeProbe([atom]), {});
247116
248200
  } catch (err) {
247117
248201
  const detail = err instanceof Error ? err.message : String(err);
248202
+ if (NO_DEFAULT_GIVEN_PATTERN.test(detail))
248203
+ continue;
247118
248204
  throw new ModelCompilationError({
247119
- message: `Invalid #(authorize) annotation on source "${source.name ?? "(unnamed)"}" [${exprs.join(" | ")}]: ${detail}`
248205
+ message: `Invalid #(authorize) annotation on source "${sourceName}": ` + `the atom \`${atom}\` could not be evaluated against its ` + `given's declared default (${detail}).`
248206
+ });
248207
+ }
248208
+ if (vacuous) {
248209
+ throw new ModelCompilationError({
248210
+ message: `Invalid #(authorize) annotation on source "${sourceName}": ` + `the atom \`${atom}\` evaluates to TRUE when a caller supplies ` + `no givens (its given's own declared default). An OR'd atom ` + `that is true by default makes the whole row filter admit ` + `every row for that caller. Give the given a default this ` + `atom evaluates false against, or declare it with no default ` + `so a caller must supply one explicitly.`
247120
248211
  });
247121
248212
  }
247122
248213
  }
247123
248214
  }
248215
+ async function validateAuthorizeProbes(compiler, options) {
248216
+ const declaredTypes = options.declaredTypes ?? new Map;
248217
+ const declaredDefaults = options.declaredDefaults ?? new Map;
248218
+ const provenNoteObjects = new Set;
248219
+ const ownNotesOf = options.authorizeOwnNotes ?? new Map;
248220
+ const pending = [];
248221
+ for (const [sourceName, groups] of options.authorizeMap ?? []) {
248222
+ for (const exprs of groups) {
248223
+ if (exprs.length === 0)
248224
+ continue;
248225
+ let condition;
248226
+ try {
248227
+ condition = await liftRowLevelCondition(compiler, sourceName, exprs);
248228
+ } catch (err) {
248229
+ pending.push({ sourceName, exprs, err });
248230
+ continue;
248231
+ }
248232
+ const classification = classifyAuthorizeGate(condition, declaredTypes, declaredDefaults);
248233
+ if (classification.shape === "given_only") {
248234
+ await runOneRowProbeOrThrow(compiler, sourceName, exprs);
248235
+ for (const note of ownNotesOf.get(sourceName) ?? []) {
248236
+ provenNoteObjects.add(note);
248237
+ }
248238
+ continue;
248239
+ }
248240
+ if (classification.shape === "rejected") {
248241
+ options.onRowLevelGateRejected?.(classification.cause);
248242
+ const ownNotes = ownNotesOf.get(sourceName) ?? [];
248243
+ if (ownNotes.length === 0) {
248244
+ options.onRowLevelGateUnexpressible?.(sourceName, classification.detail);
248245
+ continue;
248246
+ }
248247
+ throw new ModelCompilationError({
248248
+ message: `Invalid #(authorize) annotation on source "${sourceName}" ` + `[${exprs.join(" | ")}]: ${classification.detail}`
248249
+ });
248250
+ }
248251
+ await assertNoVacuousDefaultAtom(compiler, sourceName, classification.literalAtoms);
248252
+ for (const note of ownNotesOf.get(sourceName) ?? []) {
248253
+ provenNoteObjects.add(note);
248254
+ }
248255
+ }
248256
+ }
248257
+ for (const failure of pending) {
248258
+ const ownNotes = ownNotesOf.get(failure.sourceName) ?? [];
248259
+ const inherited = ownNotes.length === 0 || ownNotes.every((note) => provenNoteObjects.has(note));
248260
+ if (inherited) {
248261
+ const detail = failure.err instanceof Error ? failure.err.message : String(failure.err);
248262
+ options.onRowLevelGateRejected?.("entry_point_unexpressible");
248263
+ options.onRowLevelGateUnexpressible?.(failure.sourceName, detail);
248264
+ continue;
248265
+ }
248266
+ await runOneRowProbeOrThrow(compiler, failure.sourceName, failure.exprs);
248267
+ }
248268
+ }
247124
248269
  function parseAuthorizeAnnotation(annotation) {
247125
- const trimmed2 = annotation.trim();
247126
- const prefix = AUTHORIZE_ANNOTATION_PREFIX.exec(trimmed2);
247127
- if (!prefix)
248270
+ const content = authorizeNoteContent(annotation);
248271
+ if (content === undefined)
247128
248272
  return null;
247129
- return unwrapQuotedExpression(trimmed2.slice(prefix[0].length).trim());
248273
+ return unwrapQuotedExpression(content.trim());
247130
248274
  }
247131
248275
  function collectAuthorizeExprs(annotations) {
247132
248276
  const exprs = [];
@@ -247174,14 +248318,20 @@ function unwrapQuotedExpression(body) {
247174
248318
  }
247175
248319
  return expr;
247176
248320
  }
247177
- var AUTHORIZE_TAG, AUTHORIZE_ANNOTATION_ANYWHERE, AUTHORIZE_ANNOTATION_PREFIX, GIVEN_REF_PATTERN, STRING_LITERAL_PATTERN;
248321
+ var AUTHORIZE_ROUTE = "authorize", AUTHORIZE_TAG_LIKE, AUTHORIZE_ANNOTATION_ANYWHERE, MOTLY_AUTHORIZE_PAYLOAD, MALFORMED_AUTHORIZE_ATTEMPT, GIVEN_REF_PATTERN, STRING_LITERAL_PATTERN, everyMemberOf = () => (...members) => members, ROW_LEVEL_GATE_REJECTION_CAUSES, BOOLEAN_NODES, TRANSPARENT_NODES, SCALAR_COMPARISON_NODES, MAX_GATE_WALK_DEPTH = 64, NO_DEFAULT_GIVEN_PATTERN;
247178
248322
  var init_authorize = __esm(() => {
247179
248323
  init_errors();
247180
- AUTHORIZE_TAG = String.raw`##?\(\s*authorize\s*\)`;
247181
- AUTHORIZE_ANNOTATION_ANYWHERE = new RegExp(AUTHORIZE_TAG);
247182
- AUTHORIZE_ANNOTATION_PREFIX = new RegExp(`^${AUTHORIZE_TAG}`);
248324
+ AUTHORIZE_TAG_LIKE = String.raw`##?\|?[ \t]*[([{<]?[ \t]*authorize(?=[)\]}>]|[ \t]|$)`;
248325
+ AUTHORIZE_ANNOTATION_ANYWHERE = new RegExp(AUTHORIZE_TAG_LIKE, "iu");
248326
+ MOTLY_AUTHORIZE_PAYLOAD = /^[ \t]*[([{<][ \t]*authorize[ \t]*[)\]}>]/iu;
248327
+ MALFORMED_AUTHORIZE_ATTEMPT = /^##?\|?[ \t]*[([{<]?[ \t]*authorize/iu;
247183
248328
  GIVEN_REF_PATTERN = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
247184
248329
  STRING_LITERAL_PATTERN = /'(?:\\.|[^'\\])*'/g;
248330
+ ROW_LEVEL_GATE_REJECTION_CAUSES = everyMemberOf()("array_given_needs_in", "scalar_given_rejects_in", "field_given_has_default", "unsupported_node", "no_given_reference", "unreachable_given", "entry_point_unexpressible");
248331
+ BOOLEAN_NODES = new Set(["and", "or"]);
248332
+ TRANSPARENT_NODES = new Set(["()"]);
248333
+ SCALAR_COMPARISON_NODES = new Set(["=", "!=", ">", ">=", "<", "<="]);
248334
+ NO_DEFAULT_GIVEN_PATTERN = /has no value and no default/;
247185
248335
  });
247186
248336
 
247187
248337
  // src/authorize_metrics.ts
@@ -247197,9 +248347,22 @@ function recordAuthorizeBypass(entryPoint) {
247197
248347
  });
247198
248348
  bypassCounter.add(1, { entry_point: entryPoint });
247199
248349
  }
247200
- var guardRejectionCounter = null, bypassCounter = null;
248350
+ function recordRowLevelGateDecision(decision) {
248351
+ rowLevelDecisionCounter ??= publisherMeter().createCounter("publisher_authorize_row_level_total", {
248352
+ description: "How a row-level `#(authorize)` gate resolved a request. Label: decision ('denied_by_gate'|'empty_after_filter'). 'denied_by_gate' is the fail-closed refusal when the gate could not be applied; 'empty_after_filter' is a successful response with zero rows after the filter matched none, which is NOT an error."
248353
+ });
248354
+ rowLevelDecisionCounter.add(1, { decision });
248355
+ }
248356
+ function recordRowLevelGateRejected(cause) {
248357
+ rowLevelRejectionCounter ??= publisherMeter().createCounter("publisher_authorize_row_level_rejected_total", {
248358
+ description: "Row-level `#(authorize)` gates refused at package load because their compiled condition is not an allowed shape, or an inherited gate that could not be expressed at one derived entry point. Label: cause (" + ROW_LEVEL_GATE_REJECTION_CAUSES.map((c) => `'${c}'`).join("|") + "). All but 'entry_point_unexpressible' fail the whole model load; that one fires at load without failing it — see the doc above. Alert on any nonzero value since the last publish, not on a rate."
248359
+ });
248360
+ rowLevelRejectionCounter.add(1, { cause });
248361
+ }
248362
+ var guardRejectionCounter = null, bypassCounter = null, rowLevelDecisionCounter = null, rowLevelRejectionCounter = null;
247201
248363
  var init_authorize_metrics = __esm(() => {
247202
248364
  init_telemetry();
248365
+ init_authorize();
247203
248366
  });
247204
248367
 
247205
248368
  // src/materialization_metrics.ts
@@ -247734,76 +248897,6 @@ function filterFreshManifest(entries, now) {
247734
248897
  return { manifest, nextStaleSince };
247735
248898
  }
247736
248899
 
247737
- // src/service/annotations.ts
247738
- import { Annotations } from "@malloydata/malloy";
247739
- function isReservedRoute(route) {
247740
- return route === "" || !/[\p{L}\p{N}]/u.test(route);
247741
- }
247742
- function ownModelAnnotations(modelDef) {
247743
- return foldModelAnnotations(modelDef, (id) => id === modelDef.modelID || id.startsWith("internal://"));
247744
- }
247745
- function modelAnnotations(modelDef) {
247746
- return foldModelAnnotations(modelDef, () => true);
247747
- }
247748
- function foldModelAnnotations(modelDef, admits) {
247749
- const registry = modelDef.modelAnnotations ?? {};
247750
- const visited = new Set;
247751
- const order = [];
247752
- const visit = (id) => {
247753
- if (!admits(id))
247754
- return;
247755
- if (visited.has(id))
247756
- return;
247757
- visited.add(id);
247758
- const entry = registry[id];
247759
- if (!entry)
247760
- return;
247761
- for (const dep of entry.inheritsFrom)
247762
- visit(dep);
247763
- order.push(id);
247764
- };
247765
- visit(modelDef.modelID);
247766
- let folded;
247767
- for (const id of order) {
247768
- const own = registry[id].ownNotes;
247769
- if (!own.notes?.length && !own.blockNotes?.length)
247770
- continue;
247771
- folded = {
247772
- notes: own.notes,
247773
- blockNotes: own.blockNotes,
247774
- inherits: folded
247775
- };
247776
- }
247777
- return folded ?? {};
247778
- }
247779
- function ownModelNotes(modelDef) {
247780
- const registry = modelDef.modelAnnotations ?? {};
247781
- const isSameDocument = (id) => id === modelDef.modelID || id.startsWith("internal://");
247782
- const seen = new Set;
247783
- const texts = [];
247784
- const visit = (id) => {
247785
- if (seen.has(id) || !isSameDocument(id))
247786
- return;
247787
- seen.add(id);
247788
- const entry = registry[id];
247789
- if (!entry)
247790
- return;
247791
- for (const dep of entry.inheritsFrom)
247792
- visit(dep);
247793
- texts.push(...ownLevelNoteTexts(entry.ownNotes));
247794
- };
247795
- visit(modelDef.modelID);
247796
- return texts;
247797
- }
247798
- function annotationTexts(annote) {
247799
- const texts = new Annotations(annote).texts();
247800
- return texts.length > 0 ? texts : undefined;
247801
- }
247802
- function ownLevelNoteTexts(annote) {
247803
- return [...annote?.blockNotes ?? [], ...annote?.notes ?? []].map((note) => note.text);
247804
- }
247805
- var init_annotations = () => {};
247806
-
247807
248900
  // src/service/incremental_declaration.ts
247808
248901
  function safeTag(source) {
247809
248902
  try {
@@ -247995,6 +249088,18 @@ function assertMaterializationEligible(persistSource) {
247995
249088
  });
247996
249089
  }
247997
249090
  }
249091
+ function assertColocatedPersistNotAuthorizeGated(persistSource, sourceName = persistSource.name, origin2 = "persist") {
249092
+ if (referencesAuthorize(persistSource)) {
249093
+ recordEligibilityRefused("authorize");
249094
+ const gated = origin2 === "preaggregate" ? `the source '${sourceName}' rolls up is protected by an ` + `#(authorize) gate (its own or a joined source's)` : `it is protected by an #(authorize) gate (its own or a joined ` + `source's)`;
249095
+ const remedy = origin2 === "preaggregate" ? `Remove the '#@ preaggregate' annotation from the gated source's ` + `measure(s), or move the gate to a source that is not ` + `pre-aggregated.` : `Drop '#@ persist' from this source, or move the gate to a source ` + `that is not materialized.`;
249096
+ const what = origin2 === "preaggregate" ? `Pre-aggregation rollup '${sourceName}' cannot be built` : `Source '${sourceName}' cannot be materialized (colocated ` + `'#@ persist')`;
249097
+ const alsoRollup = origin2 === "preaggregate" ? ` A rollup also groups ACROSS the gated column, so it could not ` + `be row-filtered afterwards even in principle.` : "";
249098
+ throw new MaterializationEligibilityError({
249099
+ message: `${what}: ${gated}. An authorize expression is evaluated per ` + `request; a materialized-once table served frozen carries no gate, ` + `so it would be served to everyone, bypassing authorization.` + `${alsoRollup} This is refused for safety. ${remedy}`
249100
+ });
249101
+ }
249102
+ }
247998
249103
  function unboundParameterNames(persistSource) {
247999
249104
  const def = persistSource._sourceDef;
248000
249105
  if (def === null || typeof def !== "object") {
@@ -248148,6 +249253,19 @@ function readPreaggregateAnnotation(measure) {
248148
249253
  continue;
248149
249254
  declared = true;
248150
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
+ }
248151
249269
  if (grainText === undefined) {
248152
249270
  errors2.push({
248153
249271
  kind: "missing_grain",
@@ -248163,7 +249281,11 @@ function readPreaggregateAnnotation(measure) {
248163
249281
  });
248164
249282
  continue;
248165
249283
  }
248166
- grains.set(dimensions.join("\x00"), { dimensions, text: grainText });
249284
+ grains.set(dimensions.join("\x00"), {
249285
+ dimensions,
249286
+ text: grainText,
249287
+ namespace
249288
+ });
248167
249289
  }
248168
249290
  return {
248169
249291
  declared,
@@ -248174,6 +249296,7 @@ function readPreaggregateAnnotation(measure) {
248174
249296
  }
248175
249297
  var NEGATION;
248176
249298
  var init_preaggregation_annotation = __esm(() => {
249299
+ init_preaggregation_synthesis();
248177
249300
  NEGATION = /#@\s*-\s*preaggregate\b/;
248178
249301
  });
248179
249302
 
@@ -248187,7 +249310,7 @@ function walk(node, found) {
248187
249310
  found.aggregates.push({ function: node.function });
248188
249311
  break;
248189
249312
  case "filteredExpr":
248190
- found.filtered = true;
249313
+ found.filtered += 1;
248191
249314
  break;
248192
249315
  case "all":
248193
249316
  case "exclude":
@@ -248240,7 +249363,7 @@ function classifyMeasureAdditivity(measure) {
248240
249363
  }
248241
249364
  const found = {
248242
249365
  aggregates: [],
248243
- filtered: false,
249366
+ filtered: 0,
248244
249367
  ungrouped: false,
248245
249368
  analytic: false
248246
249369
  };
@@ -248251,8 +249374,13 @@ function classifyMeasureAdditivity(measure) {
248251
249374
  if (found.analytic) {
248252
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.`);
248253
249376
  }
248254
- if (found.filtered) {
248255
- 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;
248256
249384
  }
248257
249385
  if (found.aggregates.length === 0) {
248258
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.`);
@@ -248260,10 +249388,10 @@ function classifyMeasureAdditivity(measure) {
248260
249388
  if (found.aggregates.length > 1) {
248261
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.`);
248262
249390
  }
248263
- if (measure.e.node !== "aggregate") {
249391
+ if (aggregateRoot.node !== "aggregate") {
248264
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.`);
248265
249393
  }
248266
- const fn = measure.e.function;
249394
+ const fn = aggregateRoot.function;
248267
249395
  if (!isAdditiveAggregate(fn)) {
248268
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." : "";
248269
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}`);
@@ -248281,6 +249409,7 @@ var init_preaggregation_classifier = __esm(() => {
248281
249409
  });
248282
249410
 
248283
249411
  // src/service/preaggregation_synthesis.ts
249412
+ import { Annotations as Annotations3 } from "@malloydata/malloy";
248284
249413
  import { createHash as createHash2 } from "node:crypto";
248285
249414
  function grainDigest(grainDimensions) {
248286
249415
  return createHash2("sha256").update(grainDimensions.join("\x00")).digest("hex").slice(0, 8);
@@ -248289,10 +249418,40 @@ function rollupSourceName(baseSourceName, grainDimensions) {
248289
249418
  const slug = grainDimensions.join("_").slice(0, NAME_SLUG_LIMIT);
248290
249419
  return `${baseSourceName}__preagg__${slug}__${grainDigest(grainDimensions)}`;
248291
249420
  }
249421
+ function isSpliceableNamespace(namespace) {
249422
+ return namespace.split(".").every((s) => NAMESPACE_SEGMENT.test(s));
249423
+ }
248292
249424
  function baseAlias(baseSourceName) {
248293
249425
  return `${baseSourceName}${BASE_ALIAS_SUFFIX}`;
248294
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
+ }
248295
249453
  function planSourcePreaggregation(baseSourceName, source) {
249454
+ const inheritedNamespace = basePersistNamespace(source);
248296
249455
  const byGrain = new Map;
248297
249456
  for (const field of source.fields ?? []) {
248298
249457
  const declaration = readPreaggregateAnnotation(field);
@@ -248313,12 +249472,14 @@ function planSourcePreaggregation(baseSourceName, source) {
248313
249472
  partialName: `${name}${PARTIAL_SUFFIX}`,
248314
249473
  reaggregate: additivity.reaggregate
248315
249474
  });
249475
+ entry.namespace ??= grain.namespace;
248316
249476
  byGrain.set(key, entry);
248317
249477
  }
248318
249478
  }
248319
- return [...byGrain.values()].map(({ grainDimensions, measures }) => ({
249479
+ return [...byGrain.values()].map(({ grainDimensions, measures, namespace }) => ({
248320
249480
  baseSourceName,
248321
249481
  rollupSourceName: rollupSourceName(baseSourceName, grainDimensions),
249482
+ namespace: namespace ?? inheritedNamespace,
248322
249483
  grainDimensions,
248323
249484
  measures: [...measures].sort((a, b) => a.name.localeCompare(b.name))
248324
249485
  })).sort((a, b) => a.rollupSourceName.localeCompare(b.rollupSourceName));
@@ -248336,7 +249497,8 @@ function emitRollup(plan) {
248336
249497
  `);
248337
249498
  const merged = plan.measures.map((m) => ` ${m.name} is ${m.partialName}.${m.reaggregate}()`).join(`
248338
249499
  `);
248339
- return `#@ persist
249500
+ const persist = plan.namespace ? `#@ persist name="${plan.namespace}.${plan.rollupSourceName}"` : "#@ persist";
249501
+ return `${persist}
248340
249502
  source: ${plan.rollupSourceName} is ${alias} -> {
248341
249503
  group_by:
248342
249504
  ${plan.grainDimensions.map((d) => ` ${d}`).join(`
@@ -248369,10 +249531,11 @@ ${plans.map(emitRollup).join(`
248369
249531
  ${composites}
248370
249532
  `;
248371
249533
  }
248372
- 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;
248373
249535
  var init_preaggregation_synthesis = __esm(() => {
248374
249536
  init_preaggregation_annotation();
248375
249537
  init_preaggregation_classifier();
249538
+ NAMESPACE_SEGMENT = /^[A-Za-z_][A-Za-z0-9_$-]*$/;
248376
249539
  });
248377
249540
 
248378
249541
  // src/service/preaggregation_compile.ts
@@ -248421,7 +249584,7 @@ var init_preaggregation_compile = __esm(() => {
248421
249584
  });
248422
249585
 
248423
249586
  // src/service/build_plan.ts
248424
- import { Annotations as Annotations3 } from "@malloydata/malloy";
249587
+ import { Annotations as Annotations4 } from "@malloydata/malloy";
248425
249588
  function deriveColumns(persistSource) {
248426
249589
  try {
248427
249590
  return persistSource._explore.intrinsicFields.filter((f) => f.isAtomicField()).map((f) => ({
@@ -248620,7 +249783,7 @@ function detectDroppedPersistSources(malloyModel, recognizedNames) {
248620
249783
  continue;
248621
249784
  let isPersist = false;
248622
249785
  try {
248623
- isPersist = new Annotations3(def.annotations).parseAsTag("@").tag.has("persist");
249786
+ isPersist = new Annotations4(def.annotations).parseAsTag("@").tag.has("persist");
248624
249787
  } catch {
248625
249788
  continue;
248626
249789
  }
@@ -248998,21 +250161,6 @@ var init_filter = __esm(() => {
248998
250161
  };
248999
250162
  });
249000
250163
 
249001
- // src/service/given.ts
249002
- function malloyGivenToApi(given) {
249003
- const type = given.type;
249004
- const renderedType = type.type === "filter expression" ? `filter<${type.filterType}>` : type.type;
249005
- return {
249006
- name: given.name,
249007
- type: renderedType,
249008
- annotations: given.annotations.forRoute(undefined).filter((note) => !isReservedRoute(note.route)).map((note) => note.text),
249009
- default: given._internal?.defaultText
249010
- };
249011
- }
249012
- var init_given = __esm(() => {
249013
- init_annotations();
249014
- });
249015
-
249016
250164
  // src/service/model_limits.ts
249017
250165
  function resolveModelQueryRowLimit(userLimit, { defaultLimit, maxRows }) {
249018
250166
  const requested = userLimit && userLimit > 0 ? userLimit : defaultLimit;
@@ -249160,6 +250308,8 @@ function validateSourcePreaggregation(sourceName, source) {
249160
250308
  });
249161
250309
  }
249162
250310
  const declaredFields = [];
250311
+ const namespacesByGrain = new Map;
250312
+ const grainTextByKey = new Map;
249163
250313
  for (const field of fields) {
249164
250314
  const name = fieldName(field);
249165
250315
  const declaration = readPreaggregateAnnotation(field);
@@ -249219,8 +250369,26 @@ function validateSourcePreaggregation(sourceName, source) {
249219
250369
  if (violation)
249220
250370
  violations.push(violation);
249221
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
+ }
249222
250380
  }
249223
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
+ }
249224
250392
  const fanout = declaredFields.length > 0 ? fields.filter(isFanoutJoin) : [];
249225
250393
  if (fanout.length > 0) {
249226
250394
  const joins = fanout.map((f) => `\`${fieldName(f)}\``).join(", ");
@@ -249248,16 +250416,182 @@ var init_preaggregation_validation = __esm(() => {
249248
250416
  init_preaggregation_classifier();
249249
250417
  });
249250
250418
 
250419
+ // src/service/gate_registry_walk.ts
250420
+ import { isSourceDef as isSourceDef2 } from "@malloydata/malloy";
250421
+ function resolveDeclaredSource(struct, modelDef) {
250422
+ if (!modelDef)
250423
+ return { kind: "none" };
250424
+ let sawBrokenEntry = false;
250425
+ for (const id of [struct.referenceID, struct.sourceID]) {
250426
+ const entry = id ? modelDef.sourceRegistry?.[id]?.entry : undefined;
250427
+ if (!entry)
250428
+ continue;
250429
+ const declared = entry.type === "source_registry_reference" ? modelDef.contents[entry.name] : entry;
250430
+ if (declared === struct)
250431
+ continue;
250432
+ if (!declared || !isSourceDef2(declared)) {
250433
+ sawBrokenEntry = true;
250434
+ continue;
250435
+ }
250436
+ return { kind: "resolved", source: declared };
250437
+ }
250438
+ return sawBrokenEntry ? { kind: "unresolvable" } : { kind: "none" };
250439
+ }
250440
+ function ancestorGateExprs(struct, modelDef, seen = new Set) {
250441
+ let inherited = struct.annotations?.inherits;
250442
+ for (let depth = 0;inherited && depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
250443
+ const exprs2 = collectAuthorizeExprs(ownLevelNoteTexts(inherited));
250444
+ if (exprs2.length > 0)
250445
+ return exprs2;
250446
+ inherited = inherited.inherits;
250447
+ }
250448
+ if (inherited)
250449
+ return ["false"];
250450
+ seen.add(struct);
250451
+ if (seen.size > ANCESTOR_WALK_MAX_DEPTH)
250452
+ return ["false"];
250453
+ const declared = resolveDeclaredSource(struct, modelDef);
250454
+ if (declared.kind === "unresolvable")
250455
+ return ["false"];
250456
+ if (declared.kind === "none" || seen.has(declared.source))
250457
+ return [];
250458
+ const exprs = collectAuthorizeExprs(ownLevelNoteTexts(declared.source.annotations));
250459
+ return exprs.length > 0 ? exprs : ancestorGateExprs(declared.source, modelDef, seen);
250460
+ }
250461
+ function resolveQuerySourceBase(struct, modelDef) {
250462
+ const duck = struct;
250463
+ if (duck.type !== "query_source")
250464
+ return;
250465
+ const ref = duck.query?.structRef;
250466
+ const base = typeof ref === "string" ? modelDef?.contents[ref] : ref;
250467
+ return base && isSourceDef2(base) ? base : undefined;
250468
+ }
250469
+ function resolveCompositeResolvedBase(struct) {
250470
+ const duck = struct;
250471
+ return duck.type === "query_source" ? duck.query?.compositeResolvedSourceDef : undefined;
250472
+ }
250473
+ function effectiveAncestorGateExprs(struct, modelDef, seen = new Set) {
250474
+ const direct = ancestorGateExprs(struct, modelDef, new Set(seen));
250475
+ if (direct.length > 0)
250476
+ return [direct];
250477
+ if (seen.has(struct))
250478
+ return [];
250479
+ seen.add(struct);
250480
+ const groups = [];
250481
+ const base = resolveQuerySourceBase(struct, modelDef);
250482
+ if (!base) {
250483
+ const duck = struct;
250484
+ if (duck.type === "query_source")
250485
+ groups.push(["false"]);
250486
+ } else if (!seen.has(base)) {
250487
+ const ownExprs = collectAuthorizeExprs(ownLevelNoteTexts(base.annotations));
250488
+ groups.push(...ownExprs.length > 0 ? [ownExprs] : effectiveAncestorGateExprs(base, modelDef, seen));
250489
+ }
250490
+ const composite = resolveCompositeResolvedBase(struct);
250491
+ if (composite && !seen.has(composite)) {
250492
+ const parentOwnNotes = base ? ownLevelNotes(base.annotations) : [];
250493
+ const compositeOwnNotes = ownLevelNotes(composite.annotations).filter((note) => !parentOwnNotes.includes(note));
250494
+ const compositeOwn = collectAuthorizeExprs(compositeOwnNotes.map((note) => note.text));
250495
+ groups.push(...compositeOwn.length > 0 ? [compositeOwn] : effectiveAncestorGateExprs(composite, modelDef, seen));
250496
+ }
250497
+ return groups;
250498
+ }
250499
+ function derivedStructsReachable(roots, modelDef) {
250500
+ const seen = new Set(roots);
250501
+ const found = [];
250502
+ const worklist = [...roots];
250503
+ for (let i = 0;i < worklist.length; i++) {
250504
+ const struct = worklist[i];
250505
+ for (const next of [
250506
+ resolveQuerySourceBase(struct, modelDef),
250507
+ resolveCompositeResolvedBase(struct)
250508
+ ]) {
250509
+ if (!next || seen.has(next))
250510
+ continue;
250511
+ seen.add(next);
250512
+ found.push(next);
250513
+ worklist.push(next);
250514
+ }
250515
+ }
250516
+ return found;
250517
+ }
250518
+ var ANCESTOR_WALK_MAX_DEPTH = 32;
250519
+ var init_gate_registry_walk = __esm(() => {
250520
+ init_annotations();
250521
+ init_authorize();
250522
+ });
250523
+
249251
250524
  // src/service/source_extraction.ts
249252
250525
  import {
249253
- isSourceDef
250526
+ isJoined as isJoined2,
250527
+ isSourceDef as isSourceDef3
249254
250528
  } from "@malloydata/malloy";
250529
+ function joinFieldNamesUnresolvableDeclaration(field, modelDef) {
250530
+ const ids = [field.referenceID, field.sourceID].filter((id) => !!id);
250531
+ if (ids.length === 0)
250532
+ return false;
250533
+ for (const id of ids) {
250534
+ const entry = modelDef.sourceRegistry?.[id]?.entry;
250535
+ if (!entry)
250536
+ continue;
250537
+ const declared = entry.type === "source_registry_reference" ? modelDef.contents[entry.name] : entry;
250538
+ if (declared && isSourceDef3(declared))
250539
+ return false;
250540
+ }
250541
+ return true;
250542
+ }
249255
250543
  function extractSourcesFromModelDef(modelDef, givens, onParseError) {
249256
250544
  const filterMap = new Map;
249257
250545
  const authorizeMap = new Map;
249258
- const ownAuthorizeSources = [];
249259
- const fileLevelAuthorize = collectAuthorizeExprs((modelAnnotations(modelDef).notes ?? []).map((note) => note.text));
249260
- const sources = Object.values(modelDef.contents).filter((obj) => isSourceDef(obj)).map((sourceObj) => {
250546
+ const misplacedAuthorize = [];
250547
+ const gatedSourceOwnAuthorizeNotes = new Set;
250548
+ const nearMissAuthorize = [];
250549
+ const sweptStructs = [];
250550
+ for (const obj of Object.values(modelDef.contents)) {
250551
+ if (!isSourceDef3(obj))
250552
+ continue;
250553
+ const struct = obj;
250554
+ sweptStructs.push(obj);
250555
+ for (const note of ownLevelNotes(struct.annotations)) {
250556
+ if (containsAuthorizeAnnotationTag([note.text])) {
250557
+ gatedSourceOwnAuthorizeNotes.add(note);
250558
+ }
250559
+ }
250560
+ nearMissAuthorize.push(...collectAuthorizeNearMisses([
250561
+ ...ownLevelNotes(struct.annotations),
250562
+ ...struct.fields.flatMap((field) => ownLevelNotes(field.annotations))
250563
+ ].map((note) => note.text)));
250564
+ }
250565
+ for (const value of Object.values(modelDef.sourceRegistry ?? {})) {
250566
+ const entry = value.entry;
250567
+ if (entry.type === "source_registry_reference")
250568
+ continue;
250569
+ if (!isSourceDef3(entry))
250570
+ continue;
250571
+ sweptStructs.push(entry);
250572
+ for (const note of ownLevelNotes(entry.annotations)) {
250573
+ if (containsAuthorizeAnnotationTag([note.text])) {
250574
+ gatedSourceOwnAuthorizeNotes.add(note);
250575
+ }
250576
+ }
250577
+ nearMissAuthorize.push(...collectAuthorizeNearMisses(ownLevelNotes(entry.annotations).map((note) => note.text)));
250578
+ }
250579
+ for (const struct of derivedStructsReachable(sweptStructs, modelDef)) {
250580
+ nearMissAuthorize.push(...collectAuthorizeNearMisses([
250581
+ ...ownLevelNotes(struct.annotations),
250582
+ ...struct.fields.flatMap((field) => ownLevelNotes(field.annotations))
250583
+ ].map((note) => note.text)));
250584
+ }
250585
+ {
250586
+ const folded = modelAnnotations(modelDef);
250587
+ nearMissAuthorize.push(...collectAuthorizeNearMisses([...folded.notes ?? [], ...folded.blockNotes ?? []].map((note) => note.text)));
250588
+ }
250589
+ assertNoAuthorizeNearMisses(nearMissAuthorize);
250590
+ const authorizeOwnNotes = new Map;
250591
+ if (containsAuthorizeAnnotationTag((modelAnnotations(modelDef).notes ?? []).map((note) => note.text))) {
250592
+ misplacedAuthorize.push({ kind: "file" });
250593
+ }
250594
+ const sources = Object.values(modelDef.contents).filter((obj) => isSourceDef3(obj)).map((sourceObj) => {
249261
250595
  const struct = sourceObj;
249262
250596
  const sourceName = struct.as || struct.name;
249263
250597
  const annotations = annotationTexts(struct.annotations);
@@ -249295,36 +250629,44 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
249295
250629
  }
249296
250630
  const ownNotes = ownLevelNoteTexts(struct.annotations);
249297
250631
  const ownGates = collectAuthorizeExprs(ownNotes);
249298
- let inheritedGates = [];
249299
- if (ownGates.length === 0) {
249300
- for (let cur2 = struct.annotations?.inherits;cur2; cur2 = cur2.inherits) {
249301
- const exprs = collectAuthorizeExprs(ownLevelNoteTexts(cur2));
249302
- if (exprs.length > 0) {
249303
- inheritedGates = exprs;
249304
- break;
249305
- }
249306
- }
249307
- }
249308
- const effective = [
249309
- ...fileLevelAuthorize,
249310
- ...ownGates.length > 0 ? ownGates : inheritedGates
249311
- ];
250632
+ authorizeOwnNotes.set(sourceName, ownLevelNotes(struct.annotations).filter((note) => containsAuthorizeAnnotationTag([note.text])));
250633
+ const inheritedGroups = ownGates.length === 0 ? effectiveAncestorGateExprs(struct, modelDef) : [];
250634
+ const effectiveGroups = ownGates.length > 0 ? [ownGates] : inheritedGroups;
249312
250635
  let authorize;
249313
- if (effective.length > 0) {
249314
- authorizeMap.set(sourceName, effective);
249315
- authorize = effective;
249316
- }
249317
- const ownEffective = [...fileLevelAuthorize, ...ownGates];
249318
- if (ownEffective.length > 0) {
249319
- ownAuthorizeSources.push({
249320
- name: sourceName,
249321
- authorize: ownEffective
249322
- });
250636
+ if (effectiveGroups.length > 0) {
250637
+ authorizeMap.set(sourceName, effectiveGroups);
250638
+ authorize = effectiveGroups.flat();
249323
250639
  }
249324
250640
  const views = struct.fields.filter((field) => field.type === "turtle").filter((turtle) => turtle.pipeline.map((stage) => stage.type).every((type) => type === "reduce")).map((turtle) => ({
249325
250641
  name: turtle.as || turtle.name,
249326
250642
  annotations: annotationTexts(turtle.annotations)
249327
250643
  }));
250644
+ for (const field of struct.fields) {
250645
+ const fieldAuthorizeNotes = ownLevelNotes(field.annotations).filter((note) => containsAuthorizeAnnotationTag([note.text]));
250646
+ if (fieldAuthorizeNotes.length === 0)
250647
+ continue;
250648
+ if (fieldAuthorizeNotes.every((note) => gatedSourceOwnAuthorizeNotes.has(note))) {
250649
+ continue;
250650
+ }
250651
+ const fieldName2 = field.as || field.name;
250652
+ if (isJoined2(field) && isSourceDef3(field)) {
250653
+ const joinedStruct = field;
250654
+ if (joinFieldNamesUnresolvableDeclaration(joinedStruct, modelDef)) {
250655
+ continue;
250656
+ }
250657
+ misplacedAuthorize.push({
250658
+ kind: "field",
250659
+ name: sourceName,
250660
+ fieldName: fieldName2
250661
+ });
250662
+ continue;
250663
+ }
250664
+ misplacedAuthorize.push({
250665
+ kind: "field",
250666
+ name: sourceName,
250667
+ fieldName: fieldName2
250668
+ });
250669
+ }
249328
250670
  return {
249329
250671
  name: sourceName,
249330
250672
  annotations,
@@ -249334,20 +250676,33 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
249334
250676
  authorize
249335
250677
  };
249336
250678
  });
249337
- return { sources, filterMap, authorizeMap, ownAuthorizeSources };
250679
+ return {
250680
+ sources,
250681
+ filterMap,
250682
+ authorizeMap,
250683
+ misplacedAuthorize,
250684
+ authorizeOwnNotes
250685
+ };
249338
250686
  }
249339
250687
  function extractQueriesFromModelDef(modelDef) {
249340
250688
  const isNamedQuery = (obj) => obj.type === "query";
249341
- return Object.values(modelDef.contents).filter(isNamedQuery).map((queryObj) => ({
250689
+ const namedQueries = Object.values(modelDef.contents).filter(isNamedQuery);
250690
+ const misplacedAuthorize = namedQueries.filter((queryObj) => containsAuthorizeAnnotationTag(ownLevelNoteTexts(queryObj.annotations))).map((queryObj) => ({
250691
+ kind: "query",
250692
+ name: queryObj.as || queryObj.name
250693
+ }));
250694
+ const queries = namedQueries.map((queryObj) => ({
249342
250695
  name: queryObj.as || queryObj.name,
249343
250696
  sourceName: typeof queryObj.structRef === "string" ? queryObj.structRef : undefined,
249344
250697
  annotations: annotationTexts(queryObj.annotations)
249345
250698
  }));
250699
+ return { queries, misplacedAuthorize };
249346
250700
  }
249347
250701
  var init_source_extraction = __esm(() => {
249348
250702
  init_annotations();
249349
250703
  init_authorize();
249350
250704
  init_filter();
250705
+ init_gate_registry_walk();
249351
250706
  });
249352
250707
 
249353
250708
  // src/service/model.ts
@@ -249355,10 +250710,10 @@ import {
249355
250710
  API,
249356
250711
  FixedConnectionMap,
249357
250712
  InMemoryURLReader as InMemoryURLReader2,
249358
- isSourceDef as isSourceDef2,
250713
+ isSourceDef as isSourceDef4,
249359
250714
  MalloyConfig as MalloyConfig2,
249360
250715
  MalloyError as MalloyError2,
249361
- Annotations as Annotations4,
250716
+ Annotations as Annotations5,
249362
250717
  modelDefToModelInfo,
249363
250718
  Runtime as Runtime2
249364
250719
  } from "@malloydata/malloy";
@@ -249371,7 +250726,7 @@ import { readFileSync } from "fs";
249371
250726
  import { createRequire as createRequire2 } from "module";
249372
250727
  import * as path8 from "path";
249373
250728
  import { fileURLToPath as fileURLToPath5 } from "url";
249374
- function quoteMalloyIdentifier(name) {
250729
+ function quoteMalloyIdentifier2(name) {
249375
250730
  return "`" + (name ?? "").replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
249376
250731
  }
249377
250732
  function makeHydrationRuntime(malloyConfig, buildManifest) {
@@ -249414,6 +250769,7 @@ function hydrateNotebookCells(runtime, notebookCells) {
249414
250769
  text: sc.text,
249415
250770
  runnable,
249416
250771
  modelMaterializer,
250772
+ modelDef: cellModelDef,
249417
250773
  newSources: sc.newSources,
249418
250774
  queryInfo: sc.queryInfo
249419
250775
  };
@@ -249428,7 +250784,7 @@ function hydrateMarkdownOnlyCells(notebookCells) {
249428
250784
  return { type: "code", text: sc.text };
249429
250785
  });
249430
250786
  }
249431
- var MALLOY_VERSION, ANCESTOR_WALK_MAX_DEPTH = 32, Model;
250787
+ var MALLOY_VERSION, Model;
249432
250788
  var init_model = __esm(() => {
249433
250789
  init_telemetry();
249434
250790
  init_materialization_metrics();
@@ -249445,12 +250801,15 @@ var init_model = __esm(() => {
249445
250801
  init_annotations();
249446
250802
  init_build_plan();
249447
250803
  init_authorize();
250804
+ init_dashboard();
249448
250805
  init_filter();
249449
250806
  init_given();
250807
+ init_motly();
249450
250808
  init_model_limits();
249451
250809
  init_json_utils();
249452
250810
  init_query_metadata();
249453
250811
  init_preaggregation_validation();
250812
+ init_gate_registry_walk();
249454
250813
  init_source_extraction();
249455
250814
  init_authorize_metrics();
249456
250815
  MALLOY_VERSION = createRequire2(import.meta.url)("@malloydata/malloy/package.json").version;
@@ -249473,7 +250832,6 @@ var init_model = __esm(() => {
249473
250832
  compilationError;
249474
250833
  filterMap;
249475
250834
  givens;
249476
- fileLevelAuthorize = [];
249477
250835
  declaredQueryMetadataMemo;
249478
250836
  declaredSourceQueryMetadataMemo;
249479
250837
  authorizeReferencedGivenNames = new Set;
@@ -249482,6 +250840,11 @@ var init_model = __esm(() => {
249482
250840
  freshnessResolver;
249483
250841
  preaggregateEntityIdResolver;
249484
250842
  entryPointGatesBySource = new Map;
250843
+ gateRuntime;
250844
+ gateShapeCache = new Map;
250845
+ graftedMaterializerCache = new Map;
250846
+ static GRAFTED_MATERIALIZER_CACHE_MAX = 32;
250847
+ rowLevelFilteredRunnables = new WeakSet;
249485
250848
  meter = publisherMeter();
249486
250849
  queryExecutionHistogram = this.meter.createHistogram("malloy_model_query_duration", {
249487
250850
  description: "How long it takes to execute a Malloy model query",
@@ -249505,11 +250868,6 @@ var init_model = __esm(() => {
249505
250868
  this.compilationError = compilationError;
249506
250869
  this.filterMap = filterMap ?? new Map;
249507
250870
  this.givens = givens;
249508
- try {
249509
- this.fileLevelAuthorize = this.modelDef ? collectAuthorizeExprs((modelAnnotations(this.modelDef).notes ?? []).map((note) => note.text)) : [];
249510
- } catch {
249511
- this.fileLevelAuthorize = [];
249512
- }
249513
250871
  try {
249514
250872
  this.entryPointGatesBySource = this.computeEntryPointGatesBySource();
249515
250873
  } catch {
@@ -249539,8 +250897,64 @@ var init_model = __esm(() => {
249539
250897
  getFilters(sourceName) {
249540
250898
  return this.filterMap.get(sourceName) ?? [];
249541
250899
  }
250900
+ setGateRuntime(runtime) {
250901
+ this.gateRuntime = runtime;
250902
+ }
250903
+ defaultGraftScope() {
250904
+ if (!this.modelDef || !this.modelMaterializer)
250905
+ return;
250906
+ return {
250907
+ modelDef: this.modelDef,
250908
+ materializer: this.modelMaterializer,
250909
+ cacheScope: "model"
250910
+ };
250911
+ }
250912
+ graftScopeForCell(cellIndex) {
250913
+ const cells = this.runnableNotebookCells;
250914
+ if (!cells)
250915
+ return;
250916
+ for (let i = cellIndex - 1;i >= 0; i--) {
250917
+ const prior = cells[i];
250918
+ if (prior.type === "code" && prior.modelDef && prior.modelMaterializer) {
250919
+ return {
250920
+ modelDef: prior.modelDef,
250921
+ materializer: prior.modelMaterializer,
250922
+ cacheScope: `cell:${i}`
250923
+ };
250924
+ }
250925
+ }
250926
+ return;
250927
+ }
250928
+ selfGraftScopeForCell(cellIndex) {
250929
+ const cell = this.runnableNotebookCells?.[cellIndex];
250930
+ if (!cell?.modelDef || !cell.modelMaterializer)
250931
+ return;
250932
+ return {
250933
+ modelDef: cell.modelDef,
250934
+ materializer: cell.modelMaterializer,
250935
+ cacheScope: `cell-self:${cellIndex}`
250936
+ };
250937
+ }
250938
+ async resolveNotebookCellGraftScope(cellIndex, runnable) {
250939
+ const selfScope = this.selfGraftScopeForCell(cellIndex);
250940
+ const earlierScope = this.graftScopeForCell(cellIndex);
250941
+ if (!earlierScope)
250942
+ return { graftScope: selfScope, usesOwnScope: true };
250943
+ const { struct, modelDef } = await this.resolveRunTargetStruct(runnable);
250944
+ if (struct && modelDef && this.resolveGraftTarget(struct, modelDef, earlierScope.modelDef)) {
250945
+ return { graftScope: earlierScope, usesOwnScope: false };
250946
+ }
250947
+ return { graftScope: selfScope, usesOwnScope: true };
250948
+ }
250949
+ givenDeclaredTypesCache;
249542
250950
  givenDeclaredTypes() {
249543
- return new Map((this.givens ?? []).filter((g) => g.name != null && g.type != null).map((g) => [g.name, g.type]));
250951
+ this.givenDeclaredTypesCache ??= new Map((this.givens ?? []).filter((g) => g.name != null && g.type != null).map((g) => [g.name, g.type]));
250952
+ return this.givenDeclaredTypesCache;
250953
+ }
250954
+ givenDeclaredDefaultsCache;
250955
+ givenDeclaredDefaults() {
250956
+ this.givenDeclaredDefaultsCache ??= new Map((this.givens ?? []).filter((g) => g.name != null && g.default != null).map((g) => [g.name, g.default]));
250957
+ return this.givenDeclaredDefaultsCache;
249544
250958
  }
249545
250959
  getAuthorize(sourceName) {
249546
250960
  return this.sources?.find((source) => source.name === sourceName)?.authorize ?? [];
@@ -249565,7 +250979,6 @@ var init_model = __esm(() => {
249565
250979
  names.add(name);
249566
250980
  }
249567
250981
  };
249568
- addExprs(this.fileLevelAuthorize);
249569
250982
  for (const gates of this.entryPointGatesBySource.values()) {
249570
250983
  for (const { exprs } of gates)
249571
250984
  addExprs(exprs);
@@ -249578,7 +250991,7 @@ var init_model = __esm(() => {
249578
250991
  if (!modelDef)
249579
250992
  return byName;
249580
250993
  for (const entry of Object.values(modelDef.contents)) {
249581
- if (!isSourceDef2(entry))
250994
+ if (!isSourceDef4(entry))
249582
250995
  continue;
249583
250996
  const name = entry.as ?? entry.name;
249584
250997
  byName.set(name, this.collectEntryPointGates(entry, modelDef, new Set, true));
@@ -249586,27 +250999,68 @@ var init_model = __esm(() => {
249586
250999
  return byName;
249587
251000
  }
249588
251001
  hasAuthorize() {
249589
- return this.fileLevelAuthorize.length > 0 || (this.sources?.some((s) => (s.authorize?.length ?? 0) > 0) ?? false);
249590
- }
249591
- effectiveAuthorizeFor(sourceName) {
249592
- if (sourceName && this.sources?.some((s) => s.name === sourceName)) {
249593
- return this.getAuthorize(sourceName);
249594
- }
249595
- return this.fileLevelAuthorize;
251002
+ return this.sources?.some((s) => (s.authorize?.length ?? 0) > 0) ?? false;
251003
+ }
251004
+ anyAuthorizeNote;
251005
+ hasAnyAuthorizeNote() {
251006
+ if (this.anyAuthorizeNote !== undefined)
251007
+ return this.anyAuthorizeNote;
251008
+ this.anyAuthorizeNote = (() => {
251009
+ const modelDef = this.modelDef;
251010
+ if (!modelDef)
251011
+ return false;
251012
+ try {
251013
+ const structs = [];
251014
+ for (const obj of Object.values(modelDef.contents)) {
251015
+ if (isSourceDef4(obj))
251016
+ structs.push(obj);
251017
+ }
251018
+ for (const value of Object.values(modelDef.sourceRegistry ?? {})) {
251019
+ const entry = value.entry;
251020
+ if (entry.type === "source_registry_reference")
251021
+ continue;
251022
+ if (isSourceDef4(entry))
251023
+ structs.push(entry);
251024
+ }
251025
+ structs.push(...derivedStructsReachable(structs, modelDef));
251026
+ for (const struct of structs) {
251027
+ if (containsAuthorizeAnnotationTag(annotationTexts(struct.annotations) ?? [])) {
251028
+ return true;
251029
+ }
251030
+ for (const field of struct.fields) {
251031
+ if (containsAuthorizeAnnotationTag(annotationTexts(field.annotations) ?? [])) {
251032
+ return true;
251033
+ }
251034
+ }
251035
+ }
251036
+ return false;
251037
+ } catch {
251038
+ return true;
251039
+ }
251040
+ })();
251041
+ return this.anyAuthorizeNote;
249596
251042
  }
249597
- async assertAuthorized(sourceName, givens, bypassAuthorize = false) {
251043
+ async assertAuthorized(sourceName, givens, bypassAuthorize = false, graftScope = this.defaultGraftScope()) {
249598
251044
  if (bypassAuthorize) {
249599
251045
  this.noteAuthorizeBypass("source", sourceName);
249600
251046
  return;
249601
251047
  }
249602
251048
  const gates = sourceName ? this.entryPointGatesBySource.get(sourceName) : undefined;
249603
251049
  if (gates) {
249604
- for (const { label, exprs, selfContained, ambientPrefix } of gates) {
249605
- await this.assertAuthorizedExprs(label, exprs, givens, selfContained, ambientPrefix);
251050
+ for (const entry of gates) {
251051
+ const resolution = this.modelDef ? await this.resolveGateShape(entry, this.modelDef, graftScope) : { kind: "given_only" };
251052
+ if (resolution.kind === "row_level")
251053
+ continue;
251054
+ if (resolution.kind === "deny") {
251055
+ recordRowLevelGateDecision("denied_by_gate");
251056
+ if (resolution.cause)
251057
+ recordRowLevelGateRejected(resolution.cause);
251058
+ throw new AccessDeniedError(`Access denied for source "${entry.label}".`);
251059
+ }
251060
+ await this.assertAuthorizedExprs(entry.label, entry.exprs, givens, entry.selfContained);
249606
251061
  }
249607
251062
  return;
249608
251063
  }
249609
- await this.assertAuthorizedExprs(sourceName ?? "(query)", this.effectiveAuthorizeFor(sourceName), givens);
249610
251064
  }
249611
251065
  noteAuthorizeBypass(entryPoint, sourceName) {
249612
251066
  recordAuthorizeBypass(entryPoint);
@@ -249617,7 +251071,7 @@ var init_model = __esm(() => {
249617
251071
  packageName: this.packageName
249618
251072
  });
249619
251073
  }
249620
- async assertAuthorizedExprs(label, exprs, givens, selfContainedFirst = false, ambientPrefix = 0) {
251074
+ async assertAuthorizedExprs(label, exprs, givens, selfContainedFirst = false) {
249621
251075
  if (exprs.length === 0)
249622
251076
  return;
249623
251077
  const deny = () => {
@@ -249627,7 +251081,7 @@ var init_model = __esm(() => {
249627
251081
  deny();
249628
251082
  let passed = false;
249629
251083
  try {
249630
- passed = await evaluateAuthorize(this.modelMaterializer, exprs, givens, this.givenDeclaredTypes(), { selfContainedFirst, ambientPrefix });
251084
+ passed = await evaluateAuthorize(this.modelMaterializer, exprs, givens, this.givenDeclaredTypes(), { selfContainedFirst });
249631
251085
  } catch (err) {
249632
251086
  logger.debug("Authorize probe failed; denying", {
249633
251087
  sourceName: label,
@@ -249640,28 +251094,102 @@ var init_model = __esm(() => {
249640
251094
  deny();
249641
251095
  }
249642
251096
  async assertAuthorizedForAllSources(runnable, givens, bypassAuthorize = false) {
249643
- if (bypassAuthorize) {
249644
- this.noteAuthorizeBypass("runnable", await this.resolveAuthorizeSourceFromRunnable(runnable));
249645
- return;
249646
- }
249647
- const ownSourceName = await this.resolveAuthorizeSourceFromRunnable(runnable);
249648
- await this.assertAuthorized(ownSourceName, givens);
249649
- await this.assertAuthorizedFromCompiledRunnable(runnable, givens);
251097
+ await this.authorizeAndBindRunnable(runnable, givens, {
251098
+ bypassAuthorize
251099
+ });
249650
251100
  }
249651
- async assertAuthorizedFromCompiledRunnable(runnable, givens) {
251101
+ async collectAuthorizeEntryPointGates(runnable, givens, graftScope, skipOwnSourceGate = false) {
251102
+ const ownSourceName = await this.resolveAuthorizeSourceFromRunnable(runnable);
251103
+ if (!skipOwnSourceGate) {
251104
+ await this.assertAuthorized(ownSourceName, givens, false, graftScope);
251105
+ }
249652
251106
  const { struct, modelDef, compositeResolvedSourceDef } = await this.resolveRunTargetStruct(runnable);
249653
251107
  const seen = new Set;
249654
251108
  const entryPointGates = this.collectEntryPointGates(struct, modelDef, seen, true);
249655
251109
  if (compositeResolvedSourceDef && modelDef) {
249656
- entryPointGates.push(...this.collectEntryPointGates(compositeResolvedSourceDef, modelDef, seen, true));
251110
+ entryPointGates.push(...this.collectEntryPointGates(compositeResolvedSourceDef, modelDef, seen, true, undefined, struct ? ownLevelNotes(struct.annotations) : []));
251111
+ }
251112
+ return { entryPointGates, modelDef };
251113
+ }
251114
+ async assertAuthorizedFromCompiledRunnable(runnable, givens) {
251115
+ await this.authorizeAndBindRunnable(runnable, givens, {
251116
+ skipOwnSourceGate: true
251117
+ });
251118
+ }
251119
+ queryHadRowLevelFilterAttached(runnable) {
251120
+ return this.rowLevelFilteredRunnables.has(runnable);
251121
+ }
251122
+ async probeEntryPointGates(runnable, givens, graftScope, skipOwnSourceGate = false) {
251123
+ const { entryPointGates, modelDef } = await this.collectAuthorizeEntryPointGates(runnable, givens, graftScope, skipOwnSourceGate);
251124
+ const rowLevel = [];
251125
+ for (const entry of entryPointGates) {
251126
+ const resolution = modelDef ? await this.resolveGateShape(entry, modelDef, graftScope) : { kind: "given_only" };
251127
+ if (resolution.kind === "row_level") {
251128
+ rowLevel.push({
251129
+ label: entry.label,
251130
+ graftTarget: resolution.graftTarget,
251131
+ filterText: resolution.filterText,
251132
+ condition: resolution.condition
251133
+ });
251134
+ continue;
251135
+ }
251136
+ if (resolution.kind === "deny") {
251137
+ recordRowLevelGateDecision("denied_by_gate");
251138
+ if (resolution.cause)
251139
+ recordRowLevelGateRejected(resolution.cause);
251140
+ throw new AccessDeniedError(`Access denied for source "${entry.label}".`);
251141
+ }
251142
+ await this.assertAuthorizedExprs(entry.label, entry.exprs, givens, entry.selfContained);
251143
+ }
251144
+ return rowLevel;
251145
+ }
251146
+ async queryEntryPointHasRowLevelGate(runnable) {
251147
+ try {
251148
+ const { struct, modelDef, compositeResolvedSourceDef } = await this.resolveRunTargetStruct(runnable);
251149
+ if (!modelDef)
251150
+ return true;
251151
+ const seen = new Set;
251152
+ const gates = this.collectEntryPointGates(struct, modelDef, seen, true);
251153
+ if (compositeResolvedSourceDef) {
251154
+ gates.push(...this.collectEntryPointGates(compositeResolvedSourceDef, modelDef, seen, true, undefined, struct ? ownLevelNotes(struct.annotations) : []));
251155
+ }
251156
+ const graftScope = this.defaultGraftScope();
251157
+ for (const entry of gates) {
251158
+ const resolution = await this.resolveGateShape(entry, modelDef, graftScope);
251159
+ if (resolution.kind !== "given_only")
251160
+ return true;
251161
+ }
251162
+ return false;
251163
+ } catch {
251164
+ return true;
251165
+ }
251166
+ }
251167
+ async authorizeAndBindRunnable(runnable, givens, options) {
251168
+ if (options?.bypassAuthorize) {
251169
+ this.noteAuthorizeBypass("runnable", await this.resolveAuthorizeSourceFromRunnable(runnable));
251170
+ return runnable;
249657
251171
  }
249658
- for (const {
249659
- label,
249660
- exprs,
249661
- selfContained,
249662
- ambientPrefix
249663
- } of entryPointGates) {
249664
- await this.assertAuthorizedExprs(label, exprs, givens, selfContained, ambientPrefix);
251172
+ const graftScope = options?.graftScope ?? this.defaultGraftScope();
251173
+ const rowLevel = await this.probeEntryPointGates(runnable, givens, graftScope, options?.skipOwnSourceGate ?? false);
251174
+ if (rowLevel.length === 0)
251175
+ return runnable;
251176
+ if (!options?.recompile) {
251177
+ recordRowLevelGateDecision("denied_by_gate");
251178
+ throw new AccessDeniedError(`Access denied for source "${rowLevel[0].label}".`);
251179
+ }
251180
+ try {
251181
+ const graftedMaterializer = this.getOrBuildGraftedMaterializer(rowLevel, graftScope);
251182
+ const recompiled = options.recompile(graftedMaterializer);
251183
+ await this.assertGateLanded(recompiled, rowLevel);
251184
+ this.rowLevelFilteredRunnables.add(recompiled);
251185
+ return recompiled;
251186
+ } catch (err) {
251187
+ recordRowLevelGateDecision("denied_by_gate");
251188
+ logger.debug("Row-level authorize attach failed; denying", {
251189
+ modelPath: this.modelPath,
251190
+ error: err instanceof Error ? err.message : String(err)
251191
+ });
251192
+ throw new AccessDeniedError(`Access denied for source "${rowLevel[0].label}".`);
249665
251193
  }
249666
251194
  }
249667
251195
  async resolveRunTargetStruct(runnable) {
@@ -249689,107 +251217,233 @@ var init_model = __esm(() => {
249689
251217
  };
249690
251218
  }
249691
251219
  }
249692
- gateExprsForOwnAnnotations(struct, modelDef) {
249693
- const ownNotes = ownLevelNoteTexts(struct.annotations);
251220
+ gateExprsForOwnAnnotations(struct, modelDef, excludeNotes = []) {
251221
+ const ownNotes = ownLevelNotes(struct.annotations).filter((note) => !excludeNotes.includes(note));
249694
251222
  try {
249695
- const own = collectAuthorizeExprs(ownNotes);
251223
+ const own = collectAuthorizeExprs(ownNotes.map((note) => note.text));
249696
251224
  if (own.length > 0) {
249697
- return {
249698
- exprs: [...this.fileLevelAuthorize, ...own],
249699
- fromAncestor: false,
249700
- ambientPrefix: this.fileLevelAuthorize.length
249701
- };
251225
+ return { exprs: own, fromAncestor: false };
249702
251226
  }
249703
- const ancestor = this.ancestorGateExprs(struct, modelDef);
249704
- return {
249705
- exprs: [...this.fileLevelAuthorize, ...ancestor],
249706
- fromAncestor: ancestor.length > 0,
249707
- ambientPrefix: this.fileLevelAuthorize.length
249708
- };
251227
+ const ancestor = ancestorGateExprs(struct, modelDef);
251228
+ return { exprs: ancestor, fromAncestor: ancestor.length > 0 };
249709
251229
  } catch {
249710
- return { exprs: ["false"], fromAncestor: false, ambientPrefix: 0 };
249711
- }
249712
- }
249713
- ancestorGateExprs(struct, modelDef, seen = new Set) {
249714
- let inherited = struct.annotations?.inherits;
249715
- for (let depth = 0;inherited && depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
249716
- const exprs2 = collectAuthorizeExprs(ownLevelNoteTexts(inherited));
249717
- if (exprs2.length > 0)
249718
- return exprs2;
249719
- inherited = inherited.inherits;
249720
- }
249721
- if (inherited)
249722
- return ["false"];
249723
- seen.add(struct);
249724
- if (seen.size > ANCESTOR_WALK_MAX_DEPTH)
249725
- return ["false"];
249726
- const declared = this.resolveDeclaredSource(struct, modelDef);
249727
- if (declared.kind === "unresolvable")
249728
- return ["false"];
249729
- if (declared.kind === "none" || seen.has(declared.source))
249730
- return [];
249731
- const exprs = collectAuthorizeExprs(ownLevelNoteTexts(declared.source.annotations));
249732
- return exprs.length > 0 ? exprs : this.ancestorGateExprs(declared.source, modelDef, seen);
249733
- }
249734
- resolveDeclaredSource(struct, modelDef) {
249735
- if (!modelDef)
249736
- return { kind: "none" };
249737
- let sawBrokenEntry = false;
249738
- for (const id of [struct.referenceID, struct.sourceID]) {
249739
- const entry = id ? modelDef.sourceRegistry?.[id]?.entry : undefined;
249740
- if (!entry)
249741
- continue;
249742
- const declared = entry.type === "source_registry_reference" ? modelDef.contents[entry.name] : entry;
249743
- if (declared === struct)
249744
- continue;
249745
- if (!declared || !isSourceDef2(declared)) {
249746
- sawBrokenEntry = true;
249747
- continue;
249748
- }
249749
- return { kind: "resolved", source: declared };
251230
+ return { exprs: ["false"], fromAncestor: false };
249750
251231
  }
249751
- return sawBrokenEntry ? { kind: "unresolvable" } : { kind: "none" };
249752
251232
  }
249753
- collectEntryPointGates(struct, modelDef, seen = new Set, treatAsOwnGate = false) {
251233
+ collectEntryPointGates(struct, modelDef, seen = new Set, treatAsOwnGate = false, entryPointStruct = struct, excludeNotes = []) {
249754
251234
  if (!struct || !modelDef || seen.has(struct))
249755
251235
  return [];
249756
251236
  seen.add(struct);
249757
251237
  const results = [];
249758
251238
  const label = struct.as ?? struct.name;
249759
- const {
249760
- exprs: ownExprs,
249761
- fromAncestor,
249762
- ambientPrefix
249763
- } = this.gateExprsForOwnAnnotations(struct, modelDef);
251239
+ const { exprs: ownExprs, fromAncestor } = this.gateExprsForOwnAnnotations(struct, modelDef, excludeNotes);
249764
251240
  if (ownExprs.length > 0) {
249765
251241
  results.push({
249766
251242
  label,
249767
251243
  exprs: ownExprs,
249768
251244
  selfContained: fromAncestor || !treatAsOwnGate,
249769
- ambientPrefix
251245
+ struct: entryPointStruct
249770
251246
  });
249771
251247
  }
249772
251248
  const duck = struct;
249773
251249
  if (duck.type === "query_source") {
249774
- const ref = duck.query?.structRef;
249775
- const base = typeof ref === "string" ? modelDef.contents[ref] : ref;
249776
- if (base && isSourceDef2(base)) {
249777
- results.push(...this.collectEntryPointGates(base, modelDef, seen));
251250
+ const base = resolveQuerySourceBase(struct, modelDef);
251251
+ if (base) {
251252
+ results.push(...this.collectEntryPointGates(base, modelDef, seen, false, entryPointStruct));
249778
251253
  } else {
249779
251254
  results.push({
249780
251255
  label,
249781
251256
  exprs: ["false"],
249782
- selfContained: true,
249783
- ambientPrefix: 0
251257
+ selfContained: true
249784
251258
  });
249785
251259
  }
249786
251260
  const resolved = duck.query?.compositeResolvedSourceDef;
249787
251261
  if (resolved) {
249788
- results.push(...this.collectEntryPointGates(resolved, modelDef, seen));
251262
+ results.push(...this.collectEntryPointGates(resolved, modelDef, seen, false, entryPointStruct, base ? ownLevelNotes(base.annotations) : []));
249789
251263
  }
249790
251264
  }
249791
251265
  return results;
249792
251266
  }
251267
+ async resolveGateShape(entry, originModelDef, graftScope) {
251268
+ if (!entry.struct)
251269
+ return { kind: "given_only" };
251270
+ if (!graftScope) {
251271
+ logger.debug("Row-level gate has no graft scope to attach to (no scope was available at all, not the gate's own condition); denying", { modelPath: this.modelPath, label: entry.label });
251272
+ return { kind: "deny" };
251273
+ }
251274
+ const graftTarget = this.resolveGraftTarget(entry.struct, originModelDef, graftScope.modelDef);
251275
+ if (!graftTarget) {
251276
+ return this.classifyWithoutGraft(entry, graftScope.materializer);
251277
+ }
251278
+ const filterText = gateFilterText(entry.exprs);
251279
+ const cacheKey = `${graftScope.cacheScope}\x00${graftTarget}\x00${filterText}`;
251280
+ let cached2 = this.gateShapeCache.get(cacheKey);
251281
+ if (!cached2) {
251282
+ let condition;
251283
+ try {
251284
+ condition = await this.liftGateCondition(graftTarget, filterText, graftScope.materializer);
251285
+ } catch (err) {
251286
+ logger.debug("Row-level gate condition failed to lift; checking whether it is given-only before denying", {
251287
+ modelPath: this.modelPath,
251288
+ graftTarget,
251289
+ error: err instanceof Error ? err.message : String(err)
251290
+ });
251291
+ return this.classifyWithoutGraft(entry, graftScope.materializer);
251292
+ }
251293
+ const classification = classifyAuthorizeGate(condition, this.givenDeclaredTypes(), this.givenDeclaredDefaults());
251294
+ cached2 = { classification, condition };
251295
+ this.gateShapeCache.set(cacheKey, cached2);
251296
+ }
251297
+ if (cached2.classification.shape === "given_only") {
251298
+ return { kind: "given_only" };
251299
+ }
251300
+ if (cached2.classification.shape === "rejected") {
251301
+ return { kind: "deny", cause: cached2.classification.cause };
251302
+ }
251303
+ return {
251304
+ kind: "row_level",
251305
+ graftTarget,
251306
+ filterText,
251307
+ condition: cached2.condition
251308
+ };
251309
+ }
251310
+ async classifyWithoutGraft(entry, materializer) {
251311
+ try {
251312
+ await materializer.loadQuery(buildAuthorizeProbe(entry.exprs)).getPreparedQuery();
251313
+ return { kind: "given_only" };
251314
+ } catch (err) {
251315
+ logger.debug("Row-level gate has no attachable graft and does not compile as given-only; denying", {
251316
+ modelPath: this.modelPath,
251317
+ label: entry.label,
251318
+ error: err instanceof Error ? err.message : String(err)
251319
+ });
251320
+ return { kind: "deny" };
251321
+ }
251322
+ }
251323
+ resolveGraftTarget(struct, originModelDef, graftModelDef) {
251324
+ const direct = this.findContentsKey(struct, graftModelDef);
251325
+ if (direct)
251326
+ return direct;
251327
+ let current = struct;
251328
+ const seen = new Set([struct]);
251329
+ for (let depth = 0;depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
251330
+ const declared = resolveDeclaredSource(current, originModelDef);
251331
+ let next;
251332
+ if (declared.kind === "resolved" && !seen.has(declared.source)) {
251333
+ next = declared.source;
251334
+ } else if (declared.kind === "none") {
251335
+ next = this.findSourceByOwnAnnotationIdentity(current, graftModelDef, seen);
251336
+ }
251337
+ if (!next)
251338
+ return;
251339
+ const key = this.findContentsKey(next, graftModelDef);
251340
+ if (key)
251341
+ return key;
251342
+ seen.add(next);
251343
+ current = next;
251344
+ }
251345
+ return;
251346
+ }
251347
+ findContentsKey(struct, modelDef) {
251348
+ for (const [key, value] of Object.entries(modelDef.contents)) {
251349
+ if (value === struct)
251350
+ return key;
251351
+ }
251352
+ if (struct.sourceID) {
251353
+ for (const [key, value] of Object.entries(modelDef.contents)) {
251354
+ if (isSourceDef4(value) && value.sourceID === struct.sourceID) {
251355
+ return key;
251356
+ }
251357
+ }
251358
+ }
251359
+ return;
251360
+ }
251361
+ findSourceByOwnAnnotationIdentity(struct, modelDef, exclude) {
251362
+ const ownNotes = [
251363
+ ...struct.annotations?.blockNotes ?? [],
251364
+ ...struct.annotations?.notes ?? []
251365
+ ];
251366
+ if (ownNotes.length === 0)
251367
+ return;
251368
+ for (const value of Object.values(modelDef.contents)) {
251369
+ if (!isSourceDef4(value) || value === struct || exclude.has(value)) {
251370
+ continue;
251371
+ }
251372
+ const candidateNotes = [
251373
+ ...value.annotations?.blockNotes ?? [],
251374
+ ...value.annotations?.notes ?? []
251375
+ ];
251376
+ if (candidateNotes.some((note) => ownNotes.includes(note))) {
251377
+ return value;
251378
+ }
251379
+ }
251380
+ return;
251381
+ }
251382
+ async liftGateCondition(graftTarget, filterText, materializer) {
251383
+ const probe = materializer.loadQuery(buildRowLevelProbe(graftTarget, filterText));
251384
+ const prepared = await probe.getPreparedQuery();
251385
+ return liftProbeFilterCondition(prepared, `lifted probe for "${graftTarget}"`, filterText);
251386
+ }
251387
+ getOrBuildGraftedMaterializer(grafts, graftScope) {
251388
+ const key = `${graftScope.cacheScope}\x00` + grafts.map((g) => `${g.graftTarget}\x00${g.filterText}`).sort().join("\x01");
251389
+ const materializer = this.graftedMaterializerCache.get(key);
251390
+ if (materializer) {
251391
+ this.graftedMaterializerCache.delete(key);
251392
+ this.graftedMaterializerCache.set(key, materializer);
251393
+ return materializer;
251394
+ }
251395
+ const built = this.buildGraftedMaterializer(grafts, graftScope.modelDef);
251396
+ this.graftedMaterializerCache.set(key, built);
251397
+ while (this.graftedMaterializerCache.size > Model.GRAFTED_MATERIALIZER_CACHE_MAX) {
251398
+ const oldest = this.graftedMaterializerCache.keys().next();
251399
+ if (oldest.done)
251400
+ break;
251401
+ this.graftedMaterializerCache.delete(oldest.value);
251402
+ }
251403
+ return built;
251404
+ }
251405
+ buildGraftedMaterializer(grafts, modelDef) {
251406
+ if (!this.gateRuntime) {
251407
+ throw new Error("no retained runtime to graft a row-level gate through");
251408
+ }
251409
+ const copy = structuredClone(modelDef);
251410
+ for (const { graftTarget, condition } of grafts) {
251411
+ const target = copy.contents[graftTarget];
251412
+ if (!target || !isSourceDef4(target)) {
251413
+ throw new Error(`graft target "${graftTarget}" is not a source in this model`);
251414
+ }
251415
+ target.filterList = [...target.filterList ?? [], condition];
251416
+ }
251417
+ return this.gateRuntime._loadModelFromModelDef(copy);
251418
+ }
251419
+ async assertGateLanded(recompiled, grafts) {
251420
+ const prepared = await recompiled.getPreparedQuery();
251421
+ const modelDef = prepared._modelDef ?? this.modelDef;
251422
+ const structRef = prepared._query?.structRef;
251423
+ const resolvedRef = typeof structRef === "string" ? modelDef?.contents[structRef] : structRef;
251424
+ const struct = resolvedRef && typeof resolvedRef === "object" ? resolvedRef : undefined;
251425
+ for (const { condition } of grafts) {
251426
+ if (!condition.code || !this.filterListContainsCode(struct, modelDef, condition.code, 0)) {
251427
+ throw new Error("a row-level gate condition did not land on the recompiled query");
251428
+ }
251429
+ }
251430
+ }
251431
+ static MAX_GATE_PROOF_DEPTH = 8;
251432
+ filterListContainsCode(struct, modelDef, code, depth) {
251433
+ if (!struct || depth > Model.MAX_GATE_PROOF_DEPTH)
251434
+ return false;
251435
+ if (struct.filterList?.some((f) => f.code === code))
251436
+ return true;
251437
+ const duck = struct;
251438
+ if (duck.type === "query_source" && modelDef) {
251439
+ const ref = duck.query?.structRef;
251440
+ const base = typeof ref === "string" ? modelDef.contents[ref] : ref;
251441
+ if (base && isSourceDef4(base)) {
251442
+ return this.filterListContainsCode(base, modelDef, code, depth + 1);
251443
+ }
251444
+ }
251445
+ return false;
251446
+ }
249793
251447
  async assertAuthorizedForText(text, givens) {
249794
251448
  await this.assertAuthorized(extractRunTargetSourceName(text), givens);
249795
251449
  }
@@ -249842,8 +251496,22 @@ var init_model = __esm(() => {
249842
251496
  const sourceResult = Model.getSources(modelDef, givens);
249843
251497
  sources = sourceResult.sources;
249844
251498
  filterMap = sourceResult.filterMap;
249845
- queries = Model.getQueries(modelDef);
249846
- await validateAuthorizeProbes(modelMaterializer, sourceResult.ownAuthorizeSources);
251499
+ const queryResult = Model.getQueries(modelDef);
251500
+ queries = queryResult.queries;
251501
+ assertNoMisplacedAuthorizeAnnotations([
251502
+ ...sourceResult.misplacedAuthorize,
251503
+ ...queryResult.misplacedAuthorize
251504
+ ]);
251505
+ const declaredGivenTypes = new Map((givens ?? []).filter((g) => g.name != null && g.type != null).map((g) => [g.name, g.type]));
251506
+ const declaredGivenDefaults = new Map((givens ?? []).filter((g) => g.name != null && g.default != null).map((g) => [g.name, g.default]));
251507
+ await validateAuthorizeProbes(modelMaterializer, {
251508
+ authorizeMap: sourceResult.authorizeMap,
251509
+ declaredTypes: declaredGivenTypes,
251510
+ declaredDefaults: declaredGivenDefaults,
251511
+ authorizeOwnNotes: sourceResult.authorizeOwnNotes,
251512
+ onRowLevelGateRejected: recordRowLevelGateRejected,
251513
+ onRowLevelGateUnexpressible: (sourceName, detail) => logger.warn("Row-level #(authorize) gate not expressible at this entry point; every query against it will be denied", { packageName, modelPath, sourceName, detail })
251514
+ });
249847
251515
  const imports = modelDef.imports || [];
249848
251516
  const importedSourceNames = new Set;
249849
251517
  for (const importLocation of imports) {
@@ -249873,7 +251541,9 @@ var init_model = __esm(() => {
249873
251541
  }
249874
251542
  }
249875
251543
  }
249876
- return new Model(packageName, modelPath, dataStyles, modelType, modelMaterializer, modelDef, sources, queries, sourceInfos.length > 0 ? sourceInfos : undefined, runnableNotebookCells, undefined, filterMap, givens);
251544
+ const model = new Model(packageName, modelPath, dataStyles, modelType, modelMaterializer, modelDef, sources, queries, sourceInfos.length > 0 ? sourceInfos : undefined, runnableNotebookCells, undefined, filterMap, givens);
251545
+ model.setGateRuntime(runtime);
251546
+ return model;
249877
251547
  } catch (error) {
249878
251548
  let computedError = error;
249879
251549
  if (error instanceof Error && error.stack) {
@@ -249898,6 +251568,9 @@ var init_model = __esm(() => {
249898
251568
  const sourceInfos = data.sourceInfos;
249899
251569
  const givens = data.givens;
249900
251570
  const filterMap = data.filterMap ? new Map(data.filterMap) : undefined;
251571
+ for (const warning of data.authorizeWarnings ?? []) {
251572
+ logger.warn(warning, { packageName, modelPath: data.modelPath });
251573
+ }
249901
251574
  if (!modelDef) {
249902
251575
  return new Model(packageName, data.modelPath, dataStyles, data.modelType, undefined, undefined, sources, queries, sourceInfos, data.modelType === "notebook" ? hydrateMarkdownOnlyCells(data.notebookCells) : undefined, undefined, filterMap, givens, modelInfo);
249903
251576
  }
@@ -249905,6 +251578,7 @@ var init_model = __esm(() => {
249905
251578
  const modelMaterializer = runtime._loadModelFromModelDef(modelDef);
249906
251579
  const runnableNotebookCells = data.modelType === "notebook" ? hydrateNotebookCells(runtime, data.notebookCells) : undefined;
249907
251580
  const model = new Model(packageName, data.modelPath, dataStyles, data.modelType, modelMaterializer, modelDef, sources, queries, sourceInfos, runnableNotebookCells, undefined, filterMap, givens, modelInfo);
251581
+ model.setGateRuntime(runtime);
249908
251582
  return model;
249909
251583
  }
249910
251584
  static fromCompilationError(packageName, modelPath, modelType, error) {
@@ -249979,6 +251653,11 @@ var init_model = __esm(() => {
249979
251653
  getQueries() {
249980
251654
  return this.curateForDiscovery(this.queries);
249981
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
+ }
249982
251661
  hasEmptyDiscoverySurface() {
249983
251662
  if (!this.discoveryCurationEnabled)
249984
251663
  return false;
@@ -250117,7 +251796,7 @@ var init_model = __esm(() => {
250117
251796
  }
250118
251797
  targets.push({
250119
251798
  label: query.name,
250120
- queryString: `run: ${quoteMalloyIdentifier(query.name)}`
251799
+ queryString: `run: ${quoteMalloyIdentifier2(query.name)}`
250121
251800
  });
250122
251801
  }
250123
251802
  for (const source of this.sources ?? []) {
@@ -250127,7 +251806,7 @@ var init_model = __esm(() => {
250127
251806
  }
250128
251807
  targets.push({
250129
251808
  label: `${source.name} -> ${view.name}`,
250130
- queryString: `run: ${quoteMalloyIdentifier(source.name)} -> ${quoteMalloyIdentifier(view.name)}`
251809
+ queryString: `run: ${quoteMalloyIdentifier2(source.name)} -> ${quoteMalloyIdentifier2(view.name)}`
250131
251810
  });
250132
251811
  }
250133
251812
  }
@@ -250164,9 +251843,37 @@ var init_model = __esm(() => {
250164
251843
  throw new ModelNotFoundError(`${this.modelPath} is not a valid model name. Model files must end in .malloy.`);
250165
251844
  }
250166
251845
  }
250167
- getNotebookError() {
251846
+ getCompilationError() {
250168
251847
  return this.compilationError;
250169
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
+ }
250170
251877
  async getNotebook() {
250171
251878
  if (this.compilationError) {
250172
251879
  throw this.compilationError;
@@ -250307,6 +252014,7 @@ var init_model = __esm(() => {
250307
252014
  }
250308
252015
  let runnable;
250309
252016
  let liveRunnable;
252017
+ let queryString;
250310
252018
  let serveVirtualMap;
250311
252019
  let serveShapeBindings = [];
250312
252020
  let servedFrom;
@@ -250337,13 +252045,12 @@ var init_model = __esm(() => {
250337
252045
  throw err;
250338
252046
  }
250339
252047
  }
250340
- let queryString;
250341
252048
  if (!sourceName && !queryName && query) {
250342
252049
  queryString = `
250343
252050
  ` + query;
250344
252051
  } else if (queryName && !query) {
250345
252052
  queryString = `
250346
- run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMalloyIdentifier(queryName)}`;
252053
+ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMalloyIdentifier2(queryName)}`;
250347
252054
  } else {
250348
252055
  const endTime = performance.now();
250349
252056
  const executionTime2 = endTime - startTime;
@@ -250368,7 +252075,10 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250368
252075
  }
250369
252076
  runnable = this.modelMaterializer.loadRestrictedQuery(queryString);
250370
252077
  liveRunnable = runnable;
250371
- if (getPersistStorageMode() === "on" && this.serveBindings.length > 0 && this.serveDestinationConfig) {
252078
+ const storageRoutingPossible = getPersistStorageMode() === "on" && this.serveBindings.length > 0 && !!this.serveDestinationConfig;
252079
+ const preaggServeMaterializer = this.preaggregateServeMaterializer;
252080
+ const routingBlockedByRowLevelGate = (storageRoutingPossible || !!preaggServeMaterializer) && !bypassAuthorize && this.hasAnyAuthorizeNote() && await this.queryEntryPointHasRowLevelGate(runnable);
252081
+ if (storageRoutingPossible && !routingBlockedByRowLevelGate) {
250372
252082
  try {
250373
252083
  const shaped = await this.loadServeShapeQuery(queryString);
250374
252084
  runnable = shaped.runnable;
@@ -250388,9 +252098,9 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250388
252098
  });
250389
252099
  }
250390
252100
  }
250391
- if (this.preaggregateServeMaterializer && !serveVirtualMap) {
252101
+ if (preaggServeMaterializer && !routingBlockedByRowLevelGate && !serveVirtualMap) {
250392
252102
  try {
250393
- const candidate = this.preaggregateServeMaterializer.loadRestrictedQuery(queryString);
252103
+ const candidate = preaggServeMaterializer.loadRestrictedQuery(queryString);
250394
252104
  await candidate.getSQL({
250395
252105
  givens: querySurfaceGivens,
250396
252106
  buildManifest
@@ -250430,7 +252140,10 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250430
252140
  if (boundary === "deferred") {
250431
252141
  this.assertQueryBoundaryCompiled(compiledSource, query);
250432
252142
  }
250433
- await this.assertAuthorizedForAllSources(runnable, givens ?? {}, bypassAuthorize);
252143
+ runnable = await this.authorizeAndBindRunnable(runnable, givens ?? {}, {
252144
+ recompile: (mm) => mm.loadRestrictedQuery(queryString),
252145
+ bypassAuthorize
252146
+ });
250434
252147
  const maxRows = getMaxQueryRows();
250435
252148
  const maxBytes = getMaxResponseBytes();
250436
252149
  const effectiveBuildManifest = serveVirtualMap ? undefined : preaggRouted ? buildManifest : liveBuildManifest;
@@ -250530,6 +252243,9 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250530
252243
  servedFrom = "live_fallback";
250531
252244
  executionTime = performance.now() - startTime;
250532
252245
  }
252246
+ if (this.queryHadRowLevelFilterAttached(runnable) && queryResults.totalRows === 0) {
252247
+ recordRowLevelGateDecision("empty_after_filter");
252248
+ }
250533
252249
  assertWithinModelRowLimit(queryResults.totalRows, maxRows, "model_query");
250534
252250
  const wrappedResult = API.util.wrapResult(queryResults);
250535
252251
  const serializedResult = stringifyQueryResponse(responseShape === "compact" ? queryResults.data.value : wrappedResult, queryResults.totalRows, maxBytes, "model_query", responseShape === "compact" ? bigIntReplacer : undefined);
@@ -250616,7 +252332,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250616
252332
  if (!this.modelDef)
250617
252333
  return;
250618
252334
  try {
250619
- return new Annotations4(ownModelAnnotations(this.modelDef)).parseAsTag().tag;
252335
+ return new Annotations5(ownModelAnnotations(this.modelDef)).parseAsTag().tag;
250620
252336
  } catch {
250621
252337
  return;
250622
252338
  }
@@ -250626,12 +252342,12 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250626
252342
  return;
250627
252343
  try {
250628
252344
  const entry = this.modelDef.contents?.[sourceName];
250629
- if (!entry || !isSourceDef2(entry))
252345
+ if (!entry || !isSourceDef4(entry))
250630
252346
  return;
250631
252347
  const def = entry;
250632
252348
  if (!def.annotations)
250633
252349
  return;
250634
- return new Annotations4(def.annotations).parseAsTag("@").tag;
252350
+ return new Annotations5(def.annotations).parseAsTag("@").tag;
250635
252351
  } catch {
250636
252352
  return;
250637
252353
  }
@@ -250664,6 +252380,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250664
252380
  };
250665
252381
  });
250666
252382
  const allAnnotations = this.modelDef ? ownModelNotes(this.modelDef) : [];
252383
+ const notebookTag = motlyTag(allAnnotations);
250667
252384
  const notebook = {
250668
252385
  type: "notebook",
250669
252386
  packageName: this.packageName,
@@ -250673,6 +252390,8 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250673
252390
  sources: this.modelDef && this.sources,
250674
252391
  queries: this.modelDef && this.queries,
250675
252392
  annotations: allAnnotations,
252393
+ autorun: readAutorun(notebookTag),
252394
+ startingGivens: readStartingGivens(notebookTag, (name) => (this.givens ?? []).find((g) => g.name === name)?.type),
250676
252395
  notebookCells
250677
252396
  };
250678
252397
  return notebook;
@@ -250694,26 +252413,35 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250694
252413
  text: cell.text
250695
252414
  };
250696
252415
  }
250697
- if (cell.runnable) {
250698
- await this.assertAuthorizedForAllSources(cell.runnable, givens ?? {});
250699
- }
250700
252416
  let queryName = undefined;
250701
252417
  let queryResult = undefined;
250702
252418
  if (cell.runnable) {
250703
252419
  try {
250704
252420
  let runnableToExecute = cell.runnable;
250705
- if (!bypassFilters && cell.modelMaterializer) {
250706
- const effectiveSource = extractRunTargetSourceName(cell.text);
250707
- if (effectiveSource) {
250708
- const filters = this.getFilters(effectiveSource);
250709
- if (filters.length > 0) {
250710
- const filterClause = buildFilterClause(filters, filterParams ?? {});
250711
- if (filterClause) {
250712
- const refinedQuery = injectFilterRefinement(cell.text, filterClause);
250713
- runnableToExecute = cell.modelMaterializer.loadQuery(refinedQuery);
250714
- }
250715
- }
250716
- }
252421
+ let textToExecute = cell.text;
252422
+ const { graftScope, usesOwnScope } = await this.resolveNotebookCellGraftScope(cellIndex, cell.runnable);
252423
+ if (!graftScope) {
252424
+ logger.debug("Notebook cell has no graft scope to attach a row-level gate against (no earlier code cell, and this cell has no compiled model of its own)", { modelPath: this.modelPath, cellIndex });
252425
+ }
252426
+ const effectiveSource = !bypassFilters && cell.modelMaterializer ? extractRunTargetSourceName(cell.text) : undefined;
252427
+ const cellFilters = effectiveSource ? this.getFilters(effectiveSource) : [];
252428
+ if (cell.modelMaterializer && cellFilters.length > 0) {
252429
+ await this.probeEntryPointGates(cell.runnable, givens ?? {}, graftScope);
252430
+ }
252431
+ const filterClause = cellFilters.length > 0 ? buildFilterClause(cellFilters, filterParams ?? {}) : undefined;
252432
+ if (filterClause && cell.modelMaterializer) {
252433
+ textToExecute = injectFilterRefinement(cell.text, filterClause);
252434
+ runnableToExecute = cell.modelMaterializer.loadQuery(textToExecute);
252435
+ }
252436
+ if (cell.modelMaterializer) {
252437
+ const textForRecompile = textToExecute;
252438
+ const queryDefForOwnScopeRepoint = usesOwnScope ? (await runnableToExecute.getPreparedQuery())._query : undefined;
252439
+ runnableToExecute = await this.authorizeAndBindRunnable(runnableToExecute, givens ?? {}, {
252440
+ recompile: usesOwnScope ? (mm) => mm._loadQueryFromQueryDef(queryDefForOwnScopeRepoint) : (mm) => mm.loadQuery(textForRecompile),
252441
+ graftScope
252442
+ });
252443
+ } else {
252444
+ await this.assertAuthorizedForAllSources(runnableToExecute, givens ?? {});
250717
252445
  }
250718
252446
  const cellMaxRows = getMaxQueryRows();
250719
252447
  const cellMaxBytes = getMaxResponseBytes();
@@ -250737,6 +252465,9 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250737
252465
  });
250738
252466
  const query = (await runnableToExecute.getPreparedQuery())._query;
250739
252467
  queryName = query.as || query.name;
252468
+ if (result?._queryResult && result.totalRows === 0 && this.queryHadRowLevelFilterAttached(runnableToExecute)) {
252469
+ recordRowLevelGateDecision("empty_after_filter");
252470
+ }
250740
252471
  if (result?._queryResult) {
250741
252472
  assertWithinModelRowLimit(result.totalRows, cellMaxRows, "notebook_cell");
250742
252473
  }
@@ -250745,6 +252476,9 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250745
252476
  assertWithinModelByteLimit(queryResult, cellMaxBytes, "notebook_cell");
250746
252477
  }
250747
252478
  } catch (error) {
252479
+ if (error instanceof AccessDeniedError) {
252480
+ throw error;
252481
+ }
250748
252482
  if (error instanceof FilterValidationError) {
250749
252483
  throw new BadRequestError(error.message);
250750
252484
  }
@@ -250824,14 +252558,23 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250824
252558
  return malloyConfig;
250825
252559
  }
250826
252560
  static getQueries(modelDef) {
250827
- return extractQueriesFromModelDef(modelDef);
252561
+ const { queries, misplacedAuthorize } = extractQueriesFromModelDef(modelDef);
252562
+ return { queries, misplacedAuthorize };
250828
252563
  }
250829
252564
  static getSources(modelDef, givens) {
250830
- const { sources, filterMap, ownAuthorizeSources } = extractSourcesFromModelDef(modelDef, givens, (sourceName, err) => logger.warn(`Failed to parse filter annotations on source "${sourceName}"`, { error: err }));
252565
+ const {
252566
+ sources,
252567
+ filterMap,
252568
+ authorizeMap,
252569
+ misplacedAuthorize,
252570
+ authorizeOwnNotes
252571
+ } = extractSourcesFromModelDef(modelDef, givens, (sourceName, err) => logger.warn(`Failed to parse filter annotations on source "${sourceName}"`, { error: err }));
250831
252572
  return {
250832
252573
  sources,
250833
252574
  filterMap,
250834
- ownAuthorizeSources
252575
+ authorizeMap,
252576
+ misplacedAuthorize,
252577
+ authorizeOwnNotes
250835
252578
  };
250836
252579
  }
250837
252580
  static async getModelMaterializer(runtime, importBaseURL, modelURL, modelPath) {
@@ -250932,6 +252675,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
250932
252675
  text: stmt.text,
250933
252676
  runnable,
250934
252677
  modelMaterializer: localMM,
252678
+ modelDef: currentModelDef,
250935
252679
  newSources,
250936
252680
  queryInfo
250937
252681
  };
@@ -253058,7 +254802,7 @@ var require_luxon = __commonJS((exports) => {
253058
254802
  return defaultZone2;
253059
254803
  } else if (input instanceof Zone) {
253060
254804
  return input;
253061
- } else if (isString2(input)) {
254805
+ } else if (isString3(input)) {
253062
254806
  const lowered = input.toLowerCase();
253063
254807
  if (lowered === "default")
253064
254808
  return defaultZone2;
@@ -253433,7 +255177,7 @@ var require_luxon = __commonJS((exports) => {
253433
255177
  function isInteger(o) {
253434
255178
  return typeof o === "number" && o % 1 === 0;
253435
255179
  }
253436
- function isString2(o) {
255180
+ function isString3(o) {
253437
255181
  return typeof o === "string";
253438
255182
  }
253439
255183
  function isDate2(o) {
@@ -273330,6 +275074,30 @@ class ConnectionController {
273330
275074
  }
273331
275075
  }
273332
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
+
273333
275101
  // src/controller/database.controller.ts
273334
275102
  class DatabaseController {
273335
275103
  environmentStore;
@@ -273546,6 +275314,8 @@ init_errors();
273546
275314
  init_logger();
273547
275315
  init_query_metadata();
273548
275316
  var import_render_validator = __toESM(require_dist11(), 1);
275317
+ init_dashboard();
275318
+
273549
275319
  class QueryController {
273550
275320
  environmentStore;
273551
275321
  constructor(environmentStore) {
@@ -273596,7 +275366,7 @@ class QueryController {
273596
275366
  }
273597
275367
  }
273598
275368
  }, compactJson ? "compact" : "full", bypassAuthorize), getQueryTimeoutMs());
273599
- const renderLogs = import_render_validator.validateRenderTags(result);
275369
+ const renderLogs = filterPublisherOwnedRenderLogs(import_render_validator.validateRenderTags(result));
273600
275370
  return {
273601
275371
  result: serializedResult,
273602
275372
  resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}/models/${modelPath}/query`,
@@ -282257,6 +284027,7 @@ class CronEvaluator {
282257
284027
  }
282258
284028
 
282259
284029
  // src/service/package.ts
284030
+ init_dashboard();
282260
284031
  init_quoting();
282261
284032
  init_model();
282262
284033
 
@@ -282324,6 +284095,8 @@ class Package {
282324
284095
  sourceEligibility = undefined;
282325
284096
  incrementalPolicySources = [];
282326
284097
  renderTagWarnings = [];
284098
+ dashboardWarnings = [];
284099
+ dashboards = new Map;
282327
284100
  manifestWarnings = [];
282328
284101
  static meter = publisherMeter();
282329
284102
  static packageLoadHistogram = this.meter.createHistogram("malloy_package_load_duration", {
@@ -282530,6 +284303,7 @@ class Package {
282530
284303
  });
282531
284304
  const pkg = new Package(environmentName, packageName, packagePath, packageConfig, databases, models, malloyConfig);
282532
284305
  pkg.renderTagWarnings = renderTagWarnings;
284306
+ await pkg.discoverDashboards();
282533
284307
  pkg.manifestWarnings = outcome.packageMetadata.manifestWarnings ?? [];
282534
284308
  pkg.wireFreshnessResolvers();
282535
284309
  try {
@@ -282668,6 +284442,7 @@ class Package {
282668
284442
  }
282669
284443
  const allWarnings = [
282670
284444
  ...this.renderTagWarnings,
284445
+ ...this.dashboardWarnings,
282671
284446
  ...this.storageWarnings(),
282672
284447
  ...this.droppedPersistWarnings(),
282673
284448
  ...this.emptyDiscoveryWarnings(),
@@ -283079,6 +284854,7 @@ class Package {
283079
284854
  this.packageMetadata.manifestLocation = outcome.packageMetadata.manifestLocation ?? null;
283080
284855
  this.applyDiscoveryPolicyToModels();
283081
284856
  this.applyQueryBoundaryToModels();
284857
+ await this.discoverDashboards();
283082
284858
  this.recordManifestBinding(entries);
283083
284859
  this.wireFreshnessResolvers();
283084
284860
  const invalidMsg = this.formatInvalidExplores();
@@ -283119,15 +284895,271 @@ class Package {
283119
284895
  }));
283120
284896
  return values;
283121
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
+ }
283122
285150
  async listNotebooks() {
283123
285151
  return await Promise.all(Array.from(this.models.keys()).filter((modelPath) => {
283124
285152
  return modelPath.endsWith(NOTEBOOK_FILE_SUFFIX);
283125
285153
  }).map(async (modelPath) => {
283126
- const error = this.models.get(modelPath)?.getNotebookError();
285154
+ const model = this.models.get(modelPath);
285155
+ const error = model?.getNotebookError();
285156
+ const listing = model?.getNotebookListing() ?? {};
283127
285157
  return {
283128
285158
  environmentName: this.environmentName,
283129
285159
  packageName: this.packageName,
283130
285160
  path: modelPath,
285161
+ title: listing.title,
285162
+ description: listing.description,
283131
285163
  error: error?.message
283132
285164
  };
283133
285165
  }));
@@ -289151,6 +291183,7 @@ function registerDocsSearchTool(mcpServer, _environmentStore) {
289151
291183
  // src/mcp/tools/execute_query_tool.ts
289152
291184
  init_config();
289153
291185
  init_logger();
291186
+ init_dashboard();
289154
291187
 
289155
291188
  // src/mcp/query_envelope.ts
289156
291189
  init_json_utils();
@@ -289329,7 +291362,7 @@ function registerExecuteQueryTool(mcpServer, environmentStore) {
289329
291362
  queryCorrelationId
289330
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());
289331
291364
  const { validateRenderTags: validateRenderTags2 } = await Promise.resolve().then(() => __toESM(require_dist11(), 1));
289332
- const renderLogs = validateRenderTags2(result);
291365
+ const renderLogs = filterPublisherOwnedRenderLogs(validateRenderTags2(result));
289333
291366
  const resultUri = buildMalloyUri({
289334
291367
  environment: environmentName,
289335
291368
  package: packageName,
@@ -290441,6 +292474,7 @@ Every skill in this deployment, by what it is for. Start at a driver; it routes
290441
292474
  | Skill | Use when... |
290442
292475
  |-------|-------------|
290443
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 |
290444
292478
  | \`skill:malloy-html-data-apps\` | Building an in-package HTML data app (a \`public/\` directory the package serves) |
290445
292479
  | \`skill:malloy-html-data-app-runtime\` | Writing the JavaScript that drives that app |
290446
292480
  | \`skill:malloy-html-data-app-embedding\` | Embedding a served page into a host application |
@@ -290792,7 +292826,408 @@ Use \`+\` to modify existing views: \`run: source -> my_view + { limit: 15, wher
290792
292826
 
290793
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\`).
290794
292828
 
290795
- 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
290796
293231
 
290797
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.
290798
293233
 
@@ -291213,7 +293648,7 @@ view: overview is {
291213
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.
291214
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.
291215
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.
291216
- - **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
291217
293652
 
291218
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.
291219
293654
 
@@ -291251,7 +293686,7 @@ The agent orchestrates these. Each query and chart step hands off to a focused s
291251
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.
291252
293687
  2. SCAFFOLD the package (template below).
291253
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\`.
291254
- 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.
291255
293690
  5. EMBED (optional) with \`skill:malloy-html-data-app-embedding\`.
291256
293691
  6. PREVIEW with the local authoring loop (below).
291257
293692
  7. VERIFY before you call it done (see "What 'done' means" below). This step is not optional.
@@ -291288,7 +293723,7 @@ You are building for someone who cannot tell a correct dashboard from a broken o
291288
293723
  { "name": "my-package", "version": "0.0.1", "description": "..." }
291289
293724
  \`\`\`
291290
293725
 
291291
- \`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.
291292
293727
 
291293
293728
  Start with the smallest page that proves the wiring, dumping the rows:
291294
293729
 
@@ -295572,6 +298007,8 @@ class MaterializationService {
295572
298007
  const destination = resolveStorageDestination(persistSource);
295573
298008
  if (destination) {
295574
298009
  assertMaterializationEligible(persistSource);
298010
+ } else {
298011
+ assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, compiled.preaggregatePlans?.[persistSource.sourceID] ? "preaggregate" : "persist");
295575
298012
  }
295576
298013
  const sourceEntityId = computeSourceEntityId(persistSource, compiled.connectionDigests);
295577
298014
  if (seen.has(sourceEntityId))
@@ -295834,6 +298271,8 @@ class MaterializationService {
295834
298271
  const orchestratedInstruction = bySourceID.get(persistSource.sourceID);
295835
298272
  if (orchestratedInstruction?.destination && getPersistStorageMode() !== "off") {
295836
298273
  assertMaterializationEligible(persistSource);
298274
+ } else {
298275
+ assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, compiled.preaggregatePlans?.[persistSource.sourceID] ? "preaggregate" : "persist");
295837
298276
  }
295838
298277
  const sourceEntityId = computeSourceEntityId(persistSource, connectionDigests);
295839
298278
  const instruction = orchestratedInstruction ?? bySourceEntityId.get(sourceEntityId);
@@ -296744,7 +299183,12 @@ var ASSET_EXTENSIONS = new Set([
296744
299183
  "woff2",
296745
299184
  "xlsx"
296746
299185
  ]);
296747
- 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
+ ]);
296748
299192
  function extensionOf(segment) {
296749
299193
  const dot = segment.lastIndexOf(".");
296750
299194
  if (dot <= 0 || dot === segment.length - 1)
@@ -296893,6 +299337,7 @@ var memoryGovernor = memoryGovernorConfig ? new PackageMemoryGovernor(memoryGove
296893
299337
  memoryGovernor?.start();
296894
299338
  environmentStore.setMemoryGovernor(memoryGovernor);
296895
299339
  var packageController = new PackageController(environmentStore);
299340
+ var dashboardController = new DashboardController(environmentStore);
296896
299341
  var databaseController = new DatabaseController(environmentStore);
296897
299342
  var queryController = new QueryController(environmentStore);
296898
299343
  var compileController = new CompileController(environmentStore);
@@ -297608,6 +300053,32 @@ app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/mode
297608
300053
  res.status(status).json(json);
297609
300054
  }
297610
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
+ });
297611
300082
  app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/notebooks`, async (req, res) => {
297612
300083
  if (req.query.versionId) {
297613
300084
  setVersionIdError2(res);