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