@malloy-publisher/server 0.0.247 → 0.0.249
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/api-doc.yaml +411 -29
- 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 +11914 -9572
- package/dist/server.mjs +2773 -302
- 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();
|
|
@@ -246982,11 +247823,67 @@ var init_package_load_pool = __esm(() => {
|
|
|
246982
247823
|
});
|
|
246983
247824
|
|
|
246984
247825
|
// src/service/authorize.ts
|
|
247826
|
+
import { payloadOf, routeOf } from "@malloydata/malloy";
|
|
247827
|
+
function noteRoute(text) {
|
|
247828
|
+
return routeOf({ value: text.trimStart() });
|
|
247829
|
+
}
|
|
247830
|
+
function notePayload(text) {
|
|
247831
|
+
return payloadOf({ value: text.trimStart() }) ?? "";
|
|
247832
|
+
}
|
|
247833
|
+
function authorizeNoteContent(text) {
|
|
247834
|
+
return noteRoute(text) === AUTHORIZE_ROUTE ? notePayload(text) : undefined;
|
|
247835
|
+
}
|
|
246985
247836
|
function assertNoCallerAuthorizeAnnotation(callerText) {
|
|
246986
247837
|
if (!AUTHORIZE_ANNOTATION_ANYWHERE.test(callerText))
|
|
246987
247838
|
return;
|
|
246988
247839
|
throw new BadRequestError("An `authorize` annotation is not permitted in caller-submitted Malloy " + "text. Access gates are declared by the model author on the source; a " + "request cannot introduce, replace, or relax one. To validate a gate " + "you are authoring, save it to the package's model file and reload the " + "package — model load validates every `#(authorize)` annotation it " + "declares.");
|
|
246989
247840
|
}
|
|
247841
|
+
function containsAuthorizeAnnotationTag(texts) {
|
|
247842
|
+
return texts.some((text) => authorizeNoteContent(text) !== undefined);
|
|
247843
|
+
}
|
|
247844
|
+
function collectAuthorizeNearMisses(texts) {
|
|
247845
|
+
const found = [];
|
|
247846
|
+
for (const text of texts) {
|
|
247847
|
+
const trimmed2 = text.trimStart();
|
|
247848
|
+
const route = noteRoute(trimmed2);
|
|
247849
|
+
if (route === AUTHORIZE_ROUTE)
|
|
247850
|
+
continue;
|
|
247851
|
+
const nearMiss = route === undefined ? MALFORMED_AUTHORIZE_ATTEMPT.test(trimmed2) : route === "" ? MOTLY_AUTHORIZE_PAYLOAD.test(notePayload(trimmed2)) : route.toLowerCase() === AUTHORIZE_ROUTE;
|
|
247852
|
+
if (!nearMiss)
|
|
247853
|
+
continue;
|
|
247854
|
+
found.push(trimmed2.split(/[\r\n]/, 1)[0]);
|
|
247855
|
+
}
|
|
247856
|
+
return found;
|
|
247857
|
+
}
|
|
247858
|
+
function assertNoAuthorizeNearMisses(found) {
|
|
247859
|
+
if (found.length === 0)
|
|
247860
|
+
return;
|
|
247861
|
+
const unique = [...new Set(found)];
|
|
247862
|
+
throw new ModelCompilationError({
|
|
247863
|
+
message: `These annotations are not \`authorize\` gates and nothing enforces ` + `them:
|
|
247864
|
+
${unique.map((t) => ` - \`${t}\``).join(`
|
|
247865
|
+
`)}
|
|
247866
|
+
` + `Malloy routes an annotation by its prefix, and only ` + `\`#(authorize)\` (or \`##(authorize)\`, or the block form ` + `\`#|(authorize)\`) reaches the authorize route — a space after the ` + `\`#\`, spaces inside the brackets, or anything trailing the closing ` + `bracket makes it a plain tag Malloy hands to something else. Write ` + `\`#(authorize) "<expression>"\` on the \`source:\` statement you mean ` + `to protect. This is refused rather than interpreted: guessing at the ` + `intent would let publisher start enforcing a filter on a package that ` + `has been serving every row.`
|
|
247867
|
+
});
|
|
247868
|
+
}
|
|
247869
|
+
function describeMisplacedAuthorizeAnnotation(f) {
|
|
247870
|
+
if (f.kind === "query")
|
|
247871
|
+
return `on query "${f.name}"`;
|
|
247872
|
+
if (f.kind === "file")
|
|
247873
|
+
return "at the file level (`##(authorize)`)";
|
|
247874
|
+
return `on field "${f.fieldName}" of source "${f.name}"`;
|
|
247875
|
+
}
|
|
247876
|
+
function assertNoMisplacedAuthorizeAnnotations(found) {
|
|
247877
|
+
if (found.length === 0)
|
|
247878
|
+
return;
|
|
247879
|
+
const positions = found.map((f) => ` - ${describeMisplacedAuthorizeAnnotation(f)}`).join(`
|
|
247880
|
+
`);
|
|
247881
|
+
throw new ModelCompilationError({
|
|
247882
|
+
message: `An \`#(authorize)\` annotation is never enforced at:
|
|
247883
|
+
${positions}
|
|
247884
|
+
` + `A gate only applies where model load looks for one — a \`source:\`'s ` + `own annotation, or one it inherits from an \`extend\`/query-source ` + `base. File-level \`##(authorize)\` is deprecated and no longer ` + `enforced anywhere, so it always lands here: declare \`#(authorize)\` ` + `on each \`source:\` it was meant to protect instead. Every other ` + `position above should move to the \`source:\` statement it is meant ` + `to protect.`
|
|
247885
|
+
});
|
|
247886
|
+
}
|
|
246990
247887
|
function buildAuthorizeProbe(exprs, givenDecls = []) {
|
|
246991
247888
|
const selects = exprs.map((expr, i) => `__auth_${i} is (${expr})`).join(`
|
|
246992
247889
|
`);
|
|
@@ -247014,6 +247911,158 @@ function referencedGivenNames(expr) {
|
|
|
247014
247911
|
}
|
|
247015
247912
|
return names;
|
|
247016
247913
|
}
|
|
247914
|
+
function classifyAuthorizeGate(condition, declaredTypes, declaredDefaults) {
|
|
247915
|
+
const fieldUsage = condition.refSummary?.fieldUsage;
|
|
247916
|
+
if (!Array.isArray(fieldUsage) || fieldUsage.length === 0) {
|
|
247917
|
+
return { shape: "given_only" };
|
|
247918
|
+
}
|
|
247919
|
+
const givenNames = [];
|
|
247920
|
+
const literalAtoms = [];
|
|
247921
|
+
let rejection;
|
|
247922
|
+
const reject = (cause, detail) => {
|
|
247923
|
+
rejection ??= { shape: "rejected", cause, detail };
|
|
247924
|
+
return false;
|
|
247925
|
+
};
|
|
247926
|
+
const givenOperand = (node) => {
|
|
247927
|
+
const n = asNode(node);
|
|
247928
|
+
return n && n.node === "given" && typeof n.refName === "string" ? n.refName : null;
|
|
247929
|
+
};
|
|
247930
|
+
const isLiteralOperand = (node) => {
|
|
247931
|
+
const n = asNode(node);
|
|
247932
|
+
return !!n && (n.node === "numberLiteral" || n.node === "stringLiteral" || n.node === "true" || n.node === "false");
|
|
247933
|
+
};
|
|
247934
|
+
const literalOperandText = (node) => {
|
|
247935
|
+
const n = asNode(node);
|
|
247936
|
+
if (!n)
|
|
247937
|
+
return null;
|
|
247938
|
+
if (n.node === "true" || n.node === "false")
|
|
247939
|
+
return n.node;
|
|
247940
|
+
if (n.node === "numberLiteral" && typeof n.literal === "string") {
|
|
247941
|
+
return n.literal;
|
|
247942
|
+
}
|
|
247943
|
+
if (n.node === "stringLiteral" && typeof n.literal === "string") {
|
|
247944
|
+
return `'${n.literal.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
247945
|
+
}
|
|
247946
|
+
return null;
|
|
247947
|
+
};
|
|
247948
|
+
const declaredTypeOf = (name) => {
|
|
247949
|
+
const declared = declaredTypes.get(name);
|
|
247950
|
+
if (declared === undefined) {
|
|
247951
|
+
reject("unreachable_given", `\`$${name}\` is not on this model's given surface, so the gate ` + `would bind its declaration default rather than the caller's ` + `value. Declare it here, importing it if it lives elsewhere ` + `(\`import { ${name} } from "…"\`)`);
|
|
247952
|
+
return null;
|
|
247953
|
+
}
|
|
247954
|
+
return declared;
|
|
247955
|
+
};
|
|
247956
|
+
const walk = (node, depth) => {
|
|
247957
|
+
if (depth > MAX_GATE_WALK_DEPTH) {
|
|
247958
|
+
return reject("unsupported_node", "the gate nests too deeply to read");
|
|
247959
|
+
}
|
|
247960
|
+
const n = asNode(node);
|
|
247961
|
+
if (!n || typeof n.node !== "string") {
|
|
247962
|
+
return reject("unsupported_node", "the gate has an unreadable shape");
|
|
247963
|
+
}
|
|
247964
|
+
const kind = n.node;
|
|
247965
|
+
if (BOOLEAN_NODES.has(kind)) {
|
|
247966
|
+
const kids = asNode(n.kids);
|
|
247967
|
+
if (!kids) {
|
|
247968
|
+
return reject("unsupported_node", `\`${kind}\` has no operands`);
|
|
247969
|
+
}
|
|
247970
|
+
return walk(kids.left, depth + 1) && walk(kids.right, depth + 1);
|
|
247971
|
+
}
|
|
247972
|
+
if (TRANSPARENT_NODES.has(kind)) {
|
|
247973
|
+
return walk(n.e, depth + 1);
|
|
247974
|
+
}
|
|
247975
|
+
if (kind === "inGiven") {
|
|
247976
|
+
if (n.not === true) {
|
|
247977
|
+
return reject("unsupported_node", "a negated membership test (`not in`) is not an access rule; " + "write the gate as the set of rows a caller MAY read");
|
|
247978
|
+
}
|
|
247979
|
+
const given = givenOperand(n.givenRef);
|
|
247980
|
+
if (given === null) {
|
|
247981
|
+
return reject("unsupported_node", "`in` must test membership of a declared given");
|
|
247982
|
+
}
|
|
247983
|
+
const declared = declaredTypeOf(given);
|
|
247984
|
+
if (declared === null)
|
|
247985
|
+
return false;
|
|
247986
|
+
if (!isArrayType(declared)) {
|
|
247987
|
+
return reject("scalar_given_rejects_in", `\`$${given}\` is declared \`${declared}\` (a scalar), so ` + `\`in $${given}\` is not a membership test. Compare it with ` + `\`=\` instead`);
|
|
247988
|
+
}
|
|
247989
|
+
givenNames.push(given);
|
|
247990
|
+
return walkFieldOperand(n.e);
|
|
247991
|
+
}
|
|
247992
|
+
if (SCALAR_COMPARISON_NODES.has(kind)) {
|
|
247993
|
+
const kids = asNode(n.kids);
|
|
247994
|
+
if (!kids) {
|
|
247995
|
+
return reject("unsupported_node", `\`${kind}\` has no operands`);
|
|
247996
|
+
}
|
|
247997
|
+
const left = givenOperand(kids.left);
|
|
247998
|
+
const right = givenOperand(kids.right);
|
|
247999
|
+
const given = left ?? right;
|
|
248000
|
+
if (given === null) {
|
|
248001
|
+
return reject("no_given_reference", `\`${kind}\` must compare a field against a given; a comparison ` + `against a constant is a fixed filter and belongs in the ` + `source's own \`where:\``);
|
|
248002
|
+
}
|
|
248003
|
+
const declared = declaredTypeOf(given);
|
|
248004
|
+
if (declared === null)
|
|
248005
|
+
return false;
|
|
248006
|
+
if (isArrayType(declared)) {
|
|
248007
|
+
return reject("array_given_needs_in", `\`$${given}\` is declared \`${declared}\` (an array), so ` + `comparing it with \`${kind}\` compiles and then fails in the ` + `warehouse. Write \`in $${given}\` — it is also the spelling ` + `that matches no rows when the array is empty`);
|
|
248008
|
+
}
|
|
248009
|
+
const otherSide = left === null ? kids.left : kids.right;
|
|
248010
|
+
if (isLiteralOperand(otherSide)) {
|
|
248011
|
+
givenNames.push(given);
|
|
248012
|
+
const literalText = literalOperandText(otherSide);
|
|
248013
|
+
if (literalText !== null) {
|
|
248014
|
+
literalAtoms.push(left !== null ? `$${given} ${kind} ${literalText}` : `${literalText} ${kind} $${given}`);
|
|
248015
|
+
}
|
|
248016
|
+
return true;
|
|
248017
|
+
}
|
|
248018
|
+
if (declaredDefaults.has(given)) {
|
|
248019
|
+
return reject("field_given_has_default", `\`${kind}\` compares row field data against \`$${given}\`, ` + `which is declared with a default (\`${declaredDefaults.get(given)}\`). ` + `A caller who supplies no value for \`$${given}\` gets that ` + `default, and the comparison then applies to every row — ` + `admitting rows it was meant to exclude. Declare \`$${given}\` ` + `with no default so a caller must supply one explicitly`);
|
|
248020
|
+
}
|
|
248021
|
+
givenNames.push(given);
|
|
248022
|
+
return walkFieldOperand(otherSide);
|
|
248023
|
+
}
|
|
248024
|
+
return reject("unsupported_node", `\`${kind}\` is not permitted in a gate; a row-level gate is a ` + `boolean combination of \`<field> <operator> $GIVEN\` comparisons`);
|
|
248025
|
+
};
|
|
248026
|
+
const walkFieldOperand = (node) => {
|
|
248027
|
+
const n = asNode(node);
|
|
248028
|
+
if (!n || n.node !== "field" || !Array.isArray(n.path)) {
|
|
248029
|
+
return reject("unsupported_node", `a gate compares a FIELD against a given; \`${asNode(node)?.node ?? "that operand"}\` is not a field reference`);
|
|
248030
|
+
}
|
|
248031
|
+
return true;
|
|
248032
|
+
};
|
|
248033
|
+
let ok;
|
|
248034
|
+
try {
|
|
248035
|
+
ok = walk(condition.e, 0);
|
|
248036
|
+
} catch {
|
|
248037
|
+
return {
|
|
248038
|
+
shape: "rejected",
|
|
248039
|
+
cause: "unsupported_node",
|
|
248040
|
+
detail: "the gate's compiled shape could not be read"
|
|
248041
|
+
};
|
|
248042
|
+
}
|
|
248043
|
+
if (!ok) {
|
|
248044
|
+
return rejection ?? {
|
|
248045
|
+
shape: "rejected",
|
|
248046
|
+
cause: "unsupported_node",
|
|
248047
|
+
detail: "the gate is not an allowed shape"
|
|
248048
|
+
};
|
|
248049
|
+
}
|
|
248050
|
+
if (givenNames.length === 0) {
|
|
248051
|
+
return {
|
|
248052
|
+
shape: "rejected",
|
|
248053
|
+
cause: "no_given_reference",
|
|
248054
|
+
detail: "a row-level gate must compare a field against a given; a gate " + "that references none is a fixed filter and belongs in the " + "source's own `where:`"
|
|
248055
|
+
};
|
|
248056
|
+
}
|
|
248057
|
+
return { shape: "row_level", givenNames, literalAtoms };
|
|
248058
|
+
}
|
|
248059
|
+
function isArrayType(declaredType) {
|
|
248060
|
+
const normalized = declaredType.trim().toLowerCase();
|
|
248061
|
+
return normalized === "array" || normalized.endsWith("[]");
|
|
248062
|
+
}
|
|
248063
|
+
function asNode(node) {
|
|
248064
|
+
return node !== null && typeof node === "object" ? node : null;
|
|
248065
|
+
}
|
|
247017
248066
|
function inferGivenType(value) {
|
|
247018
248067
|
if (typeof value === "string")
|
|
247019
248068
|
return "string";
|
|
@@ -247056,9 +248105,8 @@ async function runProbe(executor, probeText, givens) {
|
|
|
247056
248105
|
}
|
|
247057
248106
|
async function evaluateAuthorize(executor, exprs, givens, declaredTypes, options) {
|
|
247058
248107
|
const selfContainedFirst = options?.selfContainedFirst ?? false;
|
|
247059
|
-
const
|
|
247060
|
-
|
|
247061
|
-
if (selfContainedFirst && index >= ambientPrefix) {
|
|
248108
|
+
for (const expr of exprs) {
|
|
248109
|
+
if (selfContainedFirst) {
|
|
247062
248110
|
if (await evaluateSelfContainedFirst(executor, expr, givens, declaredTypes)) {
|
|
247063
248111
|
return true;
|
|
247064
248112
|
}
|
|
@@ -247106,27 +248154,123 @@ async function evaluateSelfContainedFirst(executor, expr, givens, declaredTypes)
|
|
|
247106
248154
|
}
|
|
247107
248155
|
}
|
|
247108
248156
|
}
|
|
247109
|
-
|
|
247110
|
-
|
|
247111
|
-
|
|
247112
|
-
|
|
247113
|
-
|
|
248157
|
+
function quoteMalloyIdentifier(name) {
|
|
248158
|
+
return "`" + name.replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
|
|
248159
|
+
}
|
|
248160
|
+
function buildRowLevelProbe(graftTarget, filterText) {
|
|
248161
|
+
return `run: ${quoteMalloyIdentifier(graftTarget)} extend { where: ${filterText} } -> { select: __authorize_probe is 1; limit: 1 }`;
|
|
248162
|
+
}
|
|
248163
|
+
function liftProbeFilterCondition(prepared, label, filterText) {
|
|
248164
|
+
const filterList = prepared._query?.structRef?.filterList;
|
|
248165
|
+
if (!Array.isArray(filterList) || filterList.length === 0) {
|
|
248166
|
+
throw new Error(`${label} carries no filter condition`);
|
|
248167
|
+
}
|
|
248168
|
+
const lifted = filterList[filterList.length - 1];
|
|
248169
|
+
if (lifted.code !== filterText) {
|
|
248170
|
+
throw new Error(`${label} carries the wrong condition — expected "${filterText}", got "${lifted.code ?? ""}"`);
|
|
248171
|
+
}
|
|
248172
|
+
if (!lifted.isSourceFilter) {
|
|
248173
|
+
throw new Error(`${label} carries a condition that is not a source filter`);
|
|
248174
|
+
}
|
|
248175
|
+
return lifted;
|
|
248176
|
+
}
|
|
248177
|
+
function gateFilterText(exprs) {
|
|
248178
|
+
return exprs.map((e) => `(${e})`).join(" or ");
|
|
248179
|
+
}
|
|
248180
|
+
async function liftRowLevelCondition(compiler, sourceName, exprs) {
|
|
248181
|
+
const filterText = gateFilterText(exprs);
|
|
248182
|
+
const prepared = await compiler.loadQuery(buildRowLevelProbe(sourceName, filterText)).getPreparedQuery();
|
|
248183
|
+
return liftProbeFilterCondition(prepared, `row-level probe for "${sourceName}"`, filterText);
|
|
248184
|
+
}
|
|
248185
|
+
async function runOneRowProbeOrThrow(compiler, sourceName, exprs) {
|
|
248186
|
+
try {
|
|
248187
|
+
await compiler.loadQuery(buildAuthorizeProbe(exprs)).getPreparedQuery();
|
|
248188
|
+
} catch (err) {
|
|
248189
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
248190
|
+
throw new ModelCompilationError({
|
|
248191
|
+
message: `Invalid #(authorize) annotation on source "${sourceName}" [${exprs.join(" | ")}]: ${detail}`
|
|
248192
|
+
});
|
|
248193
|
+
}
|
|
248194
|
+
}
|
|
248195
|
+
async function assertNoVacuousDefaultAtom(executor, sourceName, literalAtoms) {
|
|
248196
|
+
for (const atom of literalAtoms) {
|
|
248197
|
+
let vacuous;
|
|
247114
248198
|
try {
|
|
247115
|
-
await
|
|
248199
|
+
vacuous = await runProbe(executor, buildAuthorizeProbe([atom]), {});
|
|
247116
248200
|
} catch (err) {
|
|
247117
248201
|
const detail = err instanceof Error ? err.message : String(err);
|
|
248202
|
+
if (NO_DEFAULT_GIVEN_PATTERN.test(detail))
|
|
248203
|
+
continue;
|
|
247118
248204
|
throw new ModelCompilationError({
|
|
247119
|
-
message: `Invalid #(authorize) annotation on source "${
|
|
248205
|
+
message: `Invalid #(authorize) annotation on source "${sourceName}": ` + `the atom \`${atom}\` could not be evaluated against its ` + `given's declared default (${detail}).`
|
|
248206
|
+
});
|
|
248207
|
+
}
|
|
248208
|
+
if (vacuous) {
|
|
248209
|
+
throw new ModelCompilationError({
|
|
248210
|
+
message: `Invalid #(authorize) annotation on source "${sourceName}": ` + `the atom \`${atom}\` evaluates to TRUE when a caller supplies ` + `no givens (its given's own declared default). An OR'd atom ` + `that is true by default makes the whole row filter admit ` + `every row for that caller. Give the given a default this ` + `atom evaluates false against, or declare it with no default ` + `so a caller must supply one explicitly.`
|
|
247120
248211
|
});
|
|
247121
248212
|
}
|
|
247122
248213
|
}
|
|
247123
248214
|
}
|
|
248215
|
+
async function validateAuthorizeProbes(compiler, options) {
|
|
248216
|
+
const declaredTypes = options.declaredTypes ?? new Map;
|
|
248217
|
+
const declaredDefaults = options.declaredDefaults ?? new Map;
|
|
248218
|
+
const provenNoteObjects = new Set;
|
|
248219
|
+
const ownNotesOf = options.authorizeOwnNotes ?? new Map;
|
|
248220
|
+
const pending = [];
|
|
248221
|
+
for (const [sourceName, groups] of options.authorizeMap ?? []) {
|
|
248222
|
+
for (const exprs of groups) {
|
|
248223
|
+
if (exprs.length === 0)
|
|
248224
|
+
continue;
|
|
248225
|
+
let condition;
|
|
248226
|
+
try {
|
|
248227
|
+
condition = await liftRowLevelCondition(compiler, sourceName, exprs);
|
|
248228
|
+
} catch (err) {
|
|
248229
|
+
pending.push({ sourceName, exprs, err });
|
|
248230
|
+
continue;
|
|
248231
|
+
}
|
|
248232
|
+
const classification = classifyAuthorizeGate(condition, declaredTypes, declaredDefaults);
|
|
248233
|
+
if (classification.shape === "given_only") {
|
|
248234
|
+
await runOneRowProbeOrThrow(compiler, sourceName, exprs);
|
|
248235
|
+
for (const note of ownNotesOf.get(sourceName) ?? []) {
|
|
248236
|
+
provenNoteObjects.add(note);
|
|
248237
|
+
}
|
|
248238
|
+
continue;
|
|
248239
|
+
}
|
|
248240
|
+
if (classification.shape === "rejected") {
|
|
248241
|
+
options.onRowLevelGateRejected?.(classification.cause);
|
|
248242
|
+
const ownNotes = ownNotesOf.get(sourceName) ?? [];
|
|
248243
|
+
if (ownNotes.length === 0) {
|
|
248244
|
+
options.onRowLevelGateUnexpressible?.(sourceName, classification.detail);
|
|
248245
|
+
continue;
|
|
248246
|
+
}
|
|
248247
|
+
throw new ModelCompilationError({
|
|
248248
|
+
message: `Invalid #(authorize) annotation on source "${sourceName}" ` + `[${exprs.join(" | ")}]: ${classification.detail}`
|
|
248249
|
+
});
|
|
248250
|
+
}
|
|
248251
|
+
await assertNoVacuousDefaultAtom(compiler, sourceName, classification.literalAtoms);
|
|
248252
|
+
for (const note of ownNotesOf.get(sourceName) ?? []) {
|
|
248253
|
+
provenNoteObjects.add(note);
|
|
248254
|
+
}
|
|
248255
|
+
}
|
|
248256
|
+
}
|
|
248257
|
+
for (const failure of pending) {
|
|
248258
|
+
const ownNotes = ownNotesOf.get(failure.sourceName) ?? [];
|
|
248259
|
+
const inherited = ownNotes.length === 0 || ownNotes.every((note) => provenNoteObjects.has(note));
|
|
248260
|
+
if (inherited) {
|
|
248261
|
+
const detail = failure.err instanceof Error ? failure.err.message : String(failure.err);
|
|
248262
|
+
options.onRowLevelGateRejected?.("entry_point_unexpressible");
|
|
248263
|
+
options.onRowLevelGateUnexpressible?.(failure.sourceName, detail);
|
|
248264
|
+
continue;
|
|
248265
|
+
}
|
|
248266
|
+
await runOneRowProbeOrThrow(compiler, failure.sourceName, failure.exprs);
|
|
248267
|
+
}
|
|
248268
|
+
}
|
|
247124
248269
|
function parseAuthorizeAnnotation(annotation) {
|
|
247125
|
-
const
|
|
247126
|
-
|
|
247127
|
-
if (!prefix)
|
|
248270
|
+
const content = authorizeNoteContent(annotation);
|
|
248271
|
+
if (content === undefined)
|
|
247128
248272
|
return null;
|
|
247129
|
-
return unwrapQuotedExpression(
|
|
248273
|
+
return unwrapQuotedExpression(content.trim());
|
|
247130
248274
|
}
|
|
247131
248275
|
function collectAuthorizeExprs(annotations) {
|
|
247132
248276
|
const exprs = [];
|
|
@@ -247174,14 +248318,20 @@ function unwrapQuotedExpression(body) {
|
|
|
247174
248318
|
}
|
|
247175
248319
|
return expr;
|
|
247176
248320
|
}
|
|
247177
|
-
var
|
|
248321
|
+
var AUTHORIZE_ROUTE = "authorize", AUTHORIZE_TAG_LIKE, AUTHORIZE_ANNOTATION_ANYWHERE, MOTLY_AUTHORIZE_PAYLOAD, MALFORMED_AUTHORIZE_ATTEMPT, GIVEN_REF_PATTERN, STRING_LITERAL_PATTERN, everyMemberOf = () => (...members) => members, ROW_LEVEL_GATE_REJECTION_CAUSES, BOOLEAN_NODES, TRANSPARENT_NODES, SCALAR_COMPARISON_NODES, MAX_GATE_WALK_DEPTH = 64, NO_DEFAULT_GIVEN_PATTERN;
|
|
247178
248322
|
var init_authorize = __esm(() => {
|
|
247179
248323
|
init_errors();
|
|
247180
|
-
|
|
247181
|
-
AUTHORIZE_ANNOTATION_ANYWHERE = new RegExp(
|
|
247182
|
-
|
|
248324
|
+
AUTHORIZE_TAG_LIKE = String.raw`##?\|?[ \t]*[([{<]?[ \t]*authorize(?=[)\]}>]|[ \t]|$)`;
|
|
248325
|
+
AUTHORIZE_ANNOTATION_ANYWHERE = new RegExp(AUTHORIZE_TAG_LIKE, "iu");
|
|
248326
|
+
MOTLY_AUTHORIZE_PAYLOAD = /^[ \t]*[([{<][ \t]*authorize[ \t]*[)\]}>]/iu;
|
|
248327
|
+
MALFORMED_AUTHORIZE_ATTEMPT = /^##?\|?[ \t]*[([{<]?[ \t]*authorize/iu;
|
|
247183
248328
|
GIVEN_REF_PATTERN = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
247184
248329
|
STRING_LITERAL_PATTERN = /'(?:\\.|[^'\\])*'/g;
|
|
248330
|
+
ROW_LEVEL_GATE_REJECTION_CAUSES = everyMemberOf()("array_given_needs_in", "scalar_given_rejects_in", "field_given_has_default", "unsupported_node", "no_given_reference", "unreachable_given", "entry_point_unexpressible");
|
|
248331
|
+
BOOLEAN_NODES = new Set(["and", "or"]);
|
|
248332
|
+
TRANSPARENT_NODES = new Set(["()"]);
|
|
248333
|
+
SCALAR_COMPARISON_NODES = new Set(["=", "!=", ">", ">=", "<", "<="]);
|
|
248334
|
+
NO_DEFAULT_GIVEN_PATTERN = /has no value and no default/;
|
|
247185
248335
|
});
|
|
247186
248336
|
|
|
247187
248337
|
// src/authorize_metrics.ts
|
|
@@ -247197,9 +248347,22 @@ function recordAuthorizeBypass(entryPoint) {
|
|
|
247197
248347
|
});
|
|
247198
248348
|
bypassCounter.add(1, { entry_point: entryPoint });
|
|
247199
248349
|
}
|
|
247200
|
-
|
|
248350
|
+
function recordRowLevelGateDecision(decision) {
|
|
248351
|
+
rowLevelDecisionCounter ??= publisherMeter().createCounter("publisher_authorize_row_level_total", {
|
|
248352
|
+
description: "How a row-level `#(authorize)` gate resolved a request. Label: decision ('denied_by_gate'|'empty_after_filter'). 'denied_by_gate' is the fail-closed refusal when the gate could not be applied; 'empty_after_filter' is a successful response with zero rows after the filter matched none, which is NOT an error."
|
|
248353
|
+
});
|
|
248354
|
+
rowLevelDecisionCounter.add(1, { decision });
|
|
248355
|
+
}
|
|
248356
|
+
function recordRowLevelGateRejected(cause) {
|
|
248357
|
+
rowLevelRejectionCounter ??= publisherMeter().createCounter("publisher_authorize_row_level_rejected_total", {
|
|
248358
|
+
description: "Row-level `#(authorize)` gates refused at package load because their compiled condition is not an allowed shape, or an inherited gate that could not be expressed at one derived entry point. Label: cause (" + ROW_LEVEL_GATE_REJECTION_CAUSES.map((c) => `'${c}'`).join("|") + "). All but 'entry_point_unexpressible' fail the whole model load; that one fires at load without failing it — see the doc above. Alert on any nonzero value since the last publish, not on a rate."
|
|
248359
|
+
});
|
|
248360
|
+
rowLevelRejectionCounter.add(1, { cause });
|
|
248361
|
+
}
|
|
248362
|
+
var guardRejectionCounter = null, bypassCounter = null, rowLevelDecisionCounter = null, rowLevelRejectionCounter = null;
|
|
247201
248363
|
var init_authorize_metrics = __esm(() => {
|
|
247202
248364
|
init_telemetry();
|
|
248365
|
+
init_authorize();
|
|
247203
248366
|
});
|
|
247204
248367
|
|
|
247205
248368
|
// src/materialization_metrics.ts
|
|
@@ -247734,76 +248897,6 @@ function filterFreshManifest(entries, now) {
|
|
|
247734
248897
|
return { manifest, nextStaleSince };
|
|
247735
248898
|
}
|
|
247736
248899
|
|
|
247737
|
-
// src/service/annotations.ts
|
|
247738
|
-
import { Annotations } from "@malloydata/malloy";
|
|
247739
|
-
function isReservedRoute(route) {
|
|
247740
|
-
return route === "" || !/[\p{L}\p{N}]/u.test(route);
|
|
247741
|
-
}
|
|
247742
|
-
function ownModelAnnotations(modelDef) {
|
|
247743
|
-
return foldModelAnnotations(modelDef, (id) => id === modelDef.modelID || id.startsWith("internal://"));
|
|
247744
|
-
}
|
|
247745
|
-
function modelAnnotations(modelDef) {
|
|
247746
|
-
return foldModelAnnotations(modelDef, () => true);
|
|
247747
|
-
}
|
|
247748
|
-
function foldModelAnnotations(modelDef, admits) {
|
|
247749
|
-
const registry = modelDef.modelAnnotations ?? {};
|
|
247750
|
-
const visited = new Set;
|
|
247751
|
-
const order = [];
|
|
247752
|
-
const visit = (id) => {
|
|
247753
|
-
if (!admits(id))
|
|
247754
|
-
return;
|
|
247755
|
-
if (visited.has(id))
|
|
247756
|
-
return;
|
|
247757
|
-
visited.add(id);
|
|
247758
|
-
const entry = registry[id];
|
|
247759
|
-
if (!entry)
|
|
247760
|
-
return;
|
|
247761
|
-
for (const dep of entry.inheritsFrom)
|
|
247762
|
-
visit(dep);
|
|
247763
|
-
order.push(id);
|
|
247764
|
-
};
|
|
247765
|
-
visit(modelDef.modelID);
|
|
247766
|
-
let folded;
|
|
247767
|
-
for (const id of order) {
|
|
247768
|
-
const own = registry[id].ownNotes;
|
|
247769
|
-
if (!own.notes?.length && !own.blockNotes?.length)
|
|
247770
|
-
continue;
|
|
247771
|
-
folded = {
|
|
247772
|
-
notes: own.notes,
|
|
247773
|
-
blockNotes: own.blockNotes,
|
|
247774
|
-
inherits: folded
|
|
247775
|
-
};
|
|
247776
|
-
}
|
|
247777
|
-
return folded ?? {};
|
|
247778
|
-
}
|
|
247779
|
-
function ownModelNotes(modelDef) {
|
|
247780
|
-
const registry = modelDef.modelAnnotations ?? {};
|
|
247781
|
-
const isSameDocument = (id) => id === modelDef.modelID || id.startsWith("internal://");
|
|
247782
|
-
const seen = new Set;
|
|
247783
|
-
const texts = [];
|
|
247784
|
-
const visit = (id) => {
|
|
247785
|
-
if (seen.has(id) || !isSameDocument(id))
|
|
247786
|
-
return;
|
|
247787
|
-
seen.add(id);
|
|
247788
|
-
const entry = registry[id];
|
|
247789
|
-
if (!entry)
|
|
247790
|
-
return;
|
|
247791
|
-
for (const dep of entry.inheritsFrom)
|
|
247792
|
-
visit(dep);
|
|
247793
|
-
texts.push(...ownLevelNoteTexts(entry.ownNotes));
|
|
247794
|
-
};
|
|
247795
|
-
visit(modelDef.modelID);
|
|
247796
|
-
return texts;
|
|
247797
|
-
}
|
|
247798
|
-
function annotationTexts(annote) {
|
|
247799
|
-
const texts = new Annotations(annote).texts();
|
|
247800
|
-
return texts.length > 0 ? texts : undefined;
|
|
247801
|
-
}
|
|
247802
|
-
function ownLevelNoteTexts(annote) {
|
|
247803
|
-
return [...annote?.blockNotes ?? [], ...annote?.notes ?? []].map((note) => note.text);
|
|
247804
|
-
}
|
|
247805
|
-
var init_annotations = () => {};
|
|
247806
|
-
|
|
247807
248900
|
// src/service/incremental_declaration.ts
|
|
247808
248901
|
function safeTag(source) {
|
|
247809
248902
|
try {
|
|
@@ -247995,6 +249088,18 @@ function assertMaterializationEligible(persistSource) {
|
|
|
247995
249088
|
});
|
|
247996
249089
|
}
|
|
247997
249090
|
}
|
|
249091
|
+
function assertColocatedPersistNotAuthorizeGated(persistSource, sourceName = persistSource.name, origin2 = "persist") {
|
|
249092
|
+
if (referencesAuthorize(persistSource)) {
|
|
249093
|
+
recordEligibilityRefused("authorize");
|
|
249094
|
+
const gated = origin2 === "preaggregate" ? `the source '${sourceName}' rolls up is protected by an ` + `#(authorize) gate (its own or a joined source's)` : `it is protected by an #(authorize) gate (its own or a joined ` + `source's)`;
|
|
249095
|
+
const remedy = origin2 === "preaggregate" ? `Remove the '#@ preaggregate' annotation from the gated source's ` + `measure(s), or move the gate to a source that is not ` + `pre-aggregated.` : `Drop '#@ persist' from this source, or move the gate to a source ` + `that is not materialized.`;
|
|
249096
|
+
const what = origin2 === "preaggregate" ? `Pre-aggregation rollup '${sourceName}' cannot be built` : `Source '${sourceName}' cannot be materialized (colocated ` + `'#@ persist')`;
|
|
249097
|
+
const alsoRollup = origin2 === "preaggregate" ? ` A rollup also groups ACROSS the gated column, so it could not ` + `be row-filtered afterwards even in principle.` : "";
|
|
249098
|
+
throw new MaterializationEligibilityError({
|
|
249099
|
+
message: `${what}: ${gated}. An authorize expression is evaluated per ` + `request; a materialized-once table served frozen carries no gate, ` + `so it would be served to everyone, bypassing authorization.` + `${alsoRollup} This is refused for safety. ${remedy}`
|
|
249100
|
+
});
|
|
249101
|
+
}
|
|
249102
|
+
}
|
|
247998
249103
|
function unboundParameterNames(persistSource) {
|
|
247999
249104
|
const def = persistSource._sourceDef;
|
|
248000
249105
|
if (def === null || typeof def !== "object") {
|
|
@@ -248148,6 +249253,19 @@ function readPreaggregateAnnotation(measure) {
|
|
|
248148
249253
|
continue;
|
|
248149
249254
|
declared = true;
|
|
248150
249255
|
const grainText = tag.text("preaggregate", "grain") ?? tag.text("grain");
|
|
249256
|
+
const namespaceText = tag.text("preaggregate", "namespace") ?? tag.text("namespace");
|
|
249257
|
+
let namespace;
|
|
249258
|
+
if (namespaceText !== undefined) {
|
|
249259
|
+
const candidate = namespaceText.trim();
|
|
249260
|
+
if (!isSpliceableNamespace(candidate)) {
|
|
249261
|
+
errors2.push({
|
|
249262
|
+
kind: "invalid_namespace",
|
|
249263
|
+
message: `Measure \`${name}\` declares \`#@ preaggregate namespace="${candidate}"\`, which is not a plain namespace. Use letters, digits, underscore, dollar or hyphen per part, dot-separated for a qualified one (\`analytics\`, \`my-project.analytics\`) — the rollup's own table name is generated and appended, so a namespace needing quotes cannot be joined to it.`
|
|
249264
|
+
});
|
|
249265
|
+
continue;
|
|
249266
|
+
}
|
|
249267
|
+
namespace = candidate;
|
|
249268
|
+
}
|
|
248151
249269
|
if (grainText === undefined) {
|
|
248152
249270
|
errors2.push({
|
|
248153
249271
|
kind: "missing_grain",
|
|
@@ -248163,7 +249281,11 @@ function readPreaggregateAnnotation(measure) {
|
|
|
248163
249281
|
});
|
|
248164
249282
|
continue;
|
|
248165
249283
|
}
|
|
248166
|
-
grains.set(dimensions.join("\x00"), {
|
|
249284
|
+
grains.set(dimensions.join("\x00"), {
|
|
249285
|
+
dimensions,
|
|
249286
|
+
text: grainText,
|
|
249287
|
+
namespace
|
|
249288
|
+
});
|
|
248167
249289
|
}
|
|
248168
249290
|
return {
|
|
248169
249291
|
declared,
|
|
@@ -248174,6 +249296,7 @@ function readPreaggregateAnnotation(measure) {
|
|
|
248174
249296
|
}
|
|
248175
249297
|
var NEGATION;
|
|
248176
249298
|
var init_preaggregation_annotation = __esm(() => {
|
|
249299
|
+
init_preaggregation_synthesis();
|
|
248177
249300
|
NEGATION = /#@\s*-\s*preaggregate\b/;
|
|
248178
249301
|
});
|
|
248179
249302
|
|
|
@@ -248187,7 +249310,7 @@ function walk(node, found) {
|
|
|
248187
249310
|
found.aggregates.push({ function: node.function });
|
|
248188
249311
|
break;
|
|
248189
249312
|
case "filteredExpr":
|
|
248190
|
-
found.filtered
|
|
249313
|
+
found.filtered += 1;
|
|
248191
249314
|
break;
|
|
248192
249315
|
case "all":
|
|
248193
249316
|
case "exclude":
|
|
@@ -248240,7 +249363,7 @@ function classifyMeasureAdditivity(measure) {
|
|
|
248240
249363
|
}
|
|
248241
249364
|
const found = {
|
|
248242
249365
|
aggregates: [],
|
|
248243
|
-
filtered:
|
|
249366
|
+
filtered: 0,
|
|
248244
249367
|
ungrouped: false,
|
|
248245
249368
|
analytic: false
|
|
248246
249369
|
};
|
|
@@ -248251,8 +249374,13 @@ function classifyMeasureAdditivity(measure) {
|
|
|
248251
249374
|
if (found.analytic) {
|
|
248252
249375
|
return nonAdditive("analytic", `Measure \`${name}\` contains a window function, which is computed across result rows rather than within a group, so it cannot be pre-aggregated. Remove \`#@ preaggregate\` from it.`);
|
|
248253
249376
|
}
|
|
248254
|
-
|
|
248255
|
-
|
|
249377
|
+
let aggregateRoot = measure.e;
|
|
249378
|
+
if (found.filtered > 0) {
|
|
249379
|
+
const root = measure.e;
|
|
249380
|
+
if (found.filtered !== 1 || root.node !== "filteredExpr" || root.kids.e.node !== "aggregate" || !root.kids.filterList.every((c) => c.expressionType === "scalar")) {
|
|
249381
|
+
return nonAdditive("filtered_aggregate", `Measure \`${name}\` applies a filter this version cannot pre-aggregate: a filter is supported only when written directly on the measure's single aggregate, as \`amount.sum() { where: … }\` (several conditions go in ONE \`where:\`, comma-separated, not chained). If the filter sits under a wrapper or refines a derived measure, pre-aggregate the bare filtered aggregate as its own measure and do the rest in a view, or remove \`#@ preaggregate\` from it.`);
|
|
249382
|
+
}
|
|
249383
|
+
aggregateRoot = root.kids.e;
|
|
248256
249384
|
}
|
|
248257
249385
|
if (found.aggregates.length === 0) {
|
|
248258
249386
|
return nonAdditive("no_aggregate_found", `Measure \`${name}\` does not compute a single plain aggregate, and only \`sum\`, \`count\`, \`min\` and \`max\` can be re-aggregated from a stored partial. If it is derived from other measures, pre-aggregate those instead and compute this one in a view.`);
|
|
@@ -248260,10 +249388,10 @@ function classifyMeasureAdditivity(measure) {
|
|
|
248260
249388
|
if (found.aggregates.length > 1) {
|
|
248261
249389
|
return nonAdditive("multiple_aggregates", `Measure \`${name}\` combines ${found.aggregates.length} aggregates, and a ratio or difference of aggregates cannot be re-aggregated from a single stored column. Pre-aggregate each aggregate as its own measure and combine them in a view.`);
|
|
248262
249390
|
}
|
|
248263
|
-
if (
|
|
249391
|
+
if (aggregateRoot.node !== "aggregate") {
|
|
248264
249392
|
return nonAdditive("aggregate_not_at_root", `Measure \`${name}\` wraps its aggregate in a further expression, so re-aggregating a stored partial would not reproduce it. Pre-aggregate the bare aggregate as its own measure and do the rest in a view.`);
|
|
248265
249393
|
}
|
|
248266
|
-
const fn =
|
|
249394
|
+
const fn = aggregateRoot.function;
|
|
248267
249395
|
if (!isAdditiveAggregate(fn)) {
|
|
248268
249396
|
const hint = fn === "avg" ? " Pre-aggregate a `sum` and a `count` instead, and divide them in a view." : fn === "distinct" ? " A distinct count cannot be merged from per-group partials." : "";
|
|
248269
249397
|
return nonAdditive("unsupported_aggregate", `Measure \`${name}\` uses \`${fn}\`, which cannot be re-aggregated from a stored partial; only \`sum\`, \`count\`, \`min\` and \`max\` can.${hint}`);
|
|
@@ -248281,6 +249409,7 @@ var init_preaggregation_classifier = __esm(() => {
|
|
|
248281
249409
|
});
|
|
248282
249410
|
|
|
248283
249411
|
// src/service/preaggregation_synthesis.ts
|
|
249412
|
+
import { Annotations as Annotations3 } from "@malloydata/malloy";
|
|
248284
249413
|
import { createHash as createHash2 } from "node:crypto";
|
|
248285
249414
|
function grainDigest(grainDimensions) {
|
|
248286
249415
|
return createHash2("sha256").update(grainDimensions.join("\x00")).digest("hex").slice(0, 8);
|
|
@@ -248289,10 +249418,40 @@ function rollupSourceName(baseSourceName, grainDimensions) {
|
|
|
248289
249418
|
const slug = grainDimensions.join("_").slice(0, NAME_SLUG_LIMIT);
|
|
248290
249419
|
return `${baseSourceName}__preagg__${slug}__${grainDigest(grainDimensions)}`;
|
|
248291
249420
|
}
|
|
249421
|
+
function isSpliceableNamespace(namespace) {
|
|
249422
|
+
return namespace.split(".").every((s) => NAMESPACE_SEGMENT.test(s));
|
|
249423
|
+
}
|
|
248292
249424
|
function baseAlias(baseSourceName) {
|
|
248293
249425
|
return `${baseSourceName}${BASE_ALIAS_SUFFIX}`;
|
|
248294
249426
|
}
|
|
249427
|
+
function persistNamespace(persistName) {
|
|
249428
|
+
if (!persistName)
|
|
249429
|
+
return;
|
|
249430
|
+
const lastDot = persistName.lastIndexOf(".");
|
|
249431
|
+
if (lastDot <= 0)
|
|
249432
|
+
return;
|
|
249433
|
+
if (persistName.slice(lastDot + 1).trim() === "")
|
|
249434
|
+
return;
|
|
249435
|
+
const candidate = persistName.slice(0, lastDot);
|
|
249436
|
+
return isSpliceableNamespace(candidate) ? candidate : undefined;
|
|
249437
|
+
}
|
|
249438
|
+
function basePersistNamespace(source) {
|
|
249439
|
+
if (!source.annotations)
|
|
249440
|
+
return;
|
|
249441
|
+
try {
|
|
249442
|
+
const tag = new Annotations3(source.annotations).parseAsTag("@").tag;
|
|
249443
|
+
if (!tag.has("persist"))
|
|
249444
|
+
return;
|
|
249445
|
+
const storage = tag.text("persist", "storage") ?? tag.text("storage");
|
|
249446
|
+
if (storage !== undefined && storage.trim() !== "")
|
|
249447
|
+
return;
|
|
249448
|
+
return persistNamespace(tag.text("persist", "name") ?? tag.text("name"));
|
|
249449
|
+
} catch {
|
|
249450
|
+
return;
|
|
249451
|
+
}
|
|
249452
|
+
}
|
|
248295
249453
|
function planSourcePreaggregation(baseSourceName, source) {
|
|
249454
|
+
const inheritedNamespace = basePersistNamespace(source);
|
|
248296
249455
|
const byGrain = new Map;
|
|
248297
249456
|
for (const field of source.fields ?? []) {
|
|
248298
249457
|
const declaration = readPreaggregateAnnotation(field);
|
|
@@ -248313,12 +249472,14 @@ function planSourcePreaggregation(baseSourceName, source) {
|
|
|
248313
249472
|
partialName: `${name}${PARTIAL_SUFFIX}`,
|
|
248314
249473
|
reaggregate: additivity.reaggregate
|
|
248315
249474
|
});
|
|
249475
|
+
entry.namespace ??= grain.namespace;
|
|
248316
249476
|
byGrain.set(key, entry);
|
|
248317
249477
|
}
|
|
248318
249478
|
}
|
|
248319
|
-
return [...byGrain.values()].map(({ grainDimensions, measures }) => ({
|
|
249479
|
+
return [...byGrain.values()].map(({ grainDimensions, measures, namespace }) => ({
|
|
248320
249480
|
baseSourceName,
|
|
248321
249481
|
rollupSourceName: rollupSourceName(baseSourceName, grainDimensions),
|
|
249482
|
+
namespace: namespace ?? inheritedNamespace,
|
|
248322
249483
|
grainDimensions,
|
|
248323
249484
|
measures: [...measures].sort((a, b) => a.name.localeCompare(b.name))
|
|
248324
249485
|
})).sort((a, b) => a.rollupSourceName.localeCompare(b.rollupSourceName));
|
|
@@ -248336,7 +249497,8 @@ function emitRollup(plan) {
|
|
|
248336
249497
|
`);
|
|
248337
249498
|
const merged = plan.measures.map((m) => ` ${m.name} is ${m.partialName}.${m.reaggregate}()`).join(`
|
|
248338
249499
|
`);
|
|
248339
|
-
|
|
249500
|
+
const persist = plan.namespace ? `#@ persist name="${plan.namespace}.${plan.rollupSourceName}"` : "#@ persist";
|
|
249501
|
+
return `${persist}
|
|
248340
249502
|
source: ${plan.rollupSourceName} is ${alias} -> {
|
|
248341
249503
|
group_by:
|
|
248342
249504
|
${plan.grainDimensions.map((d) => ` ${d}`).join(`
|
|
@@ -248369,10 +249531,11 @@ ${plans.map(emitRollup).join(`
|
|
|
248369
249531
|
${composites}
|
|
248370
249532
|
`;
|
|
248371
249533
|
}
|
|
248372
|
-
var BASE_ALIAS_SUFFIX = "__preagg_base", PARTIAL_SUFFIX = "__partial", NAME_SLUG_LIMIT = 40;
|
|
249534
|
+
var BASE_ALIAS_SUFFIX = "__preagg_base", PARTIAL_SUFFIX = "__partial", NAME_SLUG_LIMIT = 40, NAMESPACE_SEGMENT;
|
|
248373
249535
|
var init_preaggregation_synthesis = __esm(() => {
|
|
248374
249536
|
init_preaggregation_annotation();
|
|
248375
249537
|
init_preaggregation_classifier();
|
|
249538
|
+
NAMESPACE_SEGMENT = /^[A-Za-z_][A-Za-z0-9_$-]*$/;
|
|
248376
249539
|
});
|
|
248377
249540
|
|
|
248378
249541
|
// src/service/preaggregation_compile.ts
|
|
@@ -248421,7 +249584,7 @@ var init_preaggregation_compile = __esm(() => {
|
|
|
248421
249584
|
});
|
|
248422
249585
|
|
|
248423
249586
|
// src/service/build_plan.ts
|
|
248424
|
-
import { Annotations as
|
|
249587
|
+
import { Annotations as Annotations4 } from "@malloydata/malloy";
|
|
248425
249588
|
function deriveColumns(persistSource) {
|
|
248426
249589
|
try {
|
|
248427
249590
|
return persistSource._explore.intrinsicFields.filter((f) => f.isAtomicField()).map((f) => ({
|
|
@@ -248620,7 +249783,7 @@ function detectDroppedPersistSources(malloyModel, recognizedNames) {
|
|
|
248620
249783
|
continue;
|
|
248621
249784
|
let isPersist = false;
|
|
248622
249785
|
try {
|
|
248623
|
-
isPersist = new
|
|
249786
|
+
isPersist = new Annotations4(def.annotations).parseAsTag("@").tag.has("persist");
|
|
248624
249787
|
} catch {
|
|
248625
249788
|
continue;
|
|
248626
249789
|
}
|
|
@@ -248998,21 +250161,6 @@ var init_filter = __esm(() => {
|
|
|
248998
250161
|
};
|
|
248999
250162
|
});
|
|
249000
250163
|
|
|
249001
|
-
// src/service/given.ts
|
|
249002
|
-
function malloyGivenToApi(given) {
|
|
249003
|
-
const type = given.type;
|
|
249004
|
-
const renderedType = type.type === "filter expression" ? `filter<${type.filterType}>` : type.type;
|
|
249005
|
-
return {
|
|
249006
|
-
name: given.name,
|
|
249007
|
-
type: renderedType,
|
|
249008
|
-
annotations: given.annotations.forRoute(undefined).filter((note) => !isReservedRoute(note.route)).map((note) => note.text),
|
|
249009
|
-
default: given._internal?.defaultText
|
|
249010
|
-
};
|
|
249011
|
-
}
|
|
249012
|
-
var init_given = __esm(() => {
|
|
249013
|
-
init_annotations();
|
|
249014
|
-
});
|
|
249015
|
-
|
|
249016
250164
|
// src/service/model_limits.ts
|
|
249017
250165
|
function resolveModelQueryRowLimit(userLimit, { defaultLimit, maxRows }) {
|
|
249018
250166
|
const requested = userLimit && userLimit > 0 ? userLimit : defaultLimit;
|
|
@@ -249160,6 +250308,8 @@ function validateSourcePreaggregation(sourceName, source) {
|
|
|
249160
250308
|
});
|
|
249161
250309
|
}
|
|
249162
250310
|
const declaredFields = [];
|
|
250311
|
+
const namespacesByGrain = new Map;
|
|
250312
|
+
const grainTextByKey = new Map;
|
|
249163
250313
|
for (const field of fields) {
|
|
249164
250314
|
const name = fieldName(field);
|
|
249165
250315
|
const declaration = readPreaggregateAnnotation(field);
|
|
@@ -249219,8 +250369,26 @@ function validateSourcePreaggregation(sourceName, source) {
|
|
|
249219
250369
|
if (violation)
|
|
249220
250370
|
violations.push(violation);
|
|
249221
250371
|
}
|
|
250372
|
+
if (grain.namespace !== undefined && additivity.additive) {
|
|
250373
|
+
const key = grain.dimensions.join("\x00");
|
|
250374
|
+
const named = namespacesByGrain.get(key) ?? new Map;
|
|
250375
|
+
if (!named.has(grain.namespace))
|
|
250376
|
+
named.set(grain.namespace, name);
|
|
250377
|
+
namespacesByGrain.set(key, named);
|
|
250378
|
+
grainTextByKey.set(key, grain.text);
|
|
250379
|
+
}
|
|
249222
250380
|
}
|
|
249223
250381
|
}
|
|
250382
|
+
for (const [key, named] of namespacesByGrain) {
|
|
250383
|
+
if (named.size < 2)
|
|
250384
|
+
continue;
|
|
250385
|
+
const choices = [...named.entries()].map(([ns, measure]) => `\`${ns}\` (on \`${measure}\`)`).join(", ");
|
|
250386
|
+
violations.push({
|
|
250387
|
+
code: "conflicting_namespace",
|
|
250388
|
+
sourceName,
|
|
250389
|
+
message: `Measures on \`${sourceName}\` declare \`#@ preaggregate\` at the grain \`${grainTextByKey.get(key)}\` with different namespaces: ${choices}. One grain is one rollup table, so it can only be created in one of them. Give every measure at this grain the same \`namespace=\`, or move one to a different grain.`
|
|
250390
|
+
});
|
|
250391
|
+
}
|
|
249224
250392
|
const fanout = declaredFields.length > 0 ? fields.filter(isFanoutJoin) : [];
|
|
249225
250393
|
if (fanout.length > 0) {
|
|
249226
250394
|
const joins = fanout.map((f) => `\`${fieldName(f)}\``).join(", ");
|
|
@@ -249248,16 +250416,182 @@ var init_preaggregation_validation = __esm(() => {
|
|
|
249248
250416
|
init_preaggregation_classifier();
|
|
249249
250417
|
});
|
|
249250
250418
|
|
|
250419
|
+
// src/service/gate_registry_walk.ts
|
|
250420
|
+
import { isSourceDef as isSourceDef2 } from "@malloydata/malloy";
|
|
250421
|
+
function resolveDeclaredSource(struct, modelDef) {
|
|
250422
|
+
if (!modelDef)
|
|
250423
|
+
return { kind: "none" };
|
|
250424
|
+
let sawBrokenEntry = false;
|
|
250425
|
+
for (const id of [struct.referenceID, struct.sourceID]) {
|
|
250426
|
+
const entry = id ? modelDef.sourceRegistry?.[id]?.entry : undefined;
|
|
250427
|
+
if (!entry)
|
|
250428
|
+
continue;
|
|
250429
|
+
const declared = entry.type === "source_registry_reference" ? modelDef.contents[entry.name] : entry;
|
|
250430
|
+
if (declared === struct)
|
|
250431
|
+
continue;
|
|
250432
|
+
if (!declared || !isSourceDef2(declared)) {
|
|
250433
|
+
sawBrokenEntry = true;
|
|
250434
|
+
continue;
|
|
250435
|
+
}
|
|
250436
|
+
return { kind: "resolved", source: declared };
|
|
250437
|
+
}
|
|
250438
|
+
return sawBrokenEntry ? { kind: "unresolvable" } : { kind: "none" };
|
|
250439
|
+
}
|
|
250440
|
+
function ancestorGateExprs(struct, modelDef, seen = new Set) {
|
|
250441
|
+
let inherited = struct.annotations?.inherits;
|
|
250442
|
+
for (let depth = 0;inherited && depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
|
|
250443
|
+
const exprs2 = collectAuthorizeExprs(ownLevelNoteTexts(inherited));
|
|
250444
|
+
if (exprs2.length > 0)
|
|
250445
|
+
return exprs2;
|
|
250446
|
+
inherited = inherited.inherits;
|
|
250447
|
+
}
|
|
250448
|
+
if (inherited)
|
|
250449
|
+
return ["false"];
|
|
250450
|
+
seen.add(struct);
|
|
250451
|
+
if (seen.size > ANCESTOR_WALK_MAX_DEPTH)
|
|
250452
|
+
return ["false"];
|
|
250453
|
+
const declared = resolveDeclaredSource(struct, modelDef);
|
|
250454
|
+
if (declared.kind === "unresolvable")
|
|
250455
|
+
return ["false"];
|
|
250456
|
+
if (declared.kind === "none" || seen.has(declared.source))
|
|
250457
|
+
return [];
|
|
250458
|
+
const exprs = collectAuthorizeExprs(ownLevelNoteTexts(declared.source.annotations));
|
|
250459
|
+
return exprs.length > 0 ? exprs : ancestorGateExprs(declared.source, modelDef, seen);
|
|
250460
|
+
}
|
|
250461
|
+
function resolveQuerySourceBase(struct, modelDef) {
|
|
250462
|
+
const duck = struct;
|
|
250463
|
+
if (duck.type !== "query_source")
|
|
250464
|
+
return;
|
|
250465
|
+
const ref = duck.query?.structRef;
|
|
250466
|
+
const base = typeof ref === "string" ? modelDef?.contents[ref] : ref;
|
|
250467
|
+
return base && isSourceDef2(base) ? base : undefined;
|
|
250468
|
+
}
|
|
250469
|
+
function resolveCompositeResolvedBase(struct) {
|
|
250470
|
+
const duck = struct;
|
|
250471
|
+
return duck.type === "query_source" ? duck.query?.compositeResolvedSourceDef : undefined;
|
|
250472
|
+
}
|
|
250473
|
+
function effectiveAncestorGateExprs(struct, modelDef, seen = new Set) {
|
|
250474
|
+
const direct = ancestorGateExprs(struct, modelDef, new Set(seen));
|
|
250475
|
+
if (direct.length > 0)
|
|
250476
|
+
return [direct];
|
|
250477
|
+
if (seen.has(struct))
|
|
250478
|
+
return [];
|
|
250479
|
+
seen.add(struct);
|
|
250480
|
+
const groups = [];
|
|
250481
|
+
const base = resolveQuerySourceBase(struct, modelDef);
|
|
250482
|
+
if (!base) {
|
|
250483
|
+
const duck = struct;
|
|
250484
|
+
if (duck.type === "query_source")
|
|
250485
|
+
groups.push(["false"]);
|
|
250486
|
+
} else if (!seen.has(base)) {
|
|
250487
|
+
const ownExprs = collectAuthorizeExprs(ownLevelNoteTexts(base.annotations));
|
|
250488
|
+
groups.push(...ownExprs.length > 0 ? [ownExprs] : effectiveAncestorGateExprs(base, modelDef, seen));
|
|
250489
|
+
}
|
|
250490
|
+
const composite = resolveCompositeResolvedBase(struct);
|
|
250491
|
+
if (composite && !seen.has(composite)) {
|
|
250492
|
+
const parentOwnNotes = base ? ownLevelNotes(base.annotations) : [];
|
|
250493
|
+
const compositeOwnNotes = ownLevelNotes(composite.annotations).filter((note) => !parentOwnNotes.includes(note));
|
|
250494
|
+
const compositeOwn = collectAuthorizeExprs(compositeOwnNotes.map((note) => note.text));
|
|
250495
|
+
groups.push(...compositeOwn.length > 0 ? [compositeOwn] : effectiveAncestorGateExprs(composite, modelDef, seen));
|
|
250496
|
+
}
|
|
250497
|
+
return groups;
|
|
250498
|
+
}
|
|
250499
|
+
function derivedStructsReachable(roots, modelDef) {
|
|
250500
|
+
const seen = new Set(roots);
|
|
250501
|
+
const found = [];
|
|
250502
|
+
const worklist = [...roots];
|
|
250503
|
+
for (let i = 0;i < worklist.length; i++) {
|
|
250504
|
+
const struct = worklist[i];
|
|
250505
|
+
for (const next of [
|
|
250506
|
+
resolveQuerySourceBase(struct, modelDef),
|
|
250507
|
+
resolveCompositeResolvedBase(struct)
|
|
250508
|
+
]) {
|
|
250509
|
+
if (!next || seen.has(next))
|
|
250510
|
+
continue;
|
|
250511
|
+
seen.add(next);
|
|
250512
|
+
found.push(next);
|
|
250513
|
+
worklist.push(next);
|
|
250514
|
+
}
|
|
250515
|
+
}
|
|
250516
|
+
return found;
|
|
250517
|
+
}
|
|
250518
|
+
var ANCESTOR_WALK_MAX_DEPTH = 32;
|
|
250519
|
+
var init_gate_registry_walk = __esm(() => {
|
|
250520
|
+
init_annotations();
|
|
250521
|
+
init_authorize();
|
|
250522
|
+
});
|
|
250523
|
+
|
|
249251
250524
|
// src/service/source_extraction.ts
|
|
249252
250525
|
import {
|
|
249253
|
-
|
|
250526
|
+
isJoined as isJoined2,
|
|
250527
|
+
isSourceDef as isSourceDef3
|
|
249254
250528
|
} from "@malloydata/malloy";
|
|
250529
|
+
function joinFieldNamesUnresolvableDeclaration(field, modelDef) {
|
|
250530
|
+
const ids = [field.referenceID, field.sourceID].filter((id) => !!id);
|
|
250531
|
+
if (ids.length === 0)
|
|
250532
|
+
return false;
|
|
250533
|
+
for (const id of ids) {
|
|
250534
|
+
const entry = modelDef.sourceRegistry?.[id]?.entry;
|
|
250535
|
+
if (!entry)
|
|
250536
|
+
continue;
|
|
250537
|
+
const declared = entry.type === "source_registry_reference" ? modelDef.contents[entry.name] : entry;
|
|
250538
|
+
if (declared && isSourceDef3(declared))
|
|
250539
|
+
return false;
|
|
250540
|
+
}
|
|
250541
|
+
return true;
|
|
250542
|
+
}
|
|
249255
250543
|
function extractSourcesFromModelDef(modelDef, givens, onParseError) {
|
|
249256
250544
|
const filterMap = new Map;
|
|
249257
250545
|
const authorizeMap = new Map;
|
|
249258
|
-
const
|
|
249259
|
-
const
|
|
249260
|
-
const
|
|
250546
|
+
const misplacedAuthorize = [];
|
|
250547
|
+
const gatedSourceOwnAuthorizeNotes = new Set;
|
|
250548
|
+
const nearMissAuthorize = [];
|
|
250549
|
+
const sweptStructs = [];
|
|
250550
|
+
for (const obj of Object.values(modelDef.contents)) {
|
|
250551
|
+
if (!isSourceDef3(obj))
|
|
250552
|
+
continue;
|
|
250553
|
+
const struct = obj;
|
|
250554
|
+
sweptStructs.push(obj);
|
|
250555
|
+
for (const note of ownLevelNotes(struct.annotations)) {
|
|
250556
|
+
if (containsAuthorizeAnnotationTag([note.text])) {
|
|
250557
|
+
gatedSourceOwnAuthorizeNotes.add(note);
|
|
250558
|
+
}
|
|
250559
|
+
}
|
|
250560
|
+
nearMissAuthorize.push(...collectAuthorizeNearMisses([
|
|
250561
|
+
...ownLevelNotes(struct.annotations),
|
|
250562
|
+
...struct.fields.flatMap((field) => ownLevelNotes(field.annotations))
|
|
250563
|
+
].map((note) => note.text)));
|
|
250564
|
+
}
|
|
250565
|
+
for (const value of Object.values(modelDef.sourceRegistry ?? {})) {
|
|
250566
|
+
const entry = value.entry;
|
|
250567
|
+
if (entry.type === "source_registry_reference")
|
|
250568
|
+
continue;
|
|
250569
|
+
if (!isSourceDef3(entry))
|
|
250570
|
+
continue;
|
|
250571
|
+
sweptStructs.push(entry);
|
|
250572
|
+
for (const note of ownLevelNotes(entry.annotations)) {
|
|
250573
|
+
if (containsAuthorizeAnnotationTag([note.text])) {
|
|
250574
|
+
gatedSourceOwnAuthorizeNotes.add(note);
|
|
250575
|
+
}
|
|
250576
|
+
}
|
|
250577
|
+
nearMissAuthorize.push(...collectAuthorizeNearMisses(ownLevelNotes(entry.annotations).map((note) => note.text)));
|
|
250578
|
+
}
|
|
250579
|
+
for (const struct of derivedStructsReachable(sweptStructs, modelDef)) {
|
|
250580
|
+
nearMissAuthorize.push(...collectAuthorizeNearMisses([
|
|
250581
|
+
...ownLevelNotes(struct.annotations),
|
|
250582
|
+
...struct.fields.flatMap((field) => ownLevelNotes(field.annotations))
|
|
250583
|
+
].map((note) => note.text)));
|
|
250584
|
+
}
|
|
250585
|
+
{
|
|
250586
|
+
const folded = modelAnnotations(modelDef);
|
|
250587
|
+
nearMissAuthorize.push(...collectAuthorizeNearMisses([...folded.notes ?? [], ...folded.blockNotes ?? []].map((note) => note.text)));
|
|
250588
|
+
}
|
|
250589
|
+
assertNoAuthorizeNearMisses(nearMissAuthorize);
|
|
250590
|
+
const authorizeOwnNotes = new Map;
|
|
250591
|
+
if (containsAuthorizeAnnotationTag((modelAnnotations(modelDef).notes ?? []).map((note) => note.text))) {
|
|
250592
|
+
misplacedAuthorize.push({ kind: "file" });
|
|
250593
|
+
}
|
|
250594
|
+
const sources = Object.values(modelDef.contents).filter((obj) => isSourceDef3(obj)).map((sourceObj) => {
|
|
249261
250595
|
const struct = sourceObj;
|
|
249262
250596
|
const sourceName = struct.as || struct.name;
|
|
249263
250597
|
const annotations = annotationTexts(struct.annotations);
|
|
@@ -249295,36 +250629,44 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
|
|
|
249295
250629
|
}
|
|
249296
250630
|
const ownNotes = ownLevelNoteTexts(struct.annotations);
|
|
249297
250631
|
const ownGates = collectAuthorizeExprs(ownNotes);
|
|
249298
|
-
|
|
249299
|
-
|
|
249300
|
-
|
|
249301
|
-
const exprs = collectAuthorizeExprs(ownLevelNoteTexts(cur2));
|
|
249302
|
-
if (exprs.length > 0) {
|
|
249303
|
-
inheritedGates = exprs;
|
|
249304
|
-
break;
|
|
249305
|
-
}
|
|
249306
|
-
}
|
|
249307
|
-
}
|
|
249308
|
-
const effective = [
|
|
249309
|
-
...fileLevelAuthorize,
|
|
249310
|
-
...ownGates.length > 0 ? ownGates : inheritedGates
|
|
249311
|
-
];
|
|
250632
|
+
authorizeOwnNotes.set(sourceName, ownLevelNotes(struct.annotations).filter((note) => containsAuthorizeAnnotationTag([note.text])));
|
|
250633
|
+
const inheritedGroups = ownGates.length === 0 ? effectiveAncestorGateExprs(struct, modelDef) : [];
|
|
250634
|
+
const effectiveGroups = ownGates.length > 0 ? [ownGates] : inheritedGroups;
|
|
249312
250635
|
let authorize;
|
|
249313
|
-
if (
|
|
249314
|
-
authorizeMap.set(sourceName,
|
|
249315
|
-
authorize =
|
|
249316
|
-
}
|
|
249317
|
-
const ownEffective = [...fileLevelAuthorize, ...ownGates];
|
|
249318
|
-
if (ownEffective.length > 0) {
|
|
249319
|
-
ownAuthorizeSources.push({
|
|
249320
|
-
name: sourceName,
|
|
249321
|
-
authorize: ownEffective
|
|
249322
|
-
});
|
|
250636
|
+
if (effectiveGroups.length > 0) {
|
|
250637
|
+
authorizeMap.set(sourceName, effectiveGroups);
|
|
250638
|
+
authorize = effectiveGroups.flat();
|
|
249323
250639
|
}
|
|
249324
250640
|
const views = struct.fields.filter((field) => field.type === "turtle").filter((turtle) => turtle.pipeline.map((stage) => stage.type).every((type) => type === "reduce")).map((turtle) => ({
|
|
249325
250641
|
name: turtle.as || turtle.name,
|
|
249326
250642
|
annotations: annotationTexts(turtle.annotations)
|
|
249327
250643
|
}));
|
|
250644
|
+
for (const field of struct.fields) {
|
|
250645
|
+
const fieldAuthorizeNotes = ownLevelNotes(field.annotations).filter((note) => containsAuthorizeAnnotationTag([note.text]));
|
|
250646
|
+
if (fieldAuthorizeNotes.length === 0)
|
|
250647
|
+
continue;
|
|
250648
|
+
if (fieldAuthorizeNotes.every((note) => gatedSourceOwnAuthorizeNotes.has(note))) {
|
|
250649
|
+
continue;
|
|
250650
|
+
}
|
|
250651
|
+
const fieldName2 = field.as || field.name;
|
|
250652
|
+
if (isJoined2(field) && isSourceDef3(field)) {
|
|
250653
|
+
const joinedStruct = field;
|
|
250654
|
+
if (joinFieldNamesUnresolvableDeclaration(joinedStruct, modelDef)) {
|
|
250655
|
+
continue;
|
|
250656
|
+
}
|
|
250657
|
+
misplacedAuthorize.push({
|
|
250658
|
+
kind: "field",
|
|
250659
|
+
name: sourceName,
|
|
250660
|
+
fieldName: fieldName2
|
|
250661
|
+
});
|
|
250662
|
+
continue;
|
|
250663
|
+
}
|
|
250664
|
+
misplacedAuthorize.push({
|
|
250665
|
+
kind: "field",
|
|
250666
|
+
name: sourceName,
|
|
250667
|
+
fieldName: fieldName2
|
|
250668
|
+
});
|
|
250669
|
+
}
|
|
249328
250670
|
return {
|
|
249329
250671
|
name: sourceName,
|
|
249330
250672
|
annotations,
|
|
@@ -249334,20 +250676,33 @@ function extractSourcesFromModelDef(modelDef, givens, onParseError) {
|
|
|
249334
250676
|
authorize
|
|
249335
250677
|
};
|
|
249336
250678
|
});
|
|
249337
|
-
return {
|
|
250679
|
+
return {
|
|
250680
|
+
sources,
|
|
250681
|
+
filterMap,
|
|
250682
|
+
authorizeMap,
|
|
250683
|
+
misplacedAuthorize,
|
|
250684
|
+
authorizeOwnNotes
|
|
250685
|
+
};
|
|
249338
250686
|
}
|
|
249339
250687
|
function extractQueriesFromModelDef(modelDef) {
|
|
249340
250688
|
const isNamedQuery = (obj) => obj.type === "query";
|
|
249341
|
-
|
|
250689
|
+
const namedQueries = Object.values(modelDef.contents).filter(isNamedQuery);
|
|
250690
|
+
const misplacedAuthorize = namedQueries.filter((queryObj) => containsAuthorizeAnnotationTag(ownLevelNoteTexts(queryObj.annotations))).map((queryObj) => ({
|
|
250691
|
+
kind: "query",
|
|
250692
|
+
name: queryObj.as || queryObj.name
|
|
250693
|
+
}));
|
|
250694
|
+
const queries = namedQueries.map((queryObj) => ({
|
|
249342
250695
|
name: queryObj.as || queryObj.name,
|
|
249343
250696
|
sourceName: typeof queryObj.structRef === "string" ? queryObj.structRef : undefined,
|
|
249344
250697
|
annotations: annotationTexts(queryObj.annotations)
|
|
249345
250698
|
}));
|
|
250699
|
+
return { queries, misplacedAuthorize };
|
|
249346
250700
|
}
|
|
249347
250701
|
var init_source_extraction = __esm(() => {
|
|
249348
250702
|
init_annotations();
|
|
249349
250703
|
init_authorize();
|
|
249350
250704
|
init_filter();
|
|
250705
|
+
init_gate_registry_walk();
|
|
249351
250706
|
});
|
|
249352
250707
|
|
|
249353
250708
|
// src/service/model.ts
|
|
@@ -249355,10 +250710,10 @@ import {
|
|
|
249355
250710
|
API,
|
|
249356
250711
|
FixedConnectionMap,
|
|
249357
250712
|
InMemoryURLReader as InMemoryURLReader2,
|
|
249358
|
-
isSourceDef as
|
|
250713
|
+
isSourceDef as isSourceDef4,
|
|
249359
250714
|
MalloyConfig as MalloyConfig2,
|
|
249360
250715
|
MalloyError as MalloyError2,
|
|
249361
|
-
Annotations as
|
|
250716
|
+
Annotations as Annotations5,
|
|
249362
250717
|
modelDefToModelInfo,
|
|
249363
250718
|
Runtime as Runtime2
|
|
249364
250719
|
} from "@malloydata/malloy";
|
|
@@ -249371,7 +250726,7 @@ import { readFileSync } from "fs";
|
|
|
249371
250726
|
import { createRequire as createRequire2 } from "module";
|
|
249372
250727
|
import * as path8 from "path";
|
|
249373
250728
|
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
249374
|
-
function
|
|
250729
|
+
function quoteMalloyIdentifier2(name) {
|
|
249375
250730
|
return "`" + (name ?? "").replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
|
|
249376
250731
|
}
|
|
249377
250732
|
function makeHydrationRuntime(malloyConfig, buildManifest) {
|
|
@@ -249414,6 +250769,7 @@ function hydrateNotebookCells(runtime, notebookCells) {
|
|
|
249414
250769
|
text: sc.text,
|
|
249415
250770
|
runnable,
|
|
249416
250771
|
modelMaterializer,
|
|
250772
|
+
modelDef: cellModelDef,
|
|
249417
250773
|
newSources: sc.newSources,
|
|
249418
250774
|
queryInfo: sc.queryInfo
|
|
249419
250775
|
};
|
|
@@ -249428,7 +250784,7 @@ function hydrateMarkdownOnlyCells(notebookCells) {
|
|
|
249428
250784
|
return { type: "code", text: sc.text };
|
|
249429
250785
|
});
|
|
249430
250786
|
}
|
|
249431
|
-
var MALLOY_VERSION,
|
|
250787
|
+
var MALLOY_VERSION, Model;
|
|
249432
250788
|
var init_model = __esm(() => {
|
|
249433
250789
|
init_telemetry();
|
|
249434
250790
|
init_materialization_metrics();
|
|
@@ -249445,12 +250801,15 @@ var init_model = __esm(() => {
|
|
|
249445
250801
|
init_annotations();
|
|
249446
250802
|
init_build_plan();
|
|
249447
250803
|
init_authorize();
|
|
250804
|
+
init_dashboard();
|
|
249448
250805
|
init_filter();
|
|
249449
250806
|
init_given();
|
|
250807
|
+
init_motly();
|
|
249450
250808
|
init_model_limits();
|
|
249451
250809
|
init_json_utils();
|
|
249452
250810
|
init_query_metadata();
|
|
249453
250811
|
init_preaggregation_validation();
|
|
250812
|
+
init_gate_registry_walk();
|
|
249454
250813
|
init_source_extraction();
|
|
249455
250814
|
init_authorize_metrics();
|
|
249456
250815
|
MALLOY_VERSION = createRequire2(import.meta.url)("@malloydata/malloy/package.json").version;
|
|
@@ -249473,7 +250832,6 @@ var init_model = __esm(() => {
|
|
|
249473
250832
|
compilationError;
|
|
249474
250833
|
filterMap;
|
|
249475
250834
|
givens;
|
|
249476
|
-
fileLevelAuthorize = [];
|
|
249477
250835
|
declaredQueryMetadataMemo;
|
|
249478
250836
|
declaredSourceQueryMetadataMemo;
|
|
249479
250837
|
authorizeReferencedGivenNames = new Set;
|
|
@@ -249482,6 +250840,11 @@ var init_model = __esm(() => {
|
|
|
249482
250840
|
freshnessResolver;
|
|
249483
250841
|
preaggregateEntityIdResolver;
|
|
249484
250842
|
entryPointGatesBySource = new Map;
|
|
250843
|
+
gateRuntime;
|
|
250844
|
+
gateShapeCache = new Map;
|
|
250845
|
+
graftedMaterializerCache = new Map;
|
|
250846
|
+
static GRAFTED_MATERIALIZER_CACHE_MAX = 32;
|
|
250847
|
+
rowLevelFilteredRunnables = new WeakSet;
|
|
249485
250848
|
meter = publisherMeter();
|
|
249486
250849
|
queryExecutionHistogram = this.meter.createHistogram("malloy_model_query_duration", {
|
|
249487
250850
|
description: "How long it takes to execute a Malloy model query",
|
|
@@ -249505,11 +250868,6 @@ var init_model = __esm(() => {
|
|
|
249505
250868
|
this.compilationError = compilationError;
|
|
249506
250869
|
this.filterMap = filterMap ?? new Map;
|
|
249507
250870
|
this.givens = givens;
|
|
249508
|
-
try {
|
|
249509
|
-
this.fileLevelAuthorize = this.modelDef ? collectAuthorizeExprs((modelAnnotations(this.modelDef).notes ?? []).map((note) => note.text)) : [];
|
|
249510
|
-
} catch {
|
|
249511
|
-
this.fileLevelAuthorize = [];
|
|
249512
|
-
}
|
|
249513
250871
|
try {
|
|
249514
250872
|
this.entryPointGatesBySource = this.computeEntryPointGatesBySource();
|
|
249515
250873
|
} catch {
|
|
@@ -249539,8 +250897,64 @@ var init_model = __esm(() => {
|
|
|
249539
250897
|
getFilters(sourceName) {
|
|
249540
250898
|
return this.filterMap.get(sourceName) ?? [];
|
|
249541
250899
|
}
|
|
250900
|
+
setGateRuntime(runtime) {
|
|
250901
|
+
this.gateRuntime = runtime;
|
|
250902
|
+
}
|
|
250903
|
+
defaultGraftScope() {
|
|
250904
|
+
if (!this.modelDef || !this.modelMaterializer)
|
|
250905
|
+
return;
|
|
250906
|
+
return {
|
|
250907
|
+
modelDef: this.modelDef,
|
|
250908
|
+
materializer: this.modelMaterializer,
|
|
250909
|
+
cacheScope: "model"
|
|
250910
|
+
};
|
|
250911
|
+
}
|
|
250912
|
+
graftScopeForCell(cellIndex) {
|
|
250913
|
+
const cells = this.runnableNotebookCells;
|
|
250914
|
+
if (!cells)
|
|
250915
|
+
return;
|
|
250916
|
+
for (let i = cellIndex - 1;i >= 0; i--) {
|
|
250917
|
+
const prior = cells[i];
|
|
250918
|
+
if (prior.type === "code" && prior.modelDef && prior.modelMaterializer) {
|
|
250919
|
+
return {
|
|
250920
|
+
modelDef: prior.modelDef,
|
|
250921
|
+
materializer: prior.modelMaterializer,
|
|
250922
|
+
cacheScope: `cell:${i}`
|
|
250923
|
+
};
|
|
250924
|
+
}
|
|
250925
|
+
}
|
|
250926
|
+
return;
|
|
250927
|
+
}
|
|
250928
|
+
selfGraftScopeForCell(cellIndex) {
|
|
250929
|
+
const cell = this.runnableNotebookCells?.[cellIndex];
|
|
250930
|
+
if (!cell?.modelDef || !cell.modelMaterializer)
|
|
250931
|
+
return;
|
|
250932
|
+
return {
|
|
250933
|
+
modelDef: cell.modelDef,
|
|
250934
|
+
materializer: cell.modelMaterializer,
|
|
250935
|
+
cacheScope: `cell-self:${cellIndex}`
|
|
250936
|
+
};
|
|
250937
|
+
}
|
|
250938
|
+
async resolveNotebookCellGraftScope(cellIndex, runnable) {
|
|
250939
|
+
const selfScope = this.selfGraftScopeForCell(cellIndex);
|
|
250940
|
+
const earlierScope = this.graftScopeForCell(cellIndex);
|
|
250941
|
+
if (!earlierScope)
|
|
250942
|
+
return { graftScope: selfScope, usesOwnScope: true };
|
|
250943
|
+
const { struct, modelDef } = await this.resolveRunTargetStruct(runnable);
|
|
250944
|
+
if (struct && modelDef && this.resolveGraftTarget(struct, modelDef, earlierScope.modelDef)) {
|
|
250945
|
+
return { graftScope: earlierScope, usesOwnScope: false };
|
|
250946
|
+
}
|
|
250947
|
+
return { graftScope: selfScope, usesOwnScope: true };
|
|
250948
|
+
}
|
|
250949
|
+
givenDeclaredTypesCache;
|
|
249542
250950
|
givenDeclaredTypes() {
|
|
249543
|
-
|
|
250951
|
+
this.givenDeclaredTypesCache ??= new Map((this.givens ?? []).filter((g) => g.name != null && g.type != null).map((g) => [g.name, g.type]));
|
|
250952
|
+
return this.givenDeclaredTypesCache;
|
|
250953
|
+
}
|
|
250954
|
+
givenDeclaredDefaultsCache;
|
|
250955
|
+
givenDeclaredDefaults() {
|
|
250956
|
+
this.givenDeclaredDefaultsCache ??= new Map((this.givens ?? []).filter((g) => g.name != null && g.default != null).map((g) => [g.name, g.default]));
|
|
250957
|
+
return this.givenDeclaredDefaultsCache;
|
|
249544
250958
|
}
|
|
249545
250959
|
getAuthorize(sourceName) {
|
|
249546
250960
|
return this.sources?.find((source) => source.name === sourceName)?.authorize ?? [];
|
|
@@ -249565,7 +250979,6 @@ var init_model = __esm(() => {
|
|
|
249565
250979
|
names.add(name);
|
|
249566
250980
|
}
|
|
249567
250981
|
};
|
|
249568
|
-
addExprs(this.fileLevelAuthorize);
|
|
249569
250982
|
for (const gates of this.entryPointGatesBySource.values()) {
|
|
249570
250983
|
for (const { exprs } of gates)
|
|
249571
250984
|
addExprs(exprs);
|
|
@@ -249578,7 +250991,7 @@ var init_model = __esm(() => {
|
|
|
249578
250991
|
if (!modelDef)
|
|
249579
250992
|
return byName;
|
|
249580
250993
|
for (const entry of Object.values(modelDef.contents)) {
|
|
249581
|
-
if (!
|
|
250994
|
+
if (!isSourceDef4(entry))
|
|
249582
250995
|
continue;
|
|
249583
250996
|
const name = entry.as ?? entry.name;
|
|
249584
250997
|
byName.set(name, this.collectEntryPointGates(entry, modelDef, new Set, true));
|
|
@@ -249586,27 +250999,68 @@ var init_model = __esm(() => {
|
|
|
249586
250999
|
return byName;
|
|
249587
251000
|
}
|
|
249588
251001
|
hasAuthorize() {
|
|
249589
|
-
return this.
|
|
249590
|
-
}
|
|
249591
|
-
|
|
249592
|
-
|
|
249593
|
-
|
|
249594
|
-
|
|
249595
|
-
|
|
251002
|
+
return this.sources?.some((s) => (s.authorize?.length ?? 0) > 0) ?? false;
|
|
251003
|
+
}
|
|
251004
|
+
anyAuthorizeNote;
|
|
251005
|
+
hasAnyAuthorizeNote() {
|
|
251006
|
+
if (this.anyAuthorizeNote !== undefined)
|
|
251007
|
+
return this.anyAuthorizeNote;
|
|
251008
|
+
this.anyAuthorizeNote = (() => {
|
|
251009
|
+
const modelDef = this.modelDef;
|
|
251010
|
+
if (!modelDef)
|
|
251011
|
+
return false;
|
|
251012
|
+
try {
|
|
251013
|
+
const structs = [];
|
|
251014
|
+
for (const obj of Object.values(modelDef.contents)) {
|
|
251015
|
+
if (isSourceDef4(obj))
|
|
251016
|
+
structs.push(obj);
|
|
251017
|
+
}
|
|
251018
|
+
for (const value of Object.values(modelDef.sourceRegistry ?? {})) {
|
|
251019
|
+
const entry = value.entry;
|
|
251020
|
+
if (entry.type === "source_registry_reference")
|
|
251021
|
+
continue;
|
|
251022
|
+
if (isSourceDef4(entry))
|
|
251023
|
+
structs.push(entry);
|
|
251024
|
+
}
|
|
251025
|
+
structs.push(...derivedStructsReachable(structs, modelDef));
|
|
251026
|
+
for (const struct of structs) {
|
|
251027
|
+
if (containsAuthorizeAnnotationTag(annotationTexts(struct.annotations) ?? [])) {
|
|
251028
|
+
return true;
|
|
251029
|
+
}
|
|
251030
|
+
for (const field of struct.fields) {
|
|
251031
|
+
if (containsAuthorizeAnnotationTag(annotationTexts(field.annotations) ?? [])) {
|
|
251032
|
+
return true;
|
|
251033
|
+
}
|
|
251034
|
+
}
|
|
251035
|
+
}
|
|
251036
|
+
return false;
|
|
251037
|
+
} catch {
|
|
251038
|
+
return true;
|
|
251039
|
+
}
|
|
251040
|
+
})();
|
|
251041
|
+
return this.anyAuthorizeNote;
|
|
249596
251042
|
}
|
|
249597
|
-
async assertAuthorized(sourceName, givens, bypassAuthorize = false) {
|
|
251043
|
+
async assertAuthorized(sourceName, givens, bypassAuthorize = false, graftScope = this.defaultGraftScope()) {
|
|
249598
251044
|
if (bypassAuthorize) {
|
|
249599
251045
|
this.noteAuthorizeBypass("source", sourceName);
|
|
249600
251046
|
return;
|
|
249601
251047
|
}
|
|
249602
251048
|
const gates = sourceName ? this.entryPointGatesBySource.get(sourceName) : undefined;
|
|
249603
251049
|
if (gates) {
|
|
249604
|
-
for (const
|
|
249605
|
-
await this.
|
|
251050
|
+
for (const entry of gates) {
|
|
251051
|
+
const resolution = this.modelDef ? await this.resolveGateShape(entry, this.modelDef, graftScope) : { kind: "given_only" };
|
|
251052
|
+
if (resolution.kind === "row_level")
|
|
251053
|
+
continue;
|
|
251054
|
+
if (resolution.kind === "deny") {
|
|
251055
|
+
recordRowLevelGateDecision("denied_by_gate");
|
|
251056
|
+
if (resolution.cause)
|
|
251057
|
+
recordRowLevelGateRejected(resolution.cause);
|
|
251058
|
+
throw new AccessDeniedError(`Access denied for source "${entry.label}".`);
|
|
251059
|
+
}
|
|
251060
|
+
await this.assertAuthorizedExprs(entry.label, entry.exprs, givens, entry.selfContained);
|
|
249606
251061
|
}
|
|
249607
251062
|
return;
|
|
249608
251063
|
}
|
|
249609
|
-
await this.assertAuthorizedExprs(sourceName ?? "(query)", this.effectiveAuthorizeFor(sourceName), givens);
|
|
249610
251064
|
}
|
|
249611
251065
|
noteAuthorizeBypass(entryPoint, sourceName) {
|
|
249612
251066
|
recordAuthorizeBypass(entryPoint);
|
|
@@ -249617,7 +251071,7 @@ var init_model = __esm(() => {
|
|
|
249617
251071
|
packageName: this.packageName
|
|
249618
251072
|
});
|
|
249619
251073
|
}
|
|
249620
|
-
async assertAuthorizedExprs(label, exprs, givens, selfContainedFirst = false
|
|
251074
|
+
async assertAuthorizedExprs(label, exprs, givens, selfContainedFirst = false) {
|
|
249621
251075
|
if (exprs.length === 0)
|
|
249622
251076
|
return;
|
|
249623
251077
|
const deny = () => {
|
|
@@ -249627,7 +251081,7 @@ var init_model = __esm(() => {
|
|
|
249627
251081
|
deny();
|
|
249628
251082
|
let passed = false;
|
|
249629
251083
|
try {
|
|
249630
|
-
passed = await evaluateAuthorize(this.modelMaterializer, exprs, givens, this.givenDeclaredTypes(), { selfContainedFirst
|
|
251084
|
+
passed = await evaluateAuthorize(this.modelMaterializer, exprs, givens, this.givenDeclaredTypes(), { selfContainedFirst });
|
|
249631
251085
|
} catch (err) {
|
|
249632
251086
|
logger.debug("Authorize probe failed; denying", {
|
|
249633
251087
|
sourceName: label,
|
|
@@ -249640,28 +251094,102 @@ var init_model = __esm(() => {
|
|
|
249640
251094
|
deny();
|
|
249641
251095
|
}
|
|
249642
251096
|
async assertAuthorizedForAllSources(runnable, givens, bypassAuthorize = false) {
|
|
249643
|
-
|
|
249644
|
-
|
|
249645
|
-
|
|
249646
|
-
}
|
|
249647
|
-
const ownSourceName = await this.resolveAuthorizeSourceFromRunnable(runnable);
|
|
249648
|
-
await this.assertAuthorized(ownSourceName, givens);
|
|
249649
|
-
await this.assertAuthorizedFromCompiledRunnable(runnable, givens);
|
|
251097
|
+
await this.authorizeAndBindRunnable(runnable, givens, {
|
|
251098
|
+
bypassAuthorize
|
|
251099
|
+
});
|
|
249650
251100
|
}
|
|
249651
|
-
async
|
|
251101
|
+
async collectAuthorizeEntryPointGates(runnable, givens, graftScope, skipOwnSourceGate = false) {
|
|
251102
|
+
const ownSourceName = await this.resolveAuthorizeSourceFromRunnable(runnable);
|
|
251103
|
+
if (!skipOwnSourceGate) {
|
|
251104
|
+
await this.assertAuthorized(ownSourceName, givens, false, graftScope);
|
|
251105
|
+
}
|
|
249652
251106
|
const { struct, modelDef, compositeResolvedSourceDef } = await this.resolveRunTargetStruct(runnable);
|
|
249653
251107
|
const seen = new Set;
|
|
249654
251108
|
const entryPointGates = this.collectEntryPointGates(struct, modelDef, seen, true);
|
|
249655
251109
|
if (compositeResolvedSourceDef && modelDef) {
|
|
249656
|
-
entryPointGates.push(...this.collectEntryPointGates(compositeResolvedSourceDef, modelDef, seen, true));
|
|
251110
|
+
entryPointGates.push(...this.collectEntryPointGates(compositeResolvedSourceDef, modelDef, seen, true, undefined, struct ? ownLevelNotes(struct.annotations) : []));
|
|
251111
|
+
}
|
|
251112
|
+
return { entryPointGates, modelDef };
|
|
251113
|
+
}
|
|
251114
|
+
async assertAuthorizedFromCompiledRunnable(runnable, givens) {
|
|
251115
|
+
await this.authorizeAndBindRunnable(runnable, givens, {
|
|
251116
|
+
skipOwnSourceGate: true
|
|
251117
|
+
});
|
|
251118
|
+
}
|
|
251119
|
+
queryHadRowLevelFilterAttached(runnable) {
|
|
251120
|
+
return this.rowLevelFilteredRunnables.has(runnable);
|
|
251121
|
+
}
|
|
251122
|
+
async probeEntryPointGates(runnable, givens, graftScope, skipOwnSourceGate = false) {
|
|
251123
|
+
const { entryPointGates, modelDef } = await this.collectAuthorizeEntryPointGates(runnable, givens, graftScope, skipOwnSourceGate);
|
|
251124
|
+
const rowLevel = [];
|
|
251125
|
+
for (const entry of entryPointGates) {
|
|
251126
|
+
const resolution = modelDef ? await this.resolveGateShape(entry, modelDef, graftScope) : { kind: "given_only" };
|
|
251127
|
+
if (resolution.kind === "row_level") {
|
|
251128
|
+
rowLevel.push({
|
|
251129
|
+
label: entry.label,
|
|
251130
|
+
graftTarget: resolution.graftTarget,
|
|
251131
|
+
filterText: resolution.filterText,
|
|
251132
|
+
condition: resolution.condition
|
|
251133
|
+
});
|
|
251134
|
+
continue;
|
|
251135
|
+
}
|
|
251136
|
+
if (resolution.kind === "deny") {
|
|
251137
|
+
recordRowLevelGateDecision("denied_by_gate");
|
|
251138
|
+
if (resolution.cause)
|
|
251139
|
+
recordRowLevelGateRejected(resolution.cause);
|
|
251140
|
+
throw new AccessDeniedError(`Access denied for source "${entry.label}".`);
|
|
251141
|
+
}
|
|
251142
|
+
await this.assertAuthorizedExprs(entry.label, entry.exprs, givens, entry.selfContained);
|
|
251143
|
+
}
|
|
251144
|
+
return rowLevel;
|
|
251145
|
+
}
|
|
251146
|
+
async queryEntryPointHasRowLevelGate(runnable) {
|
|
251147
|
+
try {
|
|
251148
|
+
const { struct, modelDef, compositeResolvedSourceDef } = await this.resolveRunTargetStruct(runnable);
|
|
251149
|
+
if (!modelDef)
|
|
251150
|
+
return true;
|
|
251151
|
+
const seen = new Set;
|
|
251152
|
+
const gates = this.collectEntryPointGates(struct, modelDef, seen, true);
|
|
251153
|
+
if (compositeResolvedSourceDef) {
|
|
251154
|
+
gates.push(...this.collectEntryPointGates(compositeResolvedSourceDef, modelDef, seen, true, undefined, struct ? ownLevelNotes(struct.annotations) : []));
|
|
251155
|
+
}
|
|
251156
|
+
const graftScope = this.defaultGraftScope();
|
|
251157
|
+
for (const entry of gates) {
|
|
251158
|
+
const resolution = await this.resolveGateShape(entry, modelDef, graftScope);
|
|
251159
|
+
if (resolution.kind !== "given_only")
|
|
251160
|
+
return true;
|
|
251161
|
+
}
|
|
251162
|
+
return false;
|
|
251163
|
+
} catch {
|
|
251164
|
+
return true;
|
|
251165
|
+
}
|
|
251166
|
+
}
|
|
251167
|
+
async authorizeAndBindRunnable(runnable, givens, options) {
|
|
251168
|
+
if (options?.bypassAuthorize) {
|
|
251169
|
+
this.noteAuthorizeBypass("runnable", await this.resolveAuthorizeSourceFromRunnable(runnable));
|
|
251170
|
+
return runnable;
|
|
249657
251171
|
}
|
|
249658
|
-
|
|
249659
|
-
|
|
249660
|
-
|
|
249661
|
-
|
|
249662
|
-
|
|
249663
|
-
|
|
249664
|
-
|
|
251172
|
+
const graftScope = options?.graftScope ?? this.defaultGraftScope();
|
|
251173
|
+
const rowLevel = await this.probeEntryPointGates(runnable, givens, graftScope, options?.skipOwnSourceGate ?? false);
|
|
251174
|
+
if (rowLevel.length === 0)
|
|
251175
|
+
return runnable;
|
|
251176
|
+
if (!options?.recompile) {
|
|
251177
|
+
recordRowLevelGateDecision("denied_by_gate");
|
|
251178
|
+
throw new AccessDeniedError(`Access denied for source "${rowLevel[0].label}".`);
|
|
251179
|
+
}
|
|
251180
|
+
try {
|
|
251181
|
+
const graftedMaterializer = this.getOrBuildGraftedMaterializer(rowLevel, graftScope);
|
|
251182
|
+
const recompiled = options.recompile(graftedMaterializer);
|
|
251183
|
+
await this.assertGateLanded(recompiled, rowLevel);
|
|
251184
|
+
this.rowLevelFilteredRunnables.add(recompiled);
|
|
251185
|
+
return recompiled;
|
|
251186
|
+
} catch (err) {
|
|
251187
|
+
recordRowLevelGateDecision("denied_by_gate");
|
|
251188
|
+
logger.debug("Row-level authorize attach failed; denying", {
|
|
251189
|
+
modelPath: this.modelPath,
|
|
251190
|
+
error: err instanceof Error ? err.message : String(err)
|
|
251191
|
+
});
|
|
251192
|
+
throw new AccessDeniedError(`Access denied for source "${rowLevel[0].label}".`);
|
|
249665
251193
|
}
|
|
249666
251194
|
}
|
|
249667
251195
|
async resolveRunTargetStruct(runnable) {
|
|
@@ -249689,107 +251217,233 @@ var init_model = __esm(() => {
|
|
|
249689
251217
|
};
|
|
249690
251218
|
}
|
|
249691
251219
|
}
|
|
249692
|
-
gateExprsForOwnAnnotations(struct, modelDef) {
|
|
249693
|
-
const ownNotes =
|
|
251220
|
+
gateExprsForOwnAnnotations(struct, modelDef, excludeNotes = []) {
|
|
251221
|
+
const ownNotes = ownLevelNotes(struct.annotations).filter((note) => !excludeNotes.includes(note));
|
|
249694
251222
|
try {
|
|
249695
|
-
const own = collectAuthorizeExprs(ownNotes);
|
|
251223
|
+
const own = collectAuthorizeExprs(ownNotes.map((note) => note.text));
|
|
249696
251224
|
if (own.length > 0) {
|
|
249697
|
-
return {
|
|
249698
|
-
exprs: [...this.fileLevelAuthorize, ...own],
|
|
249699
|
-
fromAncestor: false,
|
|
249700
|
-
ambientPrefix: this.fileLevelAuthorize.length
|
|
249701
|
-
};
|
|
251225
|
+
return { exprs: own, fromAncestor: false };
|
|
249702
251226
|
}
|
|
249703
|
-
const ancestor =
|
|
249704
|
-
return {
|
|
249705
|
-
exprs: [...this.fileLevelAuthorize, ...ancestor],
|
|
249706
|
-
fromAncestor: ancestor.length > 0,
|
|
249707
|
-
ambientPrefix: this.fileLevelAuthorize.length
|
|
249708
|
-
};
|
|
251227
|
+
const ancestor = ancestorGateExprs(struct, modelDef);
|
|
251228
|
+
return { exprs: ancestor, fromAncestor: ancestor.length > 0 };
|
|
249709
251229
|
} catch {
|
|
249710
|
-
return { exprs: ["false"], fromAncestor: false
|
|
249711
|
-
}
|
|
249712
|
-
}
|
|
249713
|
-
ancestorGateExprs(struct, modelDef, seen = new Set) {
|
|
249714
|
-
let inherited = struct.annotations?.inherits;
|
|
249715
|
-
for (let depth = 0;inherited && depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
|
|
249716
|
-
const exprs2 = collectAuthorizeExprs(ownLevelNoteTexts(inherited));
|
|
249717
|
-
if (exprs2.length > 0)
|
|
249718
|
-
return exprs2;
|
|
249719
|
-
inherited = inherited.inherits;
|
|
249720
|
-
}
|
|
249721
|
-
if (inherited)
|
|
249722
|
-
return ["false"];
|
|
249723
|
-
seen.add(struct);
|
|
249724
|
-
if (seen.size > ANCESTOR_WALK_MAX_DEPTH)
|
|
249725
|
-
return ["false"];
|
|
249726
|
-
const declared = this.resolveDeclaredSource(struct, modelDef);
|
|
249727
|
-
if (declared.kind === "unresolvable")
|
|
249728
|
-
return ["false"];
|
|
249729
|
-
if (declared.kind === "none" || seen.has(declared.source))
|
|
249730
|
-
return [];
|
|
249731
|
-
const exprs = collectAuthorizeExprs(ownLevelNoteTexts(declared.source.annotations));
|
|
249732
|
-
return exprs.length > 0 ? exprs : this.ancestorGateExprs(declared.source, modelDef, seen);
|
|
249733
|
-
}
|
|
249734
|
-
resolveDeclaredSource(struct, modelDef) {
|
|
249735
|
-
if (!modelDef)
|
|
249736
|
-
return { kind: "none" };
|
|
249737
|
-
let sawBrokenEntry = false;
|
|
249738
|
-
for (const id of [struct.referenceID, struct.sourceID]) {
|
|
249739
|
-
const entry = id ? modelDef.sourceRegistry?.[id]?.entry : undefined;
|
|
249740
|
-
if (!entry)
|
|
249741
|
-
continue;
|
|
249742
|
-
const declared = entry.type === "source_registry_reference" ? modelDef.contents[entry.name] : entry;
|
|
249743
|
-
if (declared === struct)
|
|
249744
|
-
continue;
|
|
249745
|
-
if (!declared || !isSourceDef2(declared)) {
|
|
249746
|
-
sawBrokenEntry = true;
|
|
249747
|
-
continue;
|
|
249748
|
-
}
|
|
249749
|
-
return { kind: "resolved", source: declared };
|
|
251230
|
+
return { exprs: ["false"], fromAncestor: false };
|
|
249750
251231
|
}
|
|
249751
|
-
return sawBrokenEntry ? { kind: "unresolvable" } : { kind: "none" };
|
|
249752
251232
|
}
|
|
249753
|
-
collectEntryPointGates(struct, modelDef, seen = new Set, treatAsOwnGate = false) {
|
|
251233
|
+
collectEntryPointGates(struct, modelDef, seen = new Set, treatAsOwnGate = false, entryPointStruct = struct, excludeNotes = []) {
|
|
249754
251234
|
if (!struct || !modelDef || seen.has(struct))
|
|
249755
251235
|
return [];
|
|
249756
251236
|
seen.add(struct);
|
|
249757
251237
|
const results = [];
|
|
249758
251238
|
const label = struct.as ?? struct.name;
|
|
249759
|
-
const {
|
|
249760
|
-
exprs: ownExprs,
|
|
249761
|
-
fromAncestor,
|
|
249762
|
-
ambientPrefix
|
|
249763
|
-
} = this.gateExprsForOwnAnnotations(struct, modelDef);
|
|
251239
|
+
const { exprs: ownExprs, fromAncestor } = this.gateExprsForOwnAnnotations(struct, modelDef, excludeNotes);
|
|
249764
251240
|
if (ownExprs.length > 0) {
|
|
249765
251241
|
results.push({
|
|
249766
251242
|
label,
|
|
249767
251243
|
exprs: ownExprs,
|
|
249768
251244
|
selfContained: fromAncestor || !treatAsOwnGate,
|
|
249769
|
-
|
|
251245
|
+
struct: entryPointStruct
|
|
249770
251246
|
});
|
|
249771
251247
|
}
|
|
249772
251248
|
const duck = struct;
|
|
249773
251249
|
if (duck.type === "query_source") {
|
|
249774
|
-
const
|
|
249775
|
-
|
|
249776
|
-
|
|
249777
|
-
results.push(...this.collectEntryPointGates(base, modelDef, seen));
|
|
251250
|
+
const base = resolveQuerySourceBase(struct, modelDef);
|
|
251251
|
+
if (base) {
|
|
251252
|
+
results.push(...this.collectEntryPointGates(base, modelDef, seen, false, entryPointStruct));
|
|
249778
251253
|
} else {
|
|
249779
251254
|
results.push({
|
|
249780
251255
|
label,
|
|
249781
251256
|
exprs: ["false"],
|
|
249782
|
-
selfContained: true
|
|
249783
|
-
ambientPrefix: 0
|
|
251257
|
+
selfContained: true
|
|
249784
251258
|
});
|
|
249785
251259
|
}
|
|
249786
251260
|
const resolved = duck.query?.compositeResolvedSourceDef;
|
|
249787
251261
|
if (resolved) {
|
|
249788
|
-
results.push(...this.collectEntryPointGates(resolved, modelDef, seen));
|
|
251262
|
+
results.push(...this.collectEntryPointGates(resolved, modelDef, seen, false, entryPointStruct, base ? ownLevelNotes(base.annotations) : []));
|
|
249789
251263
|
}
|
|
249790
251264
|
}
|
|
249791
251265
|
return results;
|
|
249792
251266
|
}
|
|
251267
|
+
async resolveGateShape(entry, originModelDef, graftScope) {
|
|
251268
|
+
if (!entry.struct)
|
|
251269
|
+
return { kind: "given_only" };
|
|
251270
|
+
if (!graftScope) {
|
|
251271
|
+
logger.debug("Row-level gate has no graft scope to attach to (no scope was available at all, not the gate's own condition); denying", { modelPath: this.modelPath, label: entry.label });
|
|
251272
|
+
return { kind: "deny" };
|
|
251273
|
+
}
|
|
251274
|
+
const graftTarget = this.resolveGraftTarget(entry.struct, originModelDef, graftScope.modelDef);
|
|
251275
|
+
if (!graftTarget) {
|
|
251276
|
+
return this.classifyWithoutGraft(entry, graftScope.materializer);
|
|
251277
|
+
}
|
|
251278
|
+
const filterText = gateFilterText(entry.exprs);
|
|
251279
|
+
const cacheKey = `${graftScope.cacheScope}\x00${graftTarget}\x00${filterText}`;
|
|
251280
|
+
let cached2 = this.gateShapeCache.get(cacheKey);
|
|
251281
|
+
if (!cached2) {
|
|
251282
|
+
let condition;
|
|
251283
|
+
try {
|
|
251284
|
+
condition = await this.liftGateCondition(graftTarget, filterText, graftScope.materializer);
|
|
251285
|
+
} catch (err) {
|
|
251286
|
+
logger.debug("Row-level gate condition failed to lift; checking whether it is given-only before denying", {
|
|
251287
|
+
modelPath: this.modelPath,
|
|
251288
|
+
graftTarget,
|
|
251289
|
+
error: err instanceof Error ? err.message : String(err)
|
|
251290
|
+
});
|
|
251291
|
+
return this.classifyWithoutGraft(entry, graftScope.materializer);
|
|
251292
|
+
}
|
|
251293
|
+
const classification = classifyAuthorizeGate(condition, this.givenDeclaredTypes(), this.givenDeclaredDefaults());
|
|
251294
|
+
cached2 = { classification, condition };
|
|
251295
|
+
this.gateShapeCache.set(cacheKey, cached2);
|
|
251296
|
+
}
|
|
251297
|
+
if (cached2.classification.shape === "given_only") {
|
|
251298
|
+
return { kind: "given_only" };
|
|
251299
|
+
}
|
|
251300
|
+
if (cached2.classification.shape === "rejected") {
|
|
251301
|
+
return { kind: "deny", cause: cached2.classification.cause };
|
|
251302
|
+
}
|
|
251303
|
+
return {
|
|
251304
|
+
kind: "row_level",
|
|
251305
|
+
graftTarget,
|
|
251306
|
+
filterText,
|
|
251307
|
+
condition: cached2.condition
|
|
251308
|
+
};
|
|
251309
|
+
}
|
|
251310
|
+
async classifyWithoutGraft(entry, materializer) {
|
|
251311
|
+
try {
|
|
251312
|
+
await materializer.loadQuery(buildAuthorizeProbe(entry.exprs)).getPreparedQuery();
|
|
251313
|
+
return { kind: "given_only" };
|
|
251314
|
+
} catch (err) {
|
|
251315
|
+
logger.debug("Row-level gate has no attachable graft and does not compile as given-only; denying", {
|
|
251316
|
+
modelPath: this.modelPath,
|
|
251317
|
+
label: entry.label,
|
|
251318
|
+
error: err instanceof Error ? err.message : String(err)
|
|
251319
|
+
});
|
|
251320
|
+
return { kind: "deny" };
|
|
251321
|
+
}
|
|
251322
|
+
}
|
|
251323
|
+
resolveGraftTarget(struct, originModelDef, graftModelDef) {
|
|
251324
|
+
const direct = this.findContentsKey(struct, graftModelDef);
|
|
251325
|
+
if (direct)
|
|
251326
|
+
return direct;
|
|
251327
|
+
let current = struct;
|
|
251328
|
+
const seen = new Set([struct]);
|
|
251329
|
+
for (let depth = 0;depth < ANCESTOR_WALK_MAX_DEPTH; depth++) {
|
|
251330
|
+
const declared = resolveDeclaredSource(current, originModelDef);
|
|
251331
|
+
let next;
|
|
251332
|
+
if (declared.kind === "resolved" && !seen.has(declared.source)) {
|
|
251333
|
+
next = declared.source;
|
|
251334
|
+
} else if (declared.kind === "none") {
|
|
251335
|
+
next = this.findSourceByOwnAnnotationIdentity(current, graftModelDef, seen);
|
|
251336
|
+
}
|
|
251337
|
+
if (!next)
|
|
251338
|
+
return;
|
|
251339
|
+
const key = this.findContentsKey(next, graftModelDef);
|
|
251340
|
+
if (key)
|
|
251341
|
+
return key;
|
|
251342
|
+
seen.add(next);
|
|
251343
|
+
current = next;
|
|
251344
|
+
}
|
|
251345
|
+
return;
|
|
251346
|
+
}
|
|
251347
|
+
findContentsKey(struct, modelDef) {
|
|
251348
|
+
for (const [key, value] of Object.entries(modelDef.contents)) {
|
|
251349
|
+
if (value === struct)
|
|
251350
|
+
return key;
|
|
251351
|
+
}
|
|
251352
|
+
if (struct.sourceID) {
|
|
251353
|
+
for (const [key, value] of Object.entries(modelDef.contents)) {
|
|
251354
|
+
if (isSourceDef4(value) && value.sourceID === struct.sourceID) {
|
|
251355
|
+
return key;
|
|
251356
|
+
}
|
|
251357
|
+
}
|
|
251358
|
+
}
|
|
251359
|
+
return;
|
|
251360
|
+
}
|
|
251361
|
+
findSourceByOwnAnnotationIdentity(struct, modelDef, exclude) {
|
|
251362
|
+
const ownNotes = [
|
|
251363
|
+
...struct.annotations?.blockNotes ?? [],
|
|
251364
|
+
...struct.annotations?.notes ?? []
|
|
251365
|
+
];
|
|
251366
|
+
if (ownNotes.length === 0)
|
|
251367
|
+
return;
|
|
251368
|
+
for (const value of Object.values(modelDef.contents)) {
|
|
251369
|
+
if (!isSourceDef4(value) || value === struct || exclude.has(value)) {
|
|
251370
|
+
continue;
|
|
251371
|
+
}
|
|
251372
|
+
const candidateNotes = [
|
|
251373
|
+
...value.annotations?.blockNotes ?? [],
|
|
251374
|
+
...value.annotations?.notes ?? []
|
|
251375
|
+
];
|
|
251376
|
+
if (candidateNotes.some((note) => ownNotes.includes(note))) {
|
|
251377
|
+
return value;
|
|
251378
|
+
}
|
|
251379
|
+
}
|
|
251380
|
+
return;
|
|
251381
|
+
}
|
|
251382
|
+
async liftGateCondition(graftTarget, filterText, materializer) {
|
|
251383
|
+
const probe = materializer.loadQuery(buildRowLevelProbe(graftTarget, filterText));
|
|
251384
|
+
const prepared = await probe.getPreparedQuery();
|
|
251385
|
+
return liftProbeFilterCondition(prepared, `lifted probe for "${graftTarget}"`, filterText);
|
|
251386
|
+
}
|
|
251387
|
+
getOrBuildGraftedMaterializer(grafts, graftScope) {
|
|
251388
|
+
const key = `${graftScope.cacheScope}\x00` + grafts.map((g) => `${g.graftTarget}\x00${g.filterText}`).sort().join("\x01");
|
|
251389
|
+
const materializer = this.graftedMaterializerCache.get(key);
|
|
251390
|
+
if (materializer) {
|
|
251391
|
+
this.graftedMaterializerCache.delete(key);
|
|
251392
|
+
this.graftedMaterializerCache.set(key, materializer);
|
|
251393
|
+
return materializer;
|
|
251394
|
+
}
|
|
251395
|
+
const built = this.buildGraftedMaterializer(grafts, graftScope.modelDef);
|
|
251396
|
+
this.graftedMaterializerCache.set(key, built);
|
|
251397
|
+
while (this.graftedMaterializerCache.size > Model.GRAFTED_MATERIALIZER_CACHE_MAX) {
|
|
251398
|
+
const oldest = this.graftedMaterializerCache.keys().next();
|
|
251399
|
+
if (oldest.done)
|
|
251400
|
+
break;
|
|
251401
|
+
this.graftedMaterializerCache.delete(oldest.value);
|
|
251402
|
+
}
|
|
251403
|
+
return built;
|
|
251404
|
+
}
|
|
251405
|
+
buildGraftedMaterializer(grafts, modelDef) {
|
|
251406
|
+
if (!this.gateRuntime) {
|
|
251407
|
+
throw new Error("no retained runtime to graft a row-level gate through");
|
|
251408
|
+
}
|
|
251409
|
+
const copy = structuredClone(modelDef);
|
|
251410
|
+
for (const { graftTarget, condition } of grafts) {
|
|
251411
|
+
const target = copy.contents[graftTarget];
|
|
251412
|
+
if (!target || !isSourceDef4(target)) {
|
|
251413
|
+
throw new Error(`graft target "${graftTarget}" is not a source in this model`);
|
|
251414
|
+
}
|
|
251415
|
+
target.filterList = [...target.filterList ?? [], condition];
|
|
251416
|
+
}
|
|
251417
|
+
return this.gateRuntime._loadModelFromModelDef(copy);
|
|
251418
|
+
}
|
|
251419
|
+
async assertGateLanded(recompiled, grafts) {
|
|
251420
|
+
const prepared = await recompiled.getPreparedQuery();
|
|
251421
|
+
const modelDef = prepared._modelDef ?? this.modelDef;
|
|
251422
|
+
const structRef = prepared._query?.structRef;
|
|
251423
|
+
const resolvedRef = typeof structRef === "string" ? modelDef?.contents[structRef] : structRef;
|
|
251424
|
+
const struct = resolvedRef && typeof resolvedRef === "object" ? resolvedRef : undefined;
|
|
251425
|
+
for (const { condition } of grafts) {
|
|
251426
|
+
if (!condition.code || !this.filterListContainsCode(struct, modelDef, condition.code, 0)) {
|
|
251427
|
+
throw new Error("a row-level gate condition did not land on the recompiled query");
|
|
251428
|
+
}
|
|
251429
|
+
}
|
|
251430
|
+
}
|
|
251431
|
+
static MAX_GATE_PROOF_DEPTH = 8;
|
|
251432
|
+
filterListContainsCode(struct, modelDef, code, depth) {
|
|
251433
|
+
if (!struct || depth > Model.MAX_GATE_PROOF_DEPTH)
|
|
251434
|
+
return false;
|
|
251435
|
+
if (struct.filterList?.some((f) => f.code === code))
|
|
251436
|
+
return true;
|
|
251437
|
+
const duck = struct;
|
|
251438
|
+
if (duck.type === "query_source" && modelDef) {
|
|
251439
|
+
const ref = duck.query?.structRef;
|
|
251440
|
+
const base = typeof ref === "string" ? modelDef.contents[ref] : ref;
|
|
251441
|
+
if (base && isSourceDef4(base)) {
|
|
251442
|
+
return this.filterListContainsCode(base, modelDef, code, depth + 1);
|
|
251443
|
+
}
|
|
251444
|
+
}
|
|
251445
|
+
return false;
|
|
251446
|
+
}
|
|
249793
251447
|
async assertAuthorizedForText(text, givens) {
|
|
249794
251448
|
await this.assertAuthorized(extractRunTargetSourceName(text), givens);
|
|
249795
251449
|
}
|
|
@@ -249842,8 +251496,22 @@ var init_model = __esm(() => {
|
|
|
249842
251496
|
const sourceResult = Model.getSources(modelDef, givens);
|
|
249843
251497
|
sources = sourceResult.sources;
|
|
249844
251498
|
filterMap = sourceResult.filterMap;
|
|
249845
|
-
|
|
249846
|
-
|
|
251499
|
+
const queryResult = Model.getQueries(modelDef);
|
|
251500
|
+
queries = queryResult.queries;
|
|
251501
|
+
assertNoMisplacedAuthorizeAnnotations([
|
|
251502
|
+
...sourceResult.misplacedAuthorize,
|
|
251503
|
+
...queryResult.misplacedAuthorize
|
|
251504
|
+
]);
|
|
251505
|
+
const declaredGivenTypes = new Map((givens ?? []).filter((g) => g.name != null && g.type != null).map((g) => [g.name, g.type]));
|
|
251506
|
+
const declaredGivenDefaults = new Map((givens ?? []).filter((g) => g.name != null && g.default != null).map((g) => [g.name, g.default]));
|
|
251507
|
+
await validateAuthorizeProbes(modelMaterializer, {
|
|
251508
|
+
authorizeMap: sourceResult.authorizeMap,
|
|
251509
|
+
declaredTypes: declaredGivenTypes,
|
|
251510
|
+
declaredDefaults: declaredGivenDefaults,
|
|
251511
|
+
authorizeOwnNotes: sourceResult.authorizeOwnNotes,
|
|
251512
|
+
onRowLevelGateRejected: recordRowLevelGateRejected,
|
|
251513
|
+
onRowLevelGateUnexpressible: (sourceName, detail) => logger.warn("Row-level #(authorize) gate not expressible at this entry point; every query against it will be denied", { packageName, modelPath, sourceName, detail })
|
|
251514
|
+
});
|
|
249847
251515
|
const imports = modelDef.imports || [];
|
|
249848
251516
|
const importedSourceNames = new Set;
|
|
249849
251517
|
for (const importLocation of imports) {
|
|
@@ -249873,7 +251541,9 @@ var init_model = __esm(() => {
|
|
|
249873
251541
|
}
|
|
249874
251542
|
}
|
|
249875
251543
|
}
|
|
249876
|
-
|
|
251544
|
+
const model = new Model(packageName, modelPath, dataStyles, modelType, modelMaterializer, modelDef, sources, queries, sourceInfos.length > 0 ? sourceInfos : undefined, runnableNotebookCells, undefined, filterMap, givens);
|
|
251545
|
+
model.setGateRuntime(runtime);
|
|
251546
|
+
return model;
|
|
249877
251547
|
} catch (error) {
|
|
249878
251548
|
let computedError = error;
|
|
249879
251549
|
if (error instanceof Error && error.stack) {
|
|
@@ -249898,6 +251568,9 @@ var init_model = __esm(() => {
|
|
|
249898
251568
|
const sourceInfos = data.sourceInfos;
|
|
249899
251569
|
const givens = data.givens;
|
|
249900
251570
|
const filterMap = data.filterMap ? new Map(data.filterMap) : undefined;
|
|
251571
|
+
for (const warning of data.authorizeWarnings ?? []) {
|
|
251572
|
+
logger.warn(warning, { packageName, modelPath: data.modelPath });
|
|
251573
|
+
}
|
|
249901
251574
|
if (!modelDef) {
|
|
249902
251575
|
return new Model(packageName, data.modelPath, dataStyles, data.modelType, undefined, undefined, sources, queries, sourceInfos, data.modelType === "notebook" ? hydrateMarkdownOnlyCells(data.notebookCells) : undefined, undefined, filterMap, givens, modelInfo);
|
|
249903
251576
|
}
|
|
@@ -249905,6 +251578,7 @@ var init_model = __esm(() => {
|
|
|
249905
251578
|
const modelMaterializer = runtime._loadModelFromModelDef(modelDef);
|
|
249906
251579
|
const runnableNotebookCells = data.modelType === "notebook" ? hydrateNotebookCells(runtime, data.notebookCells) : undefined;
|
|
249907
251580
|
const model = new Model(packageName, data.modelPath, dataStyles, data.modelType, modelMaterializer, modelDef, sources, queries, sourceInfos, runnableNotebookCells, undefined, filterMap, givens, modelInfo);
|
|
251581
|
+
model.setGateRuntime(runtime);
|
|
249908
251582
|
return model;
|
|
249909
251583
|
}
|
|
249910
251584
|
static fromCompilationError(packageName, modelPath, modelType, error) {
|
|
@@ -249979,6 +251653,11 @@ var init_model = __esm(() => {
|
|
|
249979
251653
|
getQueries() {
|
|
249980
251654
|
return this.curateForDiscovery(this.queries);
|
|
249981
251655
|
}
|
|
251656
|
+
getDashboardModelFacts() {
|
|
251657
|
+
if (!this.modelDef)
|
|
251658
|
+
return;
|
|
251659
|
+
return readDashboardModelFacts(this.modelPath, this.modelDef, (this.givens ?? []).map((given) => given.name).filter((name) => name !== undefined));
|
|
251660
|
+
}
|
|
249982
251661
|
hasEmptyDiscoverySurface() {
|
|
249983
251662
|
if (!this.discoveryCurationEnabled)
|
|
249984
251663
|
return false;
|
|
@@ -250117,7 +251796,7 @@ var init_model = __esm(() => {
|
|
|
250117
251796
|
}
|
|
250118
251797
|
targets.push({
|
|
250119
251798
|
label: query.name,
|
|
250120
|
-
queryString: `run: ${
|
|
251799
|
+
queryString: `run: ${quoteMalloyIdentifier2(query.name)}`
|
|
250121
251800
|
});
|
|
250122
251801
|
}
|
|
250123
251802
|
for (const source of this.sources ?? []) {
|
|
@@ -250127,7 +251806,7 @@ var init_model = __esm(() => {
|
|
|
250127
251806
|
}
|
|
250128
251807
|
targets.push({
|
|
250129
251808
|
label: `${source.name} -> ${view.name}`,
|
|
250130
|
-
queryString: `run: ${
|
|
251809
|
+
queryString: `run: ${quoteMalloyIdentifier2(source.name)} -> ${quoteMalloyIdentifier2(view.name)}`
|
|
250131
251810
|
});
|
|
250132
251811
|
}
|
|
250133
251812
|
}
|
|
@@ -250164,9 +251843,37 @@ var init_model = __esm(() => {
|
|
|
250164
251843
|
throw new ModelNotFoundError(`${this.modelPath} is not a valid model name. Model files must end in .malloy.`);
|
|
250165
251844
|
}
|
|
250166
251845
|
}
|
|
250167
|
-
|
|
251846
|
+
getCompilationError() {
|
|
250168
251847
|
return this.compilationError;
|
|
250169
251848
|
}
|
|
251849
|
+
getNotebookError() {
|
|
251850
|
+
return this.getCompilationError();
|
|
251851
|
+
}
|
|
251852
|
+
getNotebookListing() {
|
|
251853
|
+
if (this.modelType !== "notebook")
|
|
251854
|
+
return {};
|
|
251855
|
+
const annotations = this.modelDef ? ownModelNotes(this.modelDef) : [];
|
|
251856
|
+
const doc = docCommentTitleAndDescription(annotations, tagText(motlyTag(annotations), "title"));
|
|
251857
|
+
return {
|
|
251858
|
+
title: doc.title ?? this.firstMarkdownHeading(),
|
|
251859
|
+
description: doc.description
|
|
251860
|
+
};
|
|
251861
|
+
}
|
|
251862
|
+
firstMarkdownHeading() {
|
|
251863
|
+
for (const cell of this.runnableNotebookCells ?? []) {
|
|
251864
|
+
if (cell.type !== "markdown")
|
|
251865
|
+
continue;
|
|
251866
|
+
for (const line of cell.text.split(`
|
|
251867
|
+
`)) {
|
|
251868
|
+
const trimmed2 = line.trim();
|
|
251869
|
+
if (trimmed2.length === 0)
|
|
251870
|
+
continue;
|
|
251871
|
+
const heading = /^#{1,6}\s+(.+)$/.exec(trimmed2);
|
|
251872
|
+
return heading ? heading[1] : undefined;
|
|
251873
|
+
}
|
|
251874
|
+
}
|
|
251875
|
+
return;
|
|
251876
|
+
}
|
|
250170
251877
|
async getNotebook() {
|
|
250171
251878
|
if (this.compilationError) {
|
|
250172
251879
|
throw this.compilationError;
|
|
@@ -250307,6 +252014,7 @@ var init_model = __esm(() => {
|
|
|
250307
252014
|
}
|
|
250308
252015
|
let runnable;
|
|
250309
252016
|
let liveRunnable;
|
|
252017
|
+
let queryString;
|
|
250310
252018
|
let serveVirtualMap;
|
|
250311
252019
|
let serveShapeBindings = [];
|
|
250312
252020
|
let servedFrom;
|
|
@@ -250337,13 +252045,12 @@ var init_model = __esm(() => {
|
|
|
250337
252045
|
throw err;
|
|
250338
252046
|
}
|
|
250339
252047
|
}
|
|
250340
|
-
let queryString;
|
|
250341
252048
|
if (!sourceName && !queryName && query) {
|
|
250342
252049
|
queryString = `
|
|
250343
252050
|
` + query;
|
|
250344
252051
|
} else if (queryName && !query) {
|
|
250345
252052
|
queryString = `
|
|
250346
|
-
run: ${sourceName ? `${
|
|
252053
|
+
run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMalloyIdentifier2(queryName)}`;
|
|
250347
252054
|
} else {
|
|
250348
252055
|
const endTime = performance.now();
|
|
250349
252056
|
const executionTime2 = endTime - startTime;
|
|
@@ -250368,7 +252075,10 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250368
252075
|
}
|
|
250369
252076
|
runnable = this.modelMaterializer.loadRestrictedQuery(queryString);
|
|
250370
252077
|
liveRunnable = runnable;
|
|
250371
|
-
|
|
252078
|
+
const storageRoutingPossible = getPersistStorageMode() === "on" && this.serveBindings.length > 0 && !!this.serveDestinationConfig;
|
|
252079
|
+
const preaggServeMaterializer = this.preaggregateServeMaterializer;
|
|
252080
|
+
const routingBlockedByRowLevelGate = (storageRoutingPossible || !!preaggServeMaterializer) && !bypassAuthorize && this.hasAnyAuthorizeNote() && await this.queryEntryPointHasRowLevelGate(runnable);
|
|
252081
|
+
if (storageRoutingPossible && !routingBlockedByRowLevelGate) {
|
|
250372
252082
|
try {
|
|
250373
252083
|
const shaped = await this.loadServeShapeQuery(queryString);
|
|
250374
252084
|
runnable = shaped.runnable;
|
|
@@ -250388,9 +252098,9 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250388
252098
|
});
|
|
250389
252099
|
}
|
|
250390
252100
|
}
|
|
250391
|
-
if (
|
|
252101
|
+
if (preaggServeMaterializer && !routingBlockedByRowLevelGate && !serveVirtualMap) {
|
|
250392
252102
|
try {
|
|
250393
|
-
const candidate =
|
|
252103
|
+
const candidate = preaggServeMaterializer.loadRestrictedQuery(queryString);
|
|
250394
252104
|
await candidate.getSQL({
|
|
250395
252105
|
givens: querySurfaceGivens,
|
|
250396
252106
|
buildManifest
|
|
@@ -250430,7 +252140,10 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250430
252140
|
if (boundary === "deferred") {
|
|
250431
252141
|
this.assertQueryBoundaryCompiled(compiledSource, query);
|
|
250432
252142
|
}
|
|
250433
|
-
await this.
|
|
252143
|
+
runnable = await this.authorizeAndBindRunnable(runnable, givens ?? {}, {
|
|
252144
|
+
recompile: (mm) => mm.loadRestrictedQuery(queryString),
|
|
252145
|
+
bypassAuthorize
|
|
252146
|
+
});
|
|
250434
252147
|
const maxRows = getMaxQueryRows();
|
|
250435
252148
|
const maxBytes = getMaxResponseBytes();
|
|
250436
252149
|
const effectiveBuildManifest = serveVirtualMap ? undefined : preaggRouted ? buildManifest : liveBuildManifest;
|
|
@@ -250530,6 +252243,9 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250530
252243
|
servedFrom = "live_fallback";
|
|
250531
252244
|
executionTime = performance.now() - startTime;
|
|
250532
252245
|
}
|
|
252246
|
+
if (this.queryHadRowLevelFilterAttached(runnable) && queryResults.totalRows === 0) {
|
|
252247
|
+
recordRowLevelGateDecision("empty_after_filter");
|
|
252248
|
+
}
|
|
250533
252249
|
assertWithinModelRowLimit(queryResults.totalRows, maxRows, "model_query");
|
|
250534
252250
|
const wrappedResult = API.util.wrapResult(queryResults);
|
|
250535
252251
|
const serializedResult = stringifyQueryResponse(responseShape === "compact" ? queryResults.data.value : wrappedResult, queryResults.totalRows, maxBytes, "model_query", responseShape === "compact" ? bigIntReplacer : undefined);
|
|
@@ -250616,7 +252332,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250616
252332
|
if (!this.modelDef)
|
|
250617
252333
|
return;
|
|
250618
252334
|
try {
|
|
250619
|
-
return new
|
|
252335
|
+
return new Annotations5(ownModelAnnotations(this.modelDef)).parseAsTag().tag;
|
|
250620
252336
|
} catch {
|
|
250621
252337
|
return;
|
|
250622
252338
|
}
|
|
@@ -250626,12 +252342,12 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250626
252342
|
return;
|
|
250627
252343
|
try {
|
|
250628
252344
|
const entry = this.modelDef.contents?.[sourceName];
|
|
250629
|
-
if (!entry || !
|
|
252345
|
+
if (!entry || !isSourceDef4(entry))
|
|
250630
252346
|
return;
|
|
250631
252347
|
const def = entry;
|
|
250632
252348
|
if (!def.annotations)
|
|
250633
252349
|
return;
|
|
250634
|
-
return new
|
|
252350
|
+
return new Annotations5(def.annotations).parseAsTag("@").tag;
|
|
250635
252351
|
} catch {
|
|
250636
252352
|
return;
|
|
250637
252353
|
}
|
|
@@ -250664,6 +252380,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250664
252380
|
};
|
|
250665
252381
|
});
|
|
250666
252382
|
const allAnnotations = this.modelDef ? ownModelNotes(this.modelDef) : [];
|
|
252383
|
+
const notebookTag = motlyTag(allAnnotations);
|
|
250667
252384
|
const notebook = {
|
|
250668
252385
|
type: "notebook",
|
|
250669
252386
|
packageName: this.packageName,
|
|
@@ -250673,6 +252390,8 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250673
252390
|
sources: this.modelDef && this.sources,
|
|
250674
252391
|
queries: this.modelDef && this.queries,
|
|
250675
252392
|
annotations: allAnnotations,
|
|
252393
|
+
autorun: readAutorun(notebookTag),
|
|
252394
|
+
startingGivens: readStartingGivens(notebookTag, (name) => (this.givens ?? []).find((g) => g.name === name)?.type),
|
|
250676
252395
|
notebookCells
|
|
250677
252396
|
};
|
|
250678
252397
|
return notebook;
|
|
@@ -250694,26 +252413,35 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250694
252413
|
text: cell.text
|
|
250695
252414
|
};
|
|
250696
252415
|
}
|
|
250697
|
-
if (cell.runnable) {
|
|
250698
|
-
await this.assertAuthorizedForAllSources(cell.runnable, givens ?? {});
|
|
250699
|
-
}
|
|
250700
252416
|
let queryName = undefined;
|
|
250701
252417
|
let queryResult = undefined;
|
|
250702
252418
|
if (cell.runnable) {
|
|
250703
252419
|
try {
|
|
250704
252420
|
let runnableToExecute = cell.runnable;
|
|
250705
|
-
|
|
250706
|
-
|
|
250707
|
-
|
|
250708
|
-
|
|
250709
|
-
|
|
250710
|
-
|
|
250711
|
-
|
|
250712
|
-
|
|
250713
|
-
|
|
250714
|
-
|
|
250715
|
-
|
|
250716
|
-
|
|
252421
|
+
let textToExecute = cell.text;
|
|
252422
|
+
const { graftScope, usesOwnScope } = await this.resolveNotebookCellGraftScope(cellIndex, cell.runnable);
|
|
252423
|
+
if (!graftScope) {
|
|
252424
|
+
logger.debug("Notebook cell has no graft scope to attach a row-level gate against (no earlier code cell, and this cell has no compiled model of its own)", { modelPath: this.modelPath, cellIndex });
|
|
252425
|
+
}
|
|
252426
|
+
const effectiveSource = !bypassFilters && cell.modelMaterializer ? extractRunTargetSourceName(cell.text) : undefined;
|
|
252427
|
+
const cellFilters = effectiveSource ? this.getFilters(effectiveSource) : [];
|
|
252428
|
+
if (cell.modelMaterializer && cellFilters.length > 0) {
|
|
252429
|
+
await this.probeEntryPointGates(cell.runnable, givens ?? {}, graftScope);
|
|
252430
|
+
}
|
|
252431
|
+
const filterClause = cellFilters.length > 0 ? buildFilterClause(cellFilters, filterParams ?? {}) : undefined;
|
|
252432
|
+
if (filterClause && cell.modelMaterializer) {
|
|
252433
|
+
textToExecute = injectFilterRefinement(cell.text, filterClause);
|
|
252434
|
+
runnableToExecute = cell.modelMaterializer.loadQuery(textToExecute);
|
|
252435
|
+
}
|
|
252436
|
+
if (cell.modelMaterializer) {
|
|
252437
|
+
const textForRecompile = textToExecute;
|
|
252438
|
+
const queryDefForOwnScopeRepoint = usesOwnScope ? (await runnableToExecute.getPreparedQuery())._query : undefined;
|
|
252439
|
+
runnableToExecute = await this.authorizeAndBindRunnable(runnableToExecute, givens ?? {}, {
|
|
252440
|
+
recompile: usesOwnScope ? (mm) => mm._loadQueryFromQueryDef(queryDefForOwnScopeRepoint) : (mm) => mm.loadQuery(textForRecompile),
|
|
252441
|
+
graftScope
|
|
252442
|
+
});
|
|
252443
|
+
} else {
|
|
252444
|
+
await this.assertAuthorizedForAllSources(runnableToExecute, givens ?? {});
|
|
250717
252445
|
}
|
|
250718
252446
|
const cellMaxRows = getMaxQueryRows();
|
|
250719
252447
|
const cellMaxBytes = getMaxResponseBytes();
|
|
@@ -250737,6 +252465,9 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250737
252465
|
});
|
|
250738
252466
|
const query = (await runnableToExecute.getPreparedQuery())._query;
|
|
250739
252467
|
queryName = query.as || query.name;
|
|
252468
|
+
if (result?._queryResult && result.totalRows === 0 && this.queryHadRowLevelFilterAttached(runnableToExecute)) {
|
|
252469
|
+
recordRowLevelGateDecision("empty_after_filter");
|
|
252470
|
+
}
|
|
250740
252471
|
if (result?._queryResult) {
|
|
250741
252472
|
assertWithinModelRowLimit(result.totalRows, cellMaxRows, "notebook_cell");
|
|
250742
252473
|
}
|
|
@@ -250745,6 +252476,9 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250745
252476
|
assertWithinModelByteLimit(queryResult, cellMaxBytes, "notebook_cell");
|
|
250746
252477
|
}
|
|
250747
252478
|
} catch (error) {
|
|
252479
|
+
if (error instanceof AccessDeniedError) {
|
|
252480
|
+
throw error;
|
|
252481
|
+
}
|
|
250748
252482
|
if (error instanceof FilterValidationError) {
|
|
250749
252483
|
throw new BadRequestError(error.message);
|
|
250750
252484
|
}
|
|
@@ -250824,14 +252558,23 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250824
252558
|
return malloyConfig;
|
|
250825
252559
|
}
|
|
250826
252560
|
static getQueries(modelDef) {
|
|
250827
|
-
|
|
252561
|
+
const { queries, misplacedAuthorize } = extractQueriesFromModelDef(modelDef);
|
|
252562
|
+
return { queries, misplacedAuthorize };
|
|
250828
252563
|
}
|
|
250829
252564
|
static getSources(modelDef, givens) {
|
|
250830
|
-
const {
|
|
252565
|
+
const {
|
|
252566
|
+
sources,
|
|
252567
|
+
filterMap,
|
|
252568
|
+
authorizeMap,
|
|
252569
|
+
misplacedAuthorize,
|
|
252570
|
+
authorizeOwnNotes
|
|
252571
|
+
} = extractSourcesFromModelDef(modelDef, givens, (sourceName, err) => logger.warn(`Failed to parse filter annotations on source "${sourceName}"`, { error: err }));
|
|
250831
252572
|
return {
|
|
250832
252573
|
sources,
|
|
250833
252574
|
filterMap,
|
|
250834
|
-
|
|
252575
|
+
authorizeMap,
|
|
252576
|
+
misplacedAuthorize,
|
|
252577
|
+
authorizeOwnNotes
|
|
250835
252578
|
};
|
|
250836
252579
|
}
|
|
250837
252580
|
static async getModelMaterializer(runtime, importBaseURL, modelURL, modelPath) {
|
|
@@ -250932,6 +252675,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier(sourceName)} -> ` : ""}${quoteMallo
|
|
|
250932
252675
|
text: stmt.text,
|
|
250933
252676
|
runnable,
|
|
250934
252677
|
modelMaterializer: localMM,
|
|
252678
|
+
modelDef: currentModelDef,
|
|
250935
252679
|
newSources,
|
|
250936
252680
|
queryInfo
|
|
250937
252681
|
};
|
|
@@ -253058,7 +254802,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
253058
254802
|
return defaultZone2;
|
|
253059
254803
|
} else if (input instanceof Zone) {
|
|
253060
254804
|
return input;
|
|
253061
|
-
} else if (
|
|
254805
|
+
} else if (isString3(input)) {
|
|
253062
254806
|
const lowered = input.toLowerCase();
|
|
253063
254807
|
if (lowered === "default")
|
|
253064
254808
|
return defaultZone2;
|
|
@@ -253433,7 +255177,7 @@ var require_luxon = __commonJS((exports) => {
|
|
|
253433
255177
|
function isInteger(o) {
|
|
253434
255178
|
return typeof o === "number" && o % 1 === 0;
|
|
253435
255179
|
}
|
|
253436
|
-
function
|
|
255180
|
+
function isString3(o) {
|
|
253437
255181
|
return typeof o === "string";
|
|
253438
255182
|
}
|
|
253439
255183
|
function isDate2(o) {
|
|
@@ -273330,6 +275074,30 @@ class ConnectionController {
|
|
|
273330
275074
|
}
|
|
273331
275075
|
}
|
|
273332
275076
|
|
|
275077
|
+
// src/controller/dashboard.controller.ts
|
|
275078
|
+
init_errors();
|
|
275079
|
+
|
|
275080
|
+
class DashboardController {
|
|
275081
|
+
environmentStore;
|
|
275082
|
+
constructor(environmentStore) {
|
|
275083
|
+
this.environmentStore = environmentStore;
|
|
275084
|
+
}
|
|
275085
|
+
async listDashboards(environmentName, packageName) {
|
|
275086
|
+
const environment = await this.environmentStore.getEnvironment(environmentName, false);
|
|
275087
|
+
const p = await environment.getPackage(packageName, false);
|
|
275088
|
+
return p.listDashboards();
|
|
275089
|
+
}
|
|
275090
|
+
async getDashboard(environmentName, packageName, dashboardName) {
|
|
275091
|
+
const environment = await this.environmentStore.getEnvironment(environmentName, false);
|
|
275092
|
+
const p = await environment.getPackage(packageName, false);
|
|
275093
|
+
const dashboard = p.getDashboard(dashboardName);
|
|
275094
|
+
if (!dashboard) {
|
|
275095
|
+
throw new DashboardNotFoundError(`Dashboard ${dashboardName} does not exist in package ${packageName}`);
|
|
275096
|
+
}
|
|
275097
|
+
return dashboard;
|
|
275098
|
+
}
|
|
275099
|
+
}
|
|
275100
|
+
|
|
273333
275101
|
// src/controller/database.controller.ts
|
|
273334
275102
|
class DatabaseController {
|
|
273335
275103
|
environmentStore;
|
|
@@ -273546,6 +275314,8 @@ init_errors();
|
|
|
273546
275314
|
init_logger();
|
|
273547
275315
|
init_query_metadata();
|
|
273548
275316
|
var import_render_validator = __toESM(require_dist11(), 1);
|
|
275317
|
+
init_dashboard();
|
|
275318
|
+
|
|
273549
275319
|
class QueryController {
|
|
273550
275320
|
environmentStore;
|
|
273551
275321
|
constructor(environmentStore) {
|
|
@@ -273596,7 +275366,7 @@ class QueryController {
|
|
|
273596
275366
|
}
|
|
273597
275367
|
}
|
|
273598
275368
|
}, compactJson ? "compact" : "full", bypassAuthorize), getQueryTimeoutMs());
|
|
273599
|
-
const renderLogs = import_render_validator.validateRenderTags(result);
|
|
275369
|
+
const renderLogs = filterPublisherOwnedRenderLogs(import_render_validator.validateRenderTags(result));
|
|
273600
275370
|
return {
|
|
273601
275371
|
result: serializedResult,
|
|
273602
275372
|
resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}/models/${modelPath}/query`,
|
|
@@ -282257,6 +284027,7 @@ class CronEvaluator {
|
|
|
282257
284027
|
}
|
|
282258
284028
|
|
|
282259
284029
|
// src/service/package.ts
|
|
284030
|
+
init_dashboard();
|
|
282260
284031
|
init_quoting();
|
|
282261
284032
|
init_model();
|
|
282262
284033
|
|
|
@@ -282324,6 +284095,8 @@ class Package {
|
|
|
282324
284095
|
sourceEligibility = undefined;
|
|
282325
284096
|
incrementalPolicySources = [];
|
|
282326
284097
|
renderTagWarnings = [];
|
|
284098
|
+
dashboardWarnings = [];
|
|
284099
|
+
dashboards = new Map;
|
|
282327
284100
|
manifestWarnings = [];
|
|
282328
284101
|
static meter = publisherMeter();
|
|
282329
284102
|
static packageLoadHistogram = this.meter.createHistogram("malloy_package_load_duration", {
|
|
@@ -282530,6 +284303,7 @@ class Package {
|
|
|
282530
284303
|
});
|
|
282531
284304
|
const pkg = new Package(environmentName, packageName, packagePath, packageConfig, databases, models, malloyConfig);
|
|
282532
284305
|
pkg.renderTagWarnings = renderTagWarnings;
|
|
284306
|
+
await pkg.discoverDashboards();
|
|
282533
284307
|
pkg.manifestWarnings = outcome.packageMetadata.manifestWarnings ?? [];
|
|
282534
284308
|
pkg.wireFreshnessResolvers();
|
|
282535
284309
|
try {
|
|
@@ -282668,6 +284442,7 @@ class Package {
|
|
|
282668
284442
|
}
|
|
282669
284443
|
const allWarnings = [
|
|
282670
284444
|
...this.renderTagWarnings,
|
|
284445
|
+
...this.dashboardWarnings,
|
|
282671
284446
|
...this.storageWarnings(),
|
|
282672
284447
|
...this.droppedPersistWarnings(),
|
|
282673
284448
|
...this.emptyDiscoveryWarnings(),
|
|
@@ -283079,6 +284854,7 @@ class Package {
|
|
|
283079
284854
|
this.packageMetadata.manifestLocation = outcome.packageMetadata.manifestLocation ?? null;
|
|
283080
284855
|
this.applyDiscoveryPolicyToModels();
|
|
283081
284856
|
this.applyQueryBoundaryToModels();
|
|
284857
|
+
await this.discoverDashboards();
|
|
283082
284858
|
this.recordManifestBinding(entries);
|
|
283083
284859
|
this.wireFreshnessResolvers();
|
|
283084
284860
|
const invalidMsg = this.formatInvalidExplores();
|
|
@@ -283119,15 +284895,271 @@ class Package {
|
|
|
283119
284895
|
}));
|
|
283120
284896
|
return values;
|
|
283121
284897
|
}
|
|
284898
|
+
isQueryableEntryPoint(modelPath) {
|
|
284899
|
+
if (!this.queryBoundaryActive())
|
|
284900
|
+
return true;
|
|
284901
|
+
const exploreSet = this.exploreSet();
|
|
284902
|
+
return exploreSet ? exploreSet.has(modelPath) : true;
|
|
284903
|
+
}
|
|
284904
|
+
servableEntryPoints() {
|
|
284905
|
+
if (!this.queryBoundaryActive())
|
|
284906
|
+
return () => true;
|
|
284907
|
+
const exploreSet = this.exploreSet();
|
|
284908
|
+
return (modelPath) => exploreSet ? exploreSet.has(modelPath) : true;
|
|
284909
|
+
}
|
|
284910
|
+
queryBoundaryActive() {
|
|
284911
|
+
if (this.packageMetadata.queryableSources === "all")
|
|
284912
|
+
return false;
|
|
284913
|
+
return this.exploresDeclared();
|
|
284914
|
+
}
|
|
284915
|
+
async claimsToBeADashboard(modelPath) {
|
|
284916
|
+
try {
|
|
284917
|
+
const source = await fs7.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
|
|
284918
|
+
return /^[ \t]*##?[ \t]*artifact\b/m.test(source);
|
|
284919
|
+
} catch {
|
|
284920
|
+
return false;
|
|
284921
|
+
}
|
|
284922
|
+
}
|
|
284923
|
+
async discoverDashboards() {
|
|
284924
|
+
const discovered = new Map;
|
|
284925
|
+
const factsByPath = new Map;
|
|
284926
|
+
const allFacts = new Map;
|
|
284927
|
+
const heldBack = [];
|
|
284928
|
+
const unconventionalSlugs = [];
|
|
284929
|
+
const droppedByError = [];
|
|
284930
|
+
const dashboardSlugs = new Set;
|
|
284931
|
+
for (const modelPath of Array.from(this.models.keys()).sort()) {
|
|
284932
|
+
const model = this.models.get(modelPath);
|
|
284933
|
+
if (!model)
|
|
284934
|
+
continue;
|
|
284935
|
+
let facts;
|
|
284936
|
+
try {
|
|
284937
|
+
facts = model.getDashboardModelFacts();
|
|
284938
|
+
if (facts)
|
|
284939
|
+
allFacts.set(modelPath, facts);
|
|
284940
|
+
} catch (err) {
|
|
284941
|
+
logger.warn("Reading a model's dashboard facts failed", {
|
|
284942
|
+
packageName: this.packageName,
|
|
284943
|
+
modelPath,
|
|
284944
|
+
error: errMessage(err)
|
|
284945
|
+
});
|
|
284946
|
+
}
|
|
284947
|
+
if (!isDashboardModelPath(modelPath))
|
|
284948
|
+
continue;
|
|
284949
|
+
const name = dashboardSlug(modelPath);
|
|
284950
|
+
let manifest;
|
|
284951
|
+
if (facts) {
|
|
284952
|
+
try {
|
|
284953
|
+
manifest = buildDashboardManifest(facts);
|
|
284954
|
+
} catch (err) {
|
|
284955
|
+
logger.warn("Dashboard discovery failed", {
|
|
284956
|
+
packageName: this.packageName,
|
|
284957
|
+
modelPath,
|
|
284958
|
+
error: errMessage(err)
|
|
284959
|
+
});
|
|
284960
|
+
if (factsCarryArtifactTag(facts)) {
|
|
284961
|
+
droppedByError.push({ modelPath, name });
|
|
284962
|
+
dashboardSlugs.add(name);
|
|
284963
|
+
}
|
|
284964
|
+
continue;
|
|
284965
|
+
}
|
|
284966
|
+
if (!manifest) {
|
|
284967
|
+
factsByPath.set(modelPath, facts);
|
|
284968
|
+
continue;
|
|
284969
|
+
}
|
|
284970
|
+
} else {
|
|
284971
|
+
const error = model.getCompilationError();
|
|
284972
|
+
if (!error || !await this.claimsToBeADashboard(modelPath)) {
|
|
284973
|
+
if (!error && await this.claimsToBeADashboard(modelPath)) {
|
|
284974
|
+
logger.warn("Dashboard file produced no facts and no error", {
|
|
284975
|
+
packageName: this.packageName,
|
|
284976
|
+
modelPath
|
|
284977
|
+
});
|
|
284978
|
+
droppedByError.push({ modelPath, name });
|
|
284979
|
+
dashboardSlugs.add(name);
|
|
284980
|
+
}
|
|
284981
|
+
continue;
|
|
284982
|
+
}
|
|
284983
|
+
manifest = {
|
|
284984
|
+
name,
|
|
284985
|
+
title: name,
|
|
284986
|
+
autorun: true,
|
|
284987
|
+
entryFile: modelPath,
|
|
284988
|
+
givens: [],
|
|
284989
|
+
error: error.message
|
|
284990
|
+
};
|
|
284991
|
+
}
|
|
284992
|
+
dashboardSlugs.add(name);
|
|
284993
|
+
if (!this.isQueryableEntryPoint(modelPath)) {
|
|
284994
|
+
heldBack.push({ modelPath, name });
|
|
284995
|
+
continue;
|
|
284996
|
+
}
|
|
284997
|
+
if (!matchesDocumentedDashboardName(name)) {
|
|
284998
|
+
unconventionalSlugs.push({ modelPath, name });
|
|
284999
|
+
}
|
|
285000
|
+
discovered.set(name, manifest);
|
|
285001
|
+
if (facts)
|
|
285002
|
+
factsByPath.set(modelPath, facts);
|
|
285003
|
+
}
|
|
285004
|
+
this.dashboards = discovered;
|
|
285005
|
+
this.dashboardWarnings = await this.lintDashboards(factsByPath, allFacts, heldBack, unconventionalSlugs, dashboardSlugs, droppedByError);
|
|
285006
|
+
for (const warning of this.dashboardWarnings) {
|
|
285007
|
+
logger.warn("Dashboard lint", {
|
|
285008
|
+
packageName: this.packageName,
|
|
285009
|
+
model: warning.model,
|
|
285010
|
+
detail: warning.message
|
|
285011
|
+
});
|
|
285012
|
+
}
|
|
285013
|
+
}
|
|
285014
|
+
async lintDashboards(factsByPath, allFacts, heldBack, unconventionalSlugs, knownSlugs, droppedByError) {
|
|
285015
|
+
const warnings = [];
|
|
285016
|
+
const withheldDrills = new Map;
|
|
285017
|
+
try {
|
|
285018
|
+
for (const { modelPath, name } of droppedByError) {
|
|
285019
|
+
warnings.push({
|
|
285020
|
+
model: modelPath,
|
|
285021
|
+
subject: name,
|
|
285022
|
+
message: `"${modelPath}" carries an artifact tag but could not be ` + `read, so it is not served and there is no dashboard ` + `"${name}". The cause is in the server log. Reload the ` + `package to try again.`,
|
|
285023
|
+
severity: "error"
|
|
285024
|
+
});
|
|
285025
|
+
}
|
|
285026
|
+
for (const { modelPath, name } of unconventionalSlugs) {
|
|
285027
|
+
warnings.push({
|
|
285028
|
+
model: modelPath,
|
|
285029
|
+
subject: name,
|
|
285030
|
+
message: `is served, but "${name}" is outside the conventional ` + `dashboard name shape (letters, digits, "-" and "_"). This ` + `server routes it and its published URL carries the name ` + `encoded, so the dashboard works. The convention is worth ` + `keeping anyway: a name outside it has to be percent-encoded ` + `by every caller that builds the URL by hand. Rename the ` + `file if that matters to yours.`,
|
|
285031
|
+
severity: "warn"
|
|
285032
|
+
});
|
|
285033
|
+
}
|
|
285034
|
+
for (const { modelPath, name } of heldBack) {
|
|
285035
|
+
for (const file of allFacts.values()) {
|
|
285036
|
+
for (const drill of file.drills) {
|
|
285037
|
+
if (!drill.to.includes(name))
|
|
285038
|
+
continue;
|
|
285039
|
+
const where = `${drill.source}.${drill.dimension}`;
|
|
285040
|
+
withheldDrills.set(`${where}|${name}`, {
|
|
285041
|
+
subject: where,
|
|
285042
|
+
message: `# drill on ${where} targets "${name}", which IS a ` + `dashboard in this package but is not served (see the ` + `finding on "${modelPath}"), so the click has nowhere ` + `to land.`,
|
|
285043
|
+
severity: "error"
|
|
285044
|
+
});
|
|
285045
|
+
}
|
|
285046
|
+
}
|
|
285047
|
+
warnings.push({
|
|
285048
|
+
model: modelPath,
|
|
285049
|
+
subject: name,
|
|
285050
|
+
message: `is a dashboard, but "${modelPath}" is not listed in ` + `'explores' and this package sets ` + `queryableSources: "declared", so its query would be ` + `refused. It is not served. Add it to 'explores', or set ` + `queryableSources: "all" to keep the curated surface for ` + `discovery only. Listing it is not always sufficient on its ` + `own: the queryable sources are the union of every listed ` + `file's export closure, so a tile reading a source that only ` + `an UNLISTED file exports is still refused. List that file ` + `too, or re-export the source from one already listed.`,
|
|
285051
|
+
severity: "warn"
|
|
285052
|
+
});
|
|
285053
|
+
}
|
|
285054
|
+
for (const [modelPath, facts] of factsByPath) {
|
|
285055
|
+
const manifest = this.dashboards.get(dashboardSlug(modelPath));
|
|
285056
|
+
const findings = manifest ? lintDashboard(facts, manifest) : lintUndiscoveredDashboard(facts);
|
|
285057
|
+
for (const finding of findings) {
|
|
285058
|
+
warnings.push({ model: modelPath, ...finding });
|
|
285059
|
+
}
|
|
285060
|
+
}
|
|
285061
|
+
const drillFacts = Array.from(allFacts.values());
|
|
285062
|
+
for (const finding of withheldDrills.values()) {
|
|
285063
|
+
warnings.push(finding);
|
|
285064
|
+
}
|
|
285065
|
+
for (const finding of lintDrillTargets(drillFacts, knownSlugs)) {
|
|
285066
|
+
warnings.push(finding);
|
|
285067
|
+
}
|
|
285068
|
+
for (const finding of lintSelfDrills(drillFacts)) {
|
|
285069
|
+
warnings.push(finding);
|
|
285070
|
+
}
|
|
285071
|
+
for (const finding of lintGivenTags(drillFacts)) {
|
|
285072
|
+
warnings.push(finding);
|
|
285073
|
+
}
|
|
285074
|
+
for (const finding of await this.unsupportedComponentWarnings()) {
|
|
285075
|
+
warnings.push(finding);
|
|
285076
|
+
}
|
|
285077
|
+
} catch (err) {
|
|
285078
|
+
logger.warn("Dashboard lint failed", {
|
|
285079
|
+
packageName: this.packageName,
|
|
285080
|
+
error: errMessage(err)
|
|
285081
|
+
});
|
|
285082
|
+
warnings.push({
|
|
285083
|
+
message: `Dashboard lint stopped early, so this list is incomplete: an ` + `unknown subset of the curation, dashboard, drill, given and ` + `unsupported-component checks did not run. Treat a missing ` + `finding of any of those kinds as unknown rather than clean, ` + `including the absence of a "held back from the listing" ` + `finding. Reload the package to run the lint again. The ` + `dashboards themselves are unaffected, because they are ` + `discovered before the lint runs. An operator can find the ` + `cause in the server log under "Dashboard lint failed".`,
|
|
285084
|
+
severity: "warn"
|
|
285085
|
+
});
|
|
285086
|
+
}
|
|
285087
|
+
return warnings;
|
|
285088
|
+
}
|
|
285089
|
+
async unsupportedComponentWarnings() {
|
|
285090
|
+
const dir = safeJoinUnderRoot(this.packagePath, DASHBOARDS_DIR);
|
|
285091
|
+
let entries;
|
|
285092
|
+
try {
|
|
285093
|
+
entries = await fs7.readdir(dir);
|
|
285094
|
+
} catch {
|
|
285095
|
+
return [];
|
|
285096
|
+
}
|
|
285097
|
+
const warnings = [];
|
|
285098
|
+
for (const entry of entries.sort()) {
|
|
285099
|
+
const suffix = COMPONENT_FILE_SUFFIXES.find((s) => entry.endsWith(s));
|
|
285100
|
+
if (!suffix)
|
|
285101
|
+
continue;
|
|
285102
|
+
const base = entry.slice(0, -suffix.length);
|
|
285103
|
+
warnings.push({
|
|
285104
|
+
model: `${DASHBOARDS_DIR}/${entry}`,
|
|
285105
|
+
subject: base,
|
|
285106
|
+
message: `Custom dashboard components are not supported, so ` + `"${DASHBOARDS_DIR}/${entry}" is ignored. Any ` + `${DASHBOARDS_DIR}/${base}${MODEL_FILE_SUFFIX} beside it still ` + `renders from its tags. For a page that runs its own code, use ` + `an HTML data app in the package's public/ directory.`,
|
|
285107
|
+
severity: "warn"
|
|
285108
|
+
});
|
|
285109
|
+
}
|
|
285110
|
+
return warnings;
|
|
285111
|
+
}
|
|
285112
|
+
listDashboards() {
|
|
285113
|
+
const servable = this.servableEntryPoints();
|
|
285114
|
+
return Array.from(this.dashboards.values()).filter((manifest) => servable(manifest.entryFile)).map((manifest) => ({
|
|
285115
|
+
resource: this.dashboardResource(manifest.name),
|
|
285116
|
+
packageName: this.packageName,
|
|
285117
|
+
name: manifest.name,
|
|
285118
|
+
path: manifest.entryFile,
|
|
285119
|
+
title: manifest.title,
|
|
285120
|
+
description: manifest.description,
|
|
285121
|
+
error: manifest.error
|
|
285122
|
+
}));
|
|
285123
|
+
}
|
|
285124
|
+
getDashboard(name) {
|
|
285125
|
+
const manifest = this.dashboards.get(name);
|
|
285126
|
+
if (manifest && !this.isQueryableEntryPoint(manifest.entryFile)) {
|
|
285127
|
+
return;
|
|
285128
|
+
}
|
|
285129
|
+
if (!manifest)
|
|
285130
|
+
return;
|
|
285131
|
+
return {
|
|
285132
|
+
resource: this.dashboardResource(manifest.name),
|
|
285133
|
+
packageName: this.packageName,
|
|
285134
|
+
name: manifest.name,
|
|
285135
|
+
path: manifest.entryFile,
|
|
285136
|
+
title: manifest.title,
|
|
285137
|
+
description: manifest.description,
|
|
285138
|
+
error: manifest.error,
|
|
285139
|
+
query: manifest.query,
|
|
285140
|
+
tiles: manifest.tiles,
|
|
285141
|
+
dashboardColumns: manifest.dashboardColumns,
|
|
285142
|
+
startingGivens: manifest.startingGivens,
|
|
285143
|
+
autorun: manifest.autorun,
|
|
285144
|
+
givens: manifest.givens
|
|
285145
|
+
};
|
|
285146
|
+
}
|
|
285147
|
+
dashboardResource(name) {
|
|
285148
|
+
return `${API_PREFIX}/environments/${this.environmentName}/packages/${this.packageName}/dashboards/${encodeURIComponent(name)}`;
|
|
285149
|
+
}
|
|
283122
285150
|
async listNotebooks() {
|
|
283123
285151
|
return await Promise.all(Array.from(this.models.keys()).filter((modelPath) => {
|
|
283124
285152
|
return modelPath.endsWith(NOTEBOOK_FILE_SUFFIX);
|
|
283125
285153
|
}).map(async (modelPath) => {
|
|
283126
|
-
const
|
|
285154
|
+
const model = this.models.get(modelPath);
|
|
285155
|
+
const error = model?.getNotebookError();
|
|
285156
|
+
const listing = model?.getNotebookListing() ?? {};
|
|
283127
285157
|
return {
|
|
283128
285158
|
environmentName: this.environmentName,
|
|
283129
285159
|
packageName: this.packageName,
|
|
283130
285160
|
path: modelPath,
|
|
285161
|
+
title: listing.title,
|
|
285162
|
+
description: listing.description,
|
|
283131
285163
|
error: error?.message
|
|
283132
285164
|
};
|
|
283133
285165
|
}));
|
|
@@ -289151,6 +291183,7 @@ function registerDocsSearchTool(mcpServer, _environmentStore) {
|
|
|
289151
291183
|
// src/mcp/tools/execute_query_tool.ts
|
|
289152
291184
|
init_config();
|
|
289153
291185
|
init_logger();
|
|
291186
|
+
init_dashboard();
|
|
289154
291187
|
|
|
289155
291188
|
// src/mcp/query_envelope.ts
|
|
289156
291189
|
init_json_utils();
|
|
@@ -289329,7 +291362,7 @@ function registerExecuteQueryTool(mcpServer, environmentStore) {
|
|
|
289329
291362
|
queryCorrelationId
|
|
289330
291363
|
} = await runWithQueryTimeout((abortSignal) => query ? model.getQueryResults(undefined, undefined, query, filterParams, undefined, givens, abortSignal, queryMetadataInput, "compact") : model.getQueryResults(sourceName, queryName, undefined, filterParams, undefined, givens, abortSignal, queryMetadataInput, "compact"), getQueryTimeoutMs());
|
|
289331
291364
|
const { validateRenderTags: validateRenderTags2 } = await Promise.resolve().then(() => __toESM(require_dist11(), 1));
|
|
289332
|
-
const renderLogs = validateRenderTags2(result);
|
|
291365
|
+
const renderLogs = filterPublisherOwnedRenderLogs(validateRenderTags2(result));
|
|
289333
291366
|
const resultUri = buildMalloyUri({
|
|
289334
291367
|
environment: environmentName,
|
|
289335
291368
|
package: packageName,
|
|
@@ -290441,6 +292474,7 @@ Every skill in this deployment, by what it is for. Start at a driver; it routes
|
|
|
290441
292474
|
| Skill | Use when... |
|
|
290442
292475
|
|-------|-------------|
|
|
290443
292476
|
| \`skill:malloy-publish\` | Moving a finished model into a served package (local-to-served handoff) |
|
|
292477
|
+
| \`skill:malloy-dashboards\` | Building a dashboard: a tagged \`.malloy\` file in a package's \`dashboards/\` directory, with filter controls and drill-through |
|
|
290444
292478
|
| \`skill:malloy-html-data-apps\` | Building an in-package HTML data app (a \`public/\` directory the package serves) |
|
|
290445
292479
|
| \`skill:malloy-html-data-app-runtime\` | Writing the JavaScript that drives that app |
|
|
290446
292480
|
| \`skill:malloy-html-data-app-embedding\` | Embedding a served page into a host application |
|
|
@@ -290792,7 +292826,408 @@ Use \`+\` to modify existing views: \`run: source -> my_view + { limit: 15, wher
|
|
|
290792
292826
|
|
|
290793
292827
|
Step complete. Output: analysis \`.malloy\` file with views, insights, and reusable building blocks. For chart/renderer details, see \`skill:malloy-gotchas-rendering\` or call \`search_malloy_docs\`. To formalize into a model, hand off to the modeling skill (\`skill:malloy-model\`).
|
|
290794
292828
|
|
|
290795
|
-
Publishing is out of scope for now: open-source Publisher serves the model from disk, and self-hosters publish via git plus their host's publish path.` }, { name: "malloy-charts", description: 'Chart selection guidance and renderer reference for Malloy views. Use when choosing visualization types, adding chart annotations, user asks "what chart should I use", "how should I visualize this", or when deciding between bar_chart, line_chart, scatter_chart, etc.', body: '# Chart Selection for Malloy\n\n> Malloy uses Vega-Lite under the hood. `#` tags control visualization. Call `search_malloy_docs` with topic "rendering" for the full tag reference (or see https://docs.malloydata.dev/documentation/visualizations/overview).\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n## Decision Tree: Which Chart?\n\n| Data Shape | Default Choice |\n|-----------|---------------|\n| Aggregates only (no group_by) | `# big_value` |\n| 1 time column + 1 measure | `# line_chart` |\n| 1 category + 1 measure | `# bar_chart` |\n| 2 numeric columns | `# scatter_chart` |\n| Geographic (US states) + 1 measure | `# shape_map` |\n| Route data (lat/lon pairs) | `# segment_map` |\n| Multiple perspectives | `# dashboard` with `nest:` |\n| Nested query to pivot | `# pivot` |\n| Filtered aggregates side-by-side | `# flatten` |\n| Detailed rows | Default table (no annotation) |\n\n| Goal | Renderer |\n|------|---------|\n| Compare categories | `# bar_chart` (sort by value, limit ~15) |\n| Show composition | `# bar_chart.stack` |\n| Trend over time | `# line_chart` |\n| Highlight KPIs | `# big_value` with `# label` |\n| Correlation | `# scatter_chart` |\n| Compare dimensions | `# dashboard` (nest chart views) |\n| Before/after | `# transpose` or `# pivot` |\n| Multiple metrics per category | Default table, `# flatten`, or `y=[\'a\',\'b\']` |\n\n**Constraints:**\n- ONE aggregate per chart view (charts render only the first; use `y=[\'a\',\'b\']` for multi-measure)\n- No fixed scale on measure definitions: use `# currency` not `# currency=usd0m`\n- One tag per line\n- Alias joined fields in `group_by` before `order_by`\n- Define measures in source, not in views\n\n\n## Chart Types\n\n### `# bar_chart`\n\n**Data shape:** `group_by` = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# bar_chart\nview: by_carrier is { group_by: carrier, aggregate: flight_count, order_by: flight_count desc, limit: 10 }\n\n# bar_chart.stack\nview: by_region is { group_by: category, region, aggregate: revenue }\n\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: category, aggregate: revenue, cost }\n```\n\n**Key properties:** `.stack`, `.size` (spark/xs/sm/md/lg/xl/2xl), `.x`, `.x.limit`, `.y` (supports `y=[\'a\',\'b\']`), `.series`, `.series.limit` (default 20), `.title`, `.subtitle`, `.x.independent`, `.y.independent`\n\n**Field role tags:** `# x`, `# y`, `# series` on individual fields to assign roles explicitly.\n\n### `# line_chart`\n\n**Data shape:** `group_by` (temporal/numeric) = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# line_chart\nview: trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n\n# line_chart { size=spark }\nview: mini_trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n```\n\n**Key properties:** `.zero_baseline`, `.interpolate` (e.g., `step`), `.size`, `.y` (supports `y=[\'a\',\'b\']`), `.series.limit` (default 12), `.title`, `.subtitle`\n\n### `# scatter_chart`\n\n**Data shape:** Fields by position: x, y, color, size (bubble), shape.\n\n```malloy\n# scatter_chart\nview: correlation is { group_by: customer_id, aggregate: avg_price, total_quantity }\n```\n\n### `# shape_map`\n\nChoropleth. US states only. Fields: state name, value.\n\n```malloy\n# shape_map\nview: by_state is { group_by: state, aggregate: revenue }\n```\n\n### `# segment_map`\n\nRoute map. US only. Fields: start_lat, start_lon, end_lat, end_lon, color.\n\n\n## Layout Types\n\n### `# big_value`\n\nKPI cards. Aggregates only, no `group_by`.\n\n```malloy\n# big_value\nview: summary is {\n aggregate:\n # label="Revenue"\n # currency\n revenue\n # label="Orders"\n # number=auto\n order_count\n}\n```\n\n**Properties:** `.size`, `.sparkline=<nested_view_name>`, `.comparison_field`, `.comparison_label`, `.down_is_good`\n\n### `# dashboard`\n\nCard-based multi-tile layout. Apply to a view whose body is a nested query; the view\'s own fields lay out automatically:\n\n- `group_by` dimensions -> a row header (repeats once per row; omit for a single block)\n- `aggregate` measures -> KPI cards, one per measure\n- each `nest:` -> a tile, rendered by the tag above it (`# table` default, or `# bar_chart` / `# line_chart` / `# big_value`)\n\n**Two modes.** Flex (default): tiles flow and wrap; `# break` forces a new row. Columns: `# dashboard { columns=N }` lays tiles into N equal columns, `# colspan=n` widens a tile, `# break` starts a new row, overflow wraps.\n\n```malloy\n// Flex: measures become KPI cards, the nest becomes a tile\n# dashboard\nview: overview is {\n group_by: category\n # currency\n aggregate:\n avg_retail is retail_price.avg()\n sum_retail is retail_price.sum()\n nest:\n # bar_chart\n by_brand is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 10 }\n}\n\n// Columns: # colspan widens tiles, # break ends a row\n# dashboard { columns=12 }\nview: layout is {\n group_by: category\n # currency\n aggregate:\n # colspan=4\n avg_retail is retail_price.avg()\n # colspan=4\n sum_retail is retail_price.sum()\n # colspan=4\n max_retail is retail_price.max()\n nest:\n # break\n # colspan=6\n # bar_chart\n # subtitle="Top brands"\n by_brand_chart is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 8 }\n # colspan=6\n by_brand_table is { group_by: brand, aggregate: product_count is count(), limit: 8 }\n}\n```\n\n**Tags:** `# dashboard { columns=N }` (columns mode), `{ gap=PX }` (tile spacing, default 16; never a mode), `{ table.max_height=PX|none }` (cap table tiles). On a measure or nest: `# colspan=N` (columns mode only), `# break` (both modes), `# subtitle="..."` (tile), `# borderless` (drop card chrome), `# label="..."` (card title).\n\nFor rich KPI cards (sparklines, comparison deltas, several metrics on one card) nest a `# big_value` view instead of relying on the dashboard\'s own measures:\n\n```malloy\n# dashboard\nview: kpis is {\n group_by: category\n nest:\n # big_value\n revenue_card is {\n aggregate:\n # label="Revenue"\n # currency\n # big_value { sparkline=trend }\n total_revenue is retail_price.sum()\n # line_chart { size=spark y.independent=true }\n # hidden\n nest: trend is { group_by: bucket is floor(id / 100)::number, aggregate: total_revenue is retail_price.sum(), order_by: bucket, limit: 20 }\n }\n}\n```\n\n**Rules:** `# dashboard` needs a nested-query view (no effect on a scalar). `# colspan` works only in columns mode and is ignored (warns) in flex. `columns` is any positive integer; a `# colspan` over the column count clamps to a full row. Style tiles via the instance theme, or theme the views inside with `# theme.*` (see Theming below).\n\n### `# pivot`\n\nPivot nested results into columns. Max 30 pivot columns.\n\n```malloy\nview: sales is {\n group_by: product, aggregate: total\n nest: # pivot\n by_quarter is { group_by: quarter, aggregate: revenue }\n}\n```\n\n### `# transpose`\n\nSwap rows/columns. Good for period comparisons.\n\n```malloy\n# transpose\nview: comparison is {\n aggregate:\n # label="This Month"\n current_revenue\n # label="Last Month"\n prior_revenue\n}\n```\n\n### `# list` / `# list_detail`\n\nList renders as comma-separated values. List_detail shows `value (detail)` pairs.\n\n### `# flatten`\n\nCollapse nested record into parent table as columns. Use for side-by-side filtered aggregates:\n\n```malloy\nview: segments is {\n group_by: product, aggregate: total_revenue\n nest: # flatten\n enterprise is { where: segment = \'Enterprise\', aggregate: # label="Enterprise" revenue }\n nest: # flatten\n smb is { where: segment = \'SMB\', aggregate: # label="SMB" revenue }\n}\n```\n\n### `# table`\n\nDefault (implicit). Use explicitly for `.size=fill` property.\n\n\n## Field Formatting Tags\n\n| Tag | Use For | Shorthand |\n|-----|---------|-----------|\n| `# number` | Numeric formatting | `=auto` (K/M/B), `=id` (no commas), `=1k`, `=1m` |\n| `# percent` | Percentages | (none needed) |\n| `# currency` | Money | `=usd2m` (USD, 2 decimals, millions); scale only in views |\n| `# duration` | Time durations | `=seconds`, `=minutes`, `=hours`, `=days` |\n| `# data_volume` | Storage sizes | `=bytes`, `=kb`, `=mb`, `=gb` |\n| `# link` | Hyperlinks | `.url_template="https://example.com/$$"` |\n| `# image` | Inline images | `.height=40px`, `.width=100px` |\n\n**Currency codes:** `usd` ($), `eur`, `gbp`. **Scale:** K/M/B/T/Q or `auto`.\n**Number suffix styles:** `word` ("42.5 million"), `letter` ("42.5M"), `scientific`.\n\n## Utility Tags\n\n| Tag | Purpose |\n|-----|---------|\n| `# hidden` | Hide from output (still usable for sorting/references) |\n| `# label="..."` | Override display name |\n| `# description="..."` | Tooltip text |\n| `# tooltip` | Include nested view in chart tooltip |\n| `# break` | Force new dashboard row |\n| `# column { width=sm }` | Table column width |\n\n## Model-Level Defaults\n\n```malloy\n## viz.line_chart.defaults.y.independent=true\n## viz.bar_chart.defaults.stack\n```\n\n## Theming\n\nPublisher styles charts and tables from one structured theme. The instance sets it (in `publisher.config.json`\'s `theme` block or the **Settings, then Theme** editor); a model overrides it per result with `# theme.*` annotations, or model-wide with `## theme.*`. Per-chart annotations use the same nested `palette.*` / `font.*` vocabulary as the config, not flat key names, and they win over the instance theme for the keys they set. The forms:\n\n| Annotation | Controls | Modes |\n|-----------|----------|-------|\n| `# theme.palette.series` | Categorical series colors (array) | shared |\n| `# theme.palette.background.{light,dark}` | Chart canvas + table background | per-mode |\n| `# theme.palette.tableHeader.{light,dark}` | Table header text color | per-mode |\n| `# theme.palette.tableHeaderBackground.{light,dark}` | Table header row background | per-mode |\n| `# theme.palette.tableBody.{light,dark}` | Table body text color | per-mode |\n| `# theme.palette.tile.{light,dark}` | Dashboard tile background | per-mode |\n| `# theme.palette.tileTitle.{light,dark}` | Dashboard tile title color | per-mode |\n| `# theme.palette.mapColor.{light,dark}` | Choropleth gradient (`# shape_map` / `# segment_map`) | per-mode |\n| `# theme.font.family` | Font for all rendered text | shared |\n| `# theme.font.size` | Table font size (px) | shared |\n\nThe seven `palette.*` color keys each take a `.light` and/or `.dark` variant so dark mode gets its own value. `palette.series`, `font.family`, and `font.size` are single values shared across modes.\n\n```malloy\n// Model-wide defaults (## applies to every view in the model):\n## theme.palette.series = ["#14b3cb", "#e47404", "#1474a4"]\n## theme.font.family = "Inter, sans-serif"\n\n// Per-view override (# applies to this result only; beats the instance theme):\n# theme.palette.background.light = "#fafafa"\n# theme.palette.background.dark = "#111111"\n# theme.palette.tableHeader.dark = "#94a3b8"\nview: revenue_by_month is {\n group_by: month\n aggregate: revenue\n}\n```\n\n**Precedence**, highest to lowest, per key: `# theme.*` on the view, then `## theme.*` model default, then the instance theme, then Publisher\'s built-in defaults. A per-chart annotation overrides the instance for the keys it sets; unset keys fall through to the instance. (This is the reverse of a bare `@malloydata/render` embed, where the embedder wins: Publisher reads the annotation itself and layers it on top.)\n\nQuote values that contain spaces or a leading `#`. The light/dark default (`defaultMode`) and the toggle lock (`allowUserToggle`) are instance-only: set them in the config `theme` block or the editor, not as annotations. The malloy-gotchas-rendering skill lists the annotation forms that look valid but do nothing.\n\n\n## Advanced Patterns\n\n### Sparklines in KPI Cards\n\n```malloy\n# big_value { sparkline=trend }\nview: revenue_kpi is {\n aggregate: # label="Revenue" # currency revenue\n nest: # line_chart { size=spark } # hidden\n trend is { group_by: order_date, aggregate: revenue, order_by: order_date }\n}\n```\n\n### KPIs with Comparison Deltas\n\n```malloy\n# big_value { comparison_field=prior_month comparison_label="vs Last Month" }\nview: rev_delta is {\n aggregate: # label="Revenue" # currency revenue, # hidden prior_month\n}\n```\n\nUse `down_is_good=true` for metrics where decrease is positive (churn, defects).\n\n### Inline Mini-Charts in Table Rows\n\n```malloy\nview: carriers is {\n group_by: carrier, aggregate: flight_count\n nest: # line_chart { size=spark }\n trend is { group_by: month, aggregate: flight_count, order_by: month }\n}\n```\n\n### Multi-Measure Series\n\n```malloy\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: quarter, aggregate: revenue, cost }\n```\n\n### Hierarchical Drill-Down\n\n```malloy\n# list_detail\nview: explorer is {\n group_by: region, aggregate: revenue\n nest: # bar_chart\n by_category is { group_by: category, aggregate: revenue, order_by: revenue desc, limit: 10 }\n}\n```\n\n### Distribution (Histogram)\n\nThere is no auto-binning function: `autobin(...)` does not exist and fails with `Unknown function \'autobin\'`. Bin by arithmetic, choosing the width from the column\'s actual range: query `min`, `max` and a few percentiles first, and say in the view\'s doc where the width came from. A bin width nobody derived is a business decision in disguise.\n\n```malloy\n# bar_chart\nview: price_dist is {\n group_by: bucket is floor(price / 20) * 20 // 20 is the bin width, from the observed range\n aggregate: order_count is count()\n order_by: bucket\n}\n```\n\n\n## Patterns for Missing Chart Types\n\n| Desired | Malloy Approximation |\n|---------|---------------------|\n| Pie/donut | `# bar_chart` sorted by value |\n| Treemap | Nested table with `order_by: desc` |\n| Heatmap | `# pivot` with color values |\n| Stacked area | `# line_chart` with series (overlaid lines) |\n| Funnel | `# bar_chart` with ordered stages |\n| Gauge/bullet | `# big_value` with `.comparison_field` |\n\n\n## Chart Annotations on Queries with `nest:`\n\nA top-level chart tag (e.g., `# bar_chart`) renders only the outer query; any `nest:` views are silently hidden from the rendering (still in raw data). To show nests, use `# dashboard` on the outer query with chart tags on each nest. Otherwise, drop the `nest:`.\n\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Two aggregates in chart | ONE aggregate, or use `y=[\'a\',\'b\']` |\n| `# currency=usd0m` on measure | `# currency` (no scale) on defs; scale only in views |\n| Chart annotation on `nest:` line | Put on the **view definition** |\n| Tags on same line | One tag per line |\n| Sparkline not showing | Add `# hidden` to nested view AND reference in `.sparkline=` |\n| Pivot > 30 columns | Filter/limit the nested group_by |\n\nNOTE: The term \'constructor\' is a reserved term in Vega-Lite. If the word \'constructor\' appears in the query, it will cause the rendering to fail. Never use it in a query and avoid using it as a dimension in a model.\n\nFor more patterns, call `search_malloy_docs` with topics like "bar charts", "line charts", "dashboards", "histograms", "percent of total", "comparing timeframes", or "pivots".\n\n## Further Reading\n\n- [Visualizations Overview](https://docs.malloydata.dev/documentation/visualizations/overview) - Official docs\n- [Bar Charts](https://docs.malloydata.dev/documentation/visualizations/bar_charts) - Stacked, grouped, series\n- [Bump Charts Blog](https://docs.malloydata.dev/blog/2023-10-26-malloy-bump-chart/) - Ranking over time\n- [Dataviz is Hierarchical](https://docs.malloydata.dev/blog/2024-02-29-hierarchical-viz/) - Nested data visualization philosophy' }, { name: "malloy-debug", description: 'Fix Malloy compile errors and understand error messages. Use when encountering errors in .malloy files, user says "fix this error", "malloy error", "compile error", "syntax error", or sees 20+ cascading errors.', body: `# Debugging Malloy Errors
|
|
292829
|
+
Publishing is out of scope for now: open-source Publisher serves the model from disk, and self-hosters publish via git plus their host's publish path.` }, { name: "malloy-charts", description: 'Chart selection guidance and renderer reference for Malloy views. Use when choosing visualization types, adding chart annotations, user asks "what chart should I use", "how should I visualize this", or when deciding between bar_chart, line_chart, scatter_chart, etc.', body: '# Chart Selection for Malloy\n\n> Malloy uses Vega-Lite under the hood. `#` tags control visualization. Call `search_malloy_docs` with topic "rendering" for the full tag reference (or see https://docs.malloydata.dev/documentation/visualizations/overview).\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n## Decision Tree: Which Chart?\n\n| Data Shape | Default Choice |\n|-----------|---------------|\n| Aggregates only (no group_by) | `# big_value` |\n| 1 time column + 1 measure | `# line_chart` |\n| 1 category + 1 measure | `# bar_chart` |\n| 2 numeric columns | `# scatter_chart` |\n| Geographic (US states) + 1 measure | `# shape_map` |\n| Route data (lat/lon pairs) | `# segment_map` |\n| Multiple perspectives | `# dashboard` with `nest:` |\n| Nested query to pivot | `# pivot` |\n| Filtered aggregates side-by-side | `# flatten` |\n| Detailed rows | Default table (no annotation) |\n\n| Goal | Renderer |\n|------|---------|\n| Compare categories | `# bar_chart` (sort by value, limit ~15) |\n| Show composition | `# bar_chart.stack` |\n| Trend over time | `# line_chart` |\n| Highlight KPIs | `# big_value` with `# label` |\n| Correlation | `# scatter_chart` |\n| Compare dimensions | `# dashboard` (nest chart views) |\n| Before/after | `# transpose` or `# pivot` |\n| Multiple metrics per category | Default table, `# flatten`, or `y=[\'a\',\'b\']` |\n\n**Constraints:**\n- ONE aggregate per chart view (charts render only the first; use `y=[\'a\',\'b\']` for multi-measure)\n- No fixed scale on measure definitions: use `# currency` not `# currency=usd0m`\n- One tag per line\n- Alias joined fields in `group_by` before `order_by`\n- Define measures in source, not in views\n\n\n## Chart Types\n\n### `# bar_chart`\n\n**Data shape:** `group_by` = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# bar_chart\nview: by_carrier is { group_by: carrier, aggregate: flight_count, order_by: flight_count desc, limit: 10 }\n\n# bar_chart.stack\nview: by_region is { group_by: category, region, aggregate: revenue }\n\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: category, aggregate: revenue, cost }\n```\n\n**Key properties:** `.stack`, `.size` (spark/xs/sm/md/lg/xl/2xl), `.x`, `.x.limit`, `.y` (supports `y=[\'a\',\'b\']`), `.series`, `.series.limit` (default 20), `.title`, `.subtitle`, `.x.independent`, `.y.independent`\n\n**Field role tags:** `# x`, `# y`, `# series` on individual fields to assign roles explicitly.\n\n### `# line_chart`\n\n**Data shape:** `group_by` (temporal/numeric) = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series.\n\n```malloy\n# line_chart\nview: trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n\n# line_chart { size=spark }\nview: mini_trend is { group_by: order_month, aggregate: revenue, order_by: order_month }\n```\n\n**Key properties:** `.zero_baseline`, `.interpolate` (e.g., `step`), `.size`, `.y` (supports `y=[\'a\',\'b\']`), `.series.limit` (default 12), `.title`, `.subtitle`\n\n### `# scatter_chart`\n\n**Data shape:** Fields by position: x, y, color, size (bubble), shape.\n\n```malloy\n# scatter_chart\nview: correlation is { group_by: customer_id, aggregate: avg_price, total_quantity }\n```\n\n### `# shape_map`\n\nChoropleth. US states only. Fields: state name, value.\n\n```malloy\n# shape_map\nview: by_state is { group_by: state, aggregate: revenue }\n```\n\n### `# segment_map`\n\nRoute map. US only. Fields: start_lat, start_lon, end_lat, end_lon, color.\n\n\n## Layout Types\n\n### `# big_value`\n\nKPI cards. Aggregates only, no `group_by`.\n\n```malloy\n# big_value\nview: summary is {\n aggregate:\n # label="Revenue"\n # currency\n revenue\n # label="Orders"\n # number=auto\n order_count\n}\n```\n\n**Properties:** `.size`, `.sparkline=<nested_view_name>`, `.comparison_field`, `.comparison_label`, `.down_is_good`\n\n### `# dashboard`\n\nCard-based multi-tile layout. Apply to a view whose body is a nested query; the view\'s own fields lay out automatically:\n\n- `group_by` dimensions -> a row header (repeats once per row; omit for a single block)\n- `aggregate` measures -> KPI cards, one per measure\n- each `nest:` -> a tile, rendered by the tag above it (`# table` default, or `# bar_chart` / `# line_chart` / `# big_value`)\n\n**Two modes.** Flex (default): tiles flow and wrap; `# break` forces a new row. Columns: `# dashboard { columns=N }` lays tiles into N equal columns, `# colspan=n` widens a tile, `# break` starts a new row, overflow wraps.\n\n```malloy\n// Flex: measures become KPI cards, the nest becomes a tile\n# dashboard\nview: overview is {\n group_by: category\n # currency\n aggregate:\n avg_retail is retail_price.avg()\n sum_retail is retail_price.sum()\n nest:\n # bar_chart\n by_brand is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 10 }\n}\n\n// Columns: # colspan widens tiles, # break ends a row\n# dashboard { columns=12 }\nview: layout is {\n group_by: category\n # currency\n aggregate:\n # colspan=4\n avg_retail is retail_price.avg()\n # colspan=4\n sum_retail is retail_price.sum()\n # colspan=4\n max_retail is retail_price.max()\n nest:\n # break\n # colspan=6\n # bar_chart\n # subtitle="Top brands"\n by_brand_chart is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 8 }\n # colspan=6\n by_brand_table is { group_by: brand, aggregate: product_count is count(), limit: 8 }\n}\n```\n\n**Tags:** `# dashboard { columns=N }` (columns mode), `{ gap=PX }` (tile spacing, default 16; never a mode), `{ table.max_height=PX|none }` (cap table tiles). On a measure or nest: `# colspan=N` (columns mode only), `# break` (both modes), `# subtitle="..."` (tile), `# borderless` (drop card chrome), `# label="..."` (card title).\n\nFor rich KPI cards (sparklines, comparison deltas, several metrics on one card) nest a `# big_value` view instead of relying on the dashboard\'s own measures:\n\n```malloy\n# dashboard\nview: kpis is {\n group_by: category\n nest:\n # big_value\n revenue_card is {\n aggregate:\n # label="Revenue"\n # currency\n # big_value { sparkline=trend }\n total_revenue is retail_price.sum()\n # line_chart { size=spark y.independent=true }\n # hidden\n nest: trend is { group_by: bucket is floor(id / 100)::number, aggregate: total_revenue is retail_price.sum(), order_by: bucket, limit: 20 }\n }\n}\n```\n\n**Rules:** `# dashboard` needs a nested-query view (no effect on a scalar). `# colspan` works only in columns mode and is ignored (warns) in flex. `columns` is any positive integer; a `# colspan` over the column count clamps to a full row. Style tiles via the instance theme, or theme the views inside with `# theme.*` (see Theming below).\n\n### `# pivot`\n\nPivot nested results into columns. Max 30 pivot columns.\n\n```malloy\nview: sales is {\n group_by: product, aggregate: total\n nest: # pivot\n by_quarter is { group_by: quarter, aggregate: revenue }\n}\n```\n\n### `# transpose`\n\nSwap rows/columns. Good for period comparisons.\n\n```malloy\n# transpose\nview: comparison is {\n aggregate:\n # label="This Month"\n current_revenue\n # label="Last Month"\n prior_revenue\n}\n```\n\n### `# list` / `# list_detail`\n\nList renders as comma-separated values. List_detail shows `value (detail)` pairs.\n\n### `# flatten`\n\nCollapse nested record into parent table as columns. Use for side-by-side filtered aggregates:\n\n```malloy\nview: segments is {\n group_by: product, aggregate: total_revenue\n nest: # flatten\n enterprise is { where: segment = \'Enterprise\', aggregate: # label="Enterprise" revenue }\n nest: # flatten\n smb is { where: segment = \'SMB\', aggregate: # label="SMB" revenue }\n}\n```\n\n### `# table`\n\nDefault (implicit). Use explicitly for `.size=fill` property.\n\n\n## Field Formatting Tags\n\n| Tag | Use For | Shorthand |\n|-----|---------|-----------|\n| `# number` | Numeric formatting | `=auto` (K/M/B), `=id` (no commas), `=1k`, `=1m` |\n| `# percent` | Percentages | (none needed) |\n| `# currency` | Money | `=usd2m` (USD, 2 decimals, millions); scale only in views |\n| `# duration` | Time durations | `=seconds`, `=minutes`, `=hours`, `=days` |\n| `# data_volume` | Storage sizes | `=bytes`, `=kb`, `=mb`, `=gb` |\n| `# link` | Hyperlinks | `.url_template="https://example.com/$$"` |\n| `# image` | Inline images | `.height=40px`, `.width=100px` |\n\n**Currency codes:** `usd` ($), `eur`, `gbp`. **Scale:** K/M/B/T/Q or `auto`.\n**Number suffix styles:** `word` ("42.5 million"), `letter` ("42.5M"), `scientific`.\n\n## Utility Tags\n\n| Tag | Purpose |\n|-----|---------|\n| `# hidden` | Hide from output (still usable for sorting/references) |\n| `# label="..."` | Override display name |\n| `# description="..."` | Tooltip text |\n| `# tooltip` | Include nested view in chart tooltip |\n| `# break` | Force new dashboard row |\n| `# column { width=sm }` | Table column width |\n\n## Model-Level Defaults\n\n```malloy\n## viz.line_chart.defaults.y.independent=true\n## viz.bar_chart.defaults.stack\n```\n\n## Theming\n\nPublisher styles charts and tables from one structured theme. The instance sets it (in `publisher.config.json`\'s `theme` block or the **Settings, then Theme** editor); a model overrides it per result with `# theme.*` annotations, or model-wide with `## theme.*`. Per-chart annotations use the same nested `palette.*` / `font.*` vocabulary as the config, not flat key names, and they win over the instance theme for the keys they set. The forms:\n\n| Annotation | Controls | Modes |\n|-----------|----------|-------|\n| `# theme.palette.series` | Categorical series colors (array) | shared |\n| `# theme.palette.background.{light,dark}` | Chart canvas + table background | per-mode |\n| `# theme.palette.tableHeader.{light,dark}` | Table header text color | per-mode |\n| `# theme.palette.tableHeaderBackground.{light,dark}` | Table header row background | per-mode |\n| `# theme.palette.tableBody.{light,dark}` | Table body text color | per-mode |\n| `# theme.palette.tile.{light,dark}` | Dashboard tile background | per-mode |\n| `# theme.palette.tileTitle.{light,dark}` | Dashboard tile title color | per-mode |\n| `# theme.palette.mapColor.{light,dark}` | Choropleth gradient (`# shape_map` / `# segment_map`) | per-mode |\n| `# theme.font.family` | Font for all rendered text | shared |\n| `# theme.font.size` | Table font size (px) | shared |\n\nThe seven `palette.*` color keys each take a `.light` and/or `.dark` variant so dark mode gets its own value. `palette.series`, `font.family`, and `font.size` are single values shared across modes.\n\n```malloy\n// Model-wide defaults (## applies to every view in the model):\n## theme.palette.series = ["#14b3cb", "#e47404", "#1474a4"]\n## theme.font.family = "Inter, sans-serif"\n\n// Per-view override (# applies to this result only; beats the instance theme):\n# theme.palette.background.light = "#fafafa"\n# theme.palette.background.dark = "#111111"\n# theme.palette.tableHeader.dark = "#94a3b8"\nview: revenue_by_month is {\n group_by: month\n aggregate: revenue\n}\n```\n\n**Precedence**, highest to lowest, per key: `# theme.*` on the view, then `## theme.*` model default, then the instance theme, then Publisher\'s built-in defaults. A per-chart annotation overrides the instance for the keys it sets; unset keys fall through to the instance. (This is the reverse of a bare `@malloydata/render` embed, where the embedder wins: Publisher reads the annotation itself and layers it on top.)\n\nQuote values that contain spaces or a leading `#`. The light/dark default (`defaultMode`) and the toggle lock (`allowUserToggle`) are instance-only: set them in the config `theme` block or the editor, not as annotations. The malloy-gotchas-rendering skill lists the annotation forms that look valid but do nothing.\n\n\n## Advanced Patterns\n\n### Sparklines in KPI Cards\n\n```malloy\n# big_value { sparkline=trend }\nview: revenue_kpi is {\n aggregate: # label="Revenue" # currency revenue\n nest: # line_chart { size=spark } # hidden\n trend is { group_by: order_date, aggregate: revenue, order_by: order_date }\n}\n```\n\n### KPIs with Comparison Deltas\n\n```malloy\n# big_value { comparison_field=prior_month comparison_label="vs Last Month" }\nview: rev_delta is {\n aggregate: # label="Revenue" # currency revenue, # hidden prior_month\n}\n```\n\nUse `down_is_good=true` for metrics where decrease is positive (churn, defects).\n\n### Inline Mini-Charts in Table Rows\n\n```malloy\nview: carriers is {\n group_by: carrier, aggregate: flight_count\n nest: # line_chart { size=spark }\n trend is { group_by: month, aggregate: flight_count, order_by: month }\n}\n```\n\n### Multi-Measure Series\n\n```malloy\n# bar_chart { y=[\'revenue\',\'cost\'] }\nview: rev_vs_cost is { group_by: quarter, aggregate: revenue, cost }\n```\n\n### Hierarchical Drill-Down\n\n```malloy\n# list_detail\nview: explorer is {\n group_by: region, aggregate: revenue\n nest: # bar_chart\n by_category is { group_by: category, aggregate: revenue, order_by: revenue desc, limit: 10 }\n}\n```\n\n### Distribution (Histogram)\n\nThere is no auto-binning function: `autobin(...)` does not exist and fails with `Unknown function \'autobin\'`. Bin by arithmetic, choosing the width from the column\'s actual range: query `min`, `max` and a few percentiles first, and say in the view\'s doc where the width came from. A bin width nobody derived is a business decision in disguise.\n\n```malloy\n# bar_chart\nview: price_dist is {\n group_by: bucket is floor(price / 20) * 20 // 20 is the bin width, from the observed range\n aggregate: order_count is count()\n order_by: bucket\n}\n```\n\n\n## Patterns for Missing Chart Types\n\n| Desired | Malloy Approximation |\n|---------|---------------------|\n| Pie/donut | `# bar_chart` sorted by value |\n| Treemap | Nested table with `order_by: desc` |\n| Heatmap | `# pivot` with color values |\n| Stacked area | `# line_chart` with series (overlaid lines) |\n| Funnel | `# bar_chart` with ordered stages |\n| Gauge/bullet | `# big_value` with `.comparison_field` |\n\n\n## Chart Annotations on Queries with `nest:`\n\nA top-level chart tag (e.g., `# bar_chart`) renders only the outer query; any `nest:` views are silently hidden from the rendering (still in raw data). To show nests, use `# dashboard` on the outer query with chart tags on each nest. Otherwise, drop the `nest:`.\n\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Two aggregates in chart | ONE aggregate, or use `y=[\'a\',\'b\']` |\n| `# currency=usd0m` on measure | `# currency` (no scale) on defs; scale only in views |\n| Chart annotation on `nest:` line | Put on the **view definition** |\n| Tags on same line | One tag per line |\n| Sparkline not showing | Add `# hidden` to nested view AND reference in `.sparkline=` |\n| Pivot > 30 columns | Filter/limit the nested group_by |\n\nNOTE: The term \'constructor\' is a reserved term in Vega-Lite. If the word \'constructor\' appears in the query, it will cause the rendering to fail. Never use it in a query and avoid using it as a dimension in a model.\n\nFor more patterns, call `search_malloy_docs` with topics like "bar charts", "line charts", "dashboards", "histograms", "percent of total", "comparing timeframes", or "pivots".\n\n## Further Reading\n\n- [Visualizations Overview](https://docs.malloydata.dev/documentation/visualizations/overview) - Official docs\n- [Bar Charts](https://docs.malloydata.dev/documentation/visualizations/bar_charts) - Stacked, grouped, series\n- [Bump Charts Blog](https://docs.malloydata.dev/blog/2023-10-26-malloy-bump-chart/) - Ranking over time\n- [Dataviz is Hierarchical](https://docs.malloydata.dev/blog/2024-02-29-hierarchical-viz/) - Nested data visualization philosophy' }, { name: "malloy-dashboards", description: "Build or modify a Malloy Publisher dashboard, a tagged .malloy file in a package's dashboards/ directory, with auto-rendered filter controls, a grid layout, and # drill click-through. Use when the user asks for a dashboard, a filterable operational view, or drill-through between views, and no code is wanted.", body: `# Publisher Dashboards
|
|
292830
|
+
|
|
292831
|
+
> A \`dashboards/*.malloy\` file **is** a dashboard. It imports the model, either declares one query and applies its own filtering or names views that already do, and tags the layout. Publisher discovers it at package load, renders the filter controls from the givens it references, offers \`# drill\` click-through between pages, and serves it at \`/<env>/<pkg>/dashboards/<name>\`. No code, no build step.
|
|
292832
|
+
|
|
292833
|
+
## When this is the right tool
|
|
292834
|
+
|
|
292835
|
+
| The user wants | Use |
|
|
292836
|
+
| ---------------------------------------------------- | ------------------------------------------------ |
|
|
292837
|
+
| A recurring, at-a-glance view behind shared filters | this skill (a dashboard) |
|
|
292838
|
+
| A narrative, with prose between the numbers | a notebook (\`skill:malloy-notebooks\`) |
|
|
292839
|
+
| Custom design, branding, or interactions beyond tags | an HTML data app (\`skill:malloy-html-data-apps\`) |
|
|
292840
|
+
| The model itself: sources, measures, joins | \`skill:malloy-modeling\` |
|
|
292841
|
+
|
|
292842
|
+
Notebooks and dashboards run the same engine, so **interactivity is not the axis**: both get filter
|
|
292843
|
+
controls, URL-addressable state, Apply batching, and \`# drill\`. Pick on the shape of the document.
|
|
292844
|
+
Scanned at a glance is a dashboard; read top to bottom is a notebook.
|
|
292845
|
+
|
|
292846
|
+
## Build sequence
|
|
292847
|
+
|
|
292848
|
+
1. **READ THE MODEL FIRST.** Get the real source, view, dimension, and given names from the package:
|
|
292849
|
+
\`malloy_getContext\` if you have it, otherwise the REST model endpoint or the \`.malloy\` files.
|
|
292850
|
+
Never guess a name. A guessed field in a query fails the whole package load, not just that one
|
|
292851
|
+
dashboard; a guessed tile or suggest source is quieter, and only shows up in the package warnings.
|
|
292852
|
+
2. **DECIDE THE FORM** (below): single-query if the page is one filtered result with parts;
|
|
292853
|
+
composite if the views already exist and the job is choosing which to show together.
|
|
292854
|
+
3. **DECLARE THE GIVENS** the dashboard will filter by, in the model (usually \`givens.malloy\`), with
|
|
292855
|
+
their control tags: see "Filter controls" below for the syntax and what each tag renders as. Skip
|
|
292856
|
+
if they already exist, since a given is a model concern and dashboards share them.
|
|
292857
|
+
4. **COMPOSE THE FILE** for \`dashboards/\`, following the template below, but do not save it yet.
|
|
292858
|
+
Import every given it filters by, and every source or query any of those givens names in a
|
|
292859
|
+
\`suggest\`. Both are per-file, and getting the suggest wrong does not error: the control still
|
|
292860
|
+
looks like a picker but has no options, and says so underneath, "Could not load the options for
|
|
292861
|
+
this control". The package warnings name it too.
|
|
292862
|
+
5. **COMPILE IT** with \`malloy_compile\` (or \`POST …/models/<path>/compile\`), against the source text,
|
|
292863
|
+
before you save, at the path the file will have. **Editing one that already exists needs
|
|
292864
|
+
\`"scope": "file"\`**, which compiles your source AS that file; the default appends it instead, so
|
|
292865
|
+
every imported name and the query name collide with the saved copy and you get a wall of
|
|
292866
|
+
already-defined errors that reads as broken Malloy rather than a wrong scope. **Editing a shared
|
|
292867
|
+
include wants \`"scope": "package"\`**, which recompiles every file as saved: \`file\` only checks the
|
|
292868
|
+
one you are editing, so renaming a source in \`_shared.malloy\` passes it while breaking every
|
|
292869
|
+
composite that imports it. A clean compile is not a working dashboard: some tag mistakes surface
|
|
292870
|
+
at step 6, and some only when you look at the page in step 7. (The third scope, \`append\`, is the
|
|
292871
|
+
default and is what a not-yet-saved file gets.)
|
|
292872
|
+
6. **SAVE IT, RELOAD, AND READ THE MANIFEST AND THE WARNINGS.** \`malloy_reloadPackage\`, or
|
|
292873
|
+
\`GET …/packages/<pkg>?reload=true\`. Check the status the reload returns as well as the warnings:
|
|
292874
|
+
a 424 means the package did not load and your edit is not live. **The \`warnings\` key is absent
|
|
292875
|
+
when there are none**, so an empty response is the pass, not a sign you are reading the wrong
|
|
292876
|
+
field. Then read \`GET …/packages/<pkg>/dashboards/<name>\`: its \`givens\` are exactly the controls
|
|
292877
|
+
that will render, which catches a given you imported but never referenced before you open the page,
|
|
292878
|
+
and its \`query\` is the name to run in step 7. See "Read the lint" below.
|
|
292879
|
+
7. **OPEN IT AND LOOK.** Not optional; see "What 'done' means".
|
|
292880
|
+
|
|
292881
|
+
## The two forms
|
|
292882
|
+
|
|
292883
|
+
**Single-query:** one query whose result is the dashboard. Reach for this by default.
|
|
292884
|
+
|
|
292885
|
+
\`\`\`malloy
|
|
292886
|
+
##! experimental.givens
|
|
292887
|
+
import { order_items, products } from '../storefront.malloy'
|
|
292888
|
+
import { CATEGORY, MIN_SALE } from '../givens.malloy'
|
|
292889
|
+
|
|
292890
|
+
#" Revenue and margin at a glance, and where they come from.
|
|
292891
|
+
# artifact { title="Business Overview" } dashboard { columns=12 }
|
|
292892
|
+
query: overview is order_items -> {
|
|
292893
|
+
where: products.category ~ $CATEGORY and sale_price ~ $MIN_SALE
|
|
292894
|
+
|
|
292895
|
+
aggregate:
|
|
292896
|
+
# label="Revenue"
|
|
292897
|
+
# currency
|
|
292898
|
+
# colspan=3
|
|
292899
|
+
total_sales
|
|
292900
|
+
# label="Gross margin"
|
|
292901
|
+
# currency
|
|
292902
|
+
# colspan=3
|
|
292903
|
+
total_margin
|
|
292904
|
+
# label="Orders"
|
|
292905
|
+
# colspan=3
|
|
292906
|
+
order_count
|
|
292907
|
+
# label="Avg order value"
|
|
292908
|
+
# currency
|
|
292909
|
+
# colspan=3
|
|
292910
|
+
avg_order_value
|
|
292911
|
+
|
|
292912
|
+
nest:
|
|
292913
|
+
# break
|
|
292914
|
+
# colspan=6
|
|
292915
|
+
# label="Revenue by month"
|
|
292916
|
+
sales_by_month
|
|
292917
|
+
# colspan=6
|
|
292918
|
+
# label="Revenue by state"
|
|
292919
|
+
sales_by_state
|
|
292920
|
+
nest:
|
|
292921
|
+
# colspan=12
|
|
292922
|
+
# label="Category performance"
|
|
292923
|
+
by_category
|
|
292924
|
+
}
|
|
292925
|
+
\`\`\`
|
|
292926
|
+
|
|
292927
|
+
The \`#"\` line above the tag is a doc comment, and it is the page's description. If you leave \`title=\`
|
|
292928
|
+
off the artifact tag, it becomes the title instead, so write it as one, not as a sentence about the
|
|
292929
|
+
page. **It belongs to the query, so it only works on this form.** Putting a \`#"\` above a
|
|
292930
|
+
model-level \`##\` tag fails the whole package load with "Object annotation not connected to any
|
|
292931
|
+
object", and a composite has no description as a result.
|
|
292932
|
+
|
|
292933
|
+
**Composite:** a list of views that already exist, each run separately into one grid. The tag is
|
|
292934
|
+
model-level (\`##\`) because there is no query of its own to hang a \`#\` tag on.
|
|
292935
|
+
|
|
292936
|
+
\`\`\`malloy
|
|
292937
|
+
##! experimental.givens
|
|
292938
|
+
## artifact { title="Seasonality" tiles=["scoped_sales -> sales_by_month", "scoped_sales -> sales_by_year", "scoped_sales -> seasonality"] dashboard_columns=3 }
|
|
292939
|
+
import { scoped_sales } from './_shared.malloy'
|
|
292940
|
+
import { products } from '../storefront.malloy'
|
|
292941
|
+
import { CATEGORY, SINCE } from '../givens.malloy'
|
|
292942
|
+
\`\`\`
|
|
292943
|
+
|
|
292944
|
+
A composite has no query, so the filtering it applies must live in what it composes: a source that
|
|
292945
|
+
already has the givens applied. Put it in an untagged \`dashboards/_shared.malloy\`, which discovery
|
|
292946
|
+
treats as a shared include rather than a dashboard. **It has to apply every given the composite
|
|
292947
|
+
imports**: a given the composite imports but nothing references gets no control, silently, at reload
|
|
292948
|
+
200 with no warning. Note \`SINCE\` is a \`date\` rather than a \`filter<>\`, so it compares with \`>=\`
|
|
292949
|
+
rather than \`~\`. Save the include before you compile the dashboard that imports it, since an importer
|
|
292950
|
+
compiled against a sibling that is not on disk fails with an \`import-error\`.
|
|
292951
|
+
|
|
292952
|
+
\`\`\`malloy
|
|
292953
|
+
##! experimental.givens
|
|
292954
|
+
import { order_items } from '../storefront.malloy'
|
|
292955
|
+
import { CATEGORY, SINCE } from '../givens.malloy'
|
|
292956
|
+
|
|
292957
|
+
source: scoped_sales is order_items extend {
|
|
292958
|
+
where: products.category ~ $CATEGORY and created_at >= $SINCE
|
|
292959
|
+
}
|
|
292960
|
+
\`\`\`
|
|
292961
|
+
|
|
292962
|
+
Its tiles are equal-width (there is no per-tile colspan), and each one takes a single column, so
|
|
292963
|
+
**\`dashboard_columns\` is how many tiles you want per row**, not a number of twelfths. Three tiles want
|
|
292964
|
+
\`dashboard_columns=3\`. Setting it to 12 out of habit gives you twelve columns and three tiles a
|
|
292965
|
+
twelfth of the page wide. Use the single-query form when one tile deserves more room than the others.
|
|
292966
|
+
|
|
292967
|
+
**Write \`dashboard_columns=N\` on a composite and \`# dashboard { columns=N }\` on a single query.**
|
|
292968
|
+
A composite forgives the mix, since both spellings feed the manifest field it
|
|
292969
|
+
lays out from, but **a single query tagged \`dashboard_columns\` silently loses its whole layout**, and
|
|
292970
|
+
the manifest reports the count either way: see "Losing the grid". A composite's tiles also keep their
|
|
292971
|
+
own field names on their axes and column headers, and the Layout section's remedy, inlining the view,
|
|
292972
|
+
is not open to you here: naming existing views is the whole point of the form. \`tiles=\` on a
|
|
292973
|
+
single-query artifact tag is dropped the same way, silently.
|
|
292974
|
+
|
|
292975
|
+
## Layout: the four tags that make a page line up
|
|
292976
|
+
|
|
292977
|
+
This section is the **single-query** form only. A composite has no colspans and counts tiles per row,
|
|
292978
|
+
above. Cards and tiles share one grid, so copy this recipe, and use the same count on every
|
|
292979
|
+
single-query dashboard in the package so they read as one product:
|
|
292980
|
+
|
|
292981
|
+
1. **\`columns=12\`** on the \`# dashboard\` tag. Twelve divides by 2, 3, 4 and 6, so a row is even with
|
|
292982
|
+
three cards or four.
|
|
292983
|
+
2. **A \`# colspan\` on every card and tile, summing to 12 per row.** Four cards at 3, three at 4, two
|
|
292984
|
+
tiles at 6, a full-width table at 12. Omit them and every item falls to a single column, a twelfth
|
|
292985
|
+
of the width, which is too narrow for a line chart to draw in at all.
|
|
292986
|
+
3. **\`# break\` on the first tile after the cards.** Otherwise it flows into the columns left beside
|
|
292987
|
+
the cards and the next tile wraps. Not needed per row: once a row sums to 12 the next item wraps
|
|
292988
|
+
on its own.
|
|
292989
|
+
4. **\`# label="…"\` on every nest and every aggregate**, including the aggregates inside a table
|
|
292990
|
+
nest, whose column headers are field names too. The heading is otherwise the view's or field's
|
|
292991
|
+
name, and a wide table full of \`total_sales\` and \`order_item_count\` is the most visible thing
|
|
292992
|
+
between a rough page and a finished one. A view nested **by name** is the exception: you can label
|
|
292993
|
+
the tile, but its own field names still reach the chart axes and the column headers, so inline it
|
|
292994
|
+
if you want those labelled too.
|
|
292995
|
+
|
|
292996
|
+
Then the traps:
|
|
292997
|
+
|
|
292998
|
+
- **No \`# size=fill\` on a dashboard tile.** Inside a dashboard it measures against the container the
|
|
292999
|
+
whole grid was handed, not the tile, so it yields a chart thousands of pixels tall. Tiles already
|
|
293000
|
+
size to their colspan.
|
|
293001
|
+
- **A KPI card's label is one line that ellipses** rather than wrapping, so a long label in a narrow
|
|
293002
|
+
card is truncated with no other sign. Widen the card or shorten the label.
|
|
293003
|
+
- **A ratio needs a number format.** \`order_count / customer_count\` renders as \`10.695\` on a card;
|
|
293004
|
+
\`# number="#,##0.0"\` is the precision it actually carries.
|
|
293005
|
+
- **A \`# shape_map\` legend is titled with the measure's field _name_, not its \`# label\`.** Rename it
|
|
293006
|
+
in the view: \`aggregate: revenue is total_sales\`. Renaming drops the measure's own format tags
|
|
293007
|
+
though, so \`# currency\` becomes plain digits unless you re-tag it at the rename. Prefer \`# label=\`
|
|
293008
|
+
anywhere the legend is not the problem.
|
|
293009
|
+
- **A series legend sizes itself from the longer of the series label and its widest value**, then
|
|
293010
|
+
truncates both. A 4-character label over 4-digit years clips to \`20…\`; \`# label="Order year"\`
|
|
293011
|
+
instead of \`"Year"\` buys the room. A legend showing \`…\` is this, not a data problem.
|
|
293012
|
+
|
|
293013
|
+
The last two are upstream renderer behavior, cheap to work around in the model.
|
|
293014
|
+
|
|
293015
|
+
The same tags govern a \`# dashboard\` **view** run in a notebook cell, since both surfaces render
|
|
293016
|
+
through the same code, so a view laid out this way looks the same in a cell as on a dashboard page.
|
|
293017
|
+
Height is the one thing the surface decides: a single-query dashboard renders at its natural height,
|
|
293018
|
+
a composite's tiles are each capped, and in a notebook a chart cell is capped and a table cell hugs
|
|
293019
|
+
its rows.
|
|
293020
|
+
|
|
293021
|
+
## The rules that actually bite
|
|
293022
|
+
|
|
293023
|
+
- **The filename is the dashboard's name:** its URL slug, its listing name, and its \`# drill\`
|
|
293024
|
+
target. The query inside can be called anything, and sometimes must be (a query named \`regions\`
|
|
293025
|
+
collides with an imported \`regions\` source).
|
|
293026
|
+
- **Importing a given is what makes it bindable.** Malloy's given namespace is per-file. A given the
|
|
293027
|
+
dashboard file does not import gets no control and cannot be sent to it, even when the \`where:\`
|
|
293028
|
+
that references it lives up an import chain. A composite must import the givens its tiles use.
|
|
293029
|
+
- **A suggest's source or query has to resolve in the dashboard file too.** \`suggest { source=products … }\`
|
|
293030
|
+
means the dashboard imports \`products\`.
|
|
293031
|
+
- **A model-level \`##\` tag must be on one line.** Wrapping one always breaks it, but how you find
|
|
293032
|
+
out depends on what follows. If the continuation is not valid Malloy you get a compile error. If it
|
|
293033
|
+
happens to be, an \`import\` say, the file compiles clean, quietly stops being a dashboard and
|
|
293034
|
+
becomes a shared include, and only the package warnings tell you. Match on the shape rather than the
|
|
293035
|
+
words: the message may say a tag "does not parse", or was "refused" or "dropped rather than parsed",
|
|
293036
|
+
and on a file that still built it opens "Annotation" rather than "Tag". See "Losing the grid" below.
|
|
293037
|
+
- **In a \`# dashboard\` view, fields render by role.** A top-level \`aggregate:\` measure is a KPI card,
|
|
293038
|
+
so do not nest a \`# big_value\` view to get one. Each \`nest:\` is a tile. Give every KPI a
|
|
293039
|
+
\`# label=\`, or the card is headed \`total_sales\`.
|
|
293040
|
+
- **Only table cells are marked drillable.** See "Drill".
|
|
293041
|
+
|
|
293042
|
+
\`skill:malloy-gotchas-rendering\` covers the renderer tags in depth; \`skill:malloy-charts\` covers
|
|
293043
|
+
choosing them.
|
|
293044
|
+
|
|
293045
|
+
## Filter controls
|
|
293046
|
+
|
|
293047
|
+
Controls come from the \`given:\` declarations the query references, and the tags on the declaration
|
|
293048
|
+
are the control contract, declared once and identical on every dashboard and in every notebook that
|
|
293049
|
+
uses them:
|
|
293050
|
+
|
|
293051
|
+
\`\`\`malloy
|
|
293052
|
+
##! experimental.givens
|
|
293053
|
+
|
|
293054
|
+
# label="Category" control=select suggest { source=products dimension=category }
|
|
293055
|
+
given: CATEGORY :: filter<string> is f''
|
|
293056
|
+
|
|
293057
|
+
# label="Brand" control=multiselect suggest { query=brand_suggest dimension=brand }
|
|
293058
|
+
given: BRAND :: filter<string> is f''
|
|
293059
|
+
|
|
293060
|
+
# label="Minimum line total" range_min=0 range_max=250
|
|
293061
|
+
given: MIN_SALE :: filter<number> is f''
|
|
293062
|
+
|
|
293063
|
+
# label="Ordered since"
|
|
293064
|
+
given: SINCE :: date is @2023-01-01
|
|
293065
|
+
\`\`\`
|
|
293066
|
+
|
|
293067
|
+
A sixth tag, \`description\`, is part of the contract and has two spellings that do different things:
|
|
293068
|
+
\`# description="…"\` publishes to the API but Publisher's own UI does not render it, while
|
|
293069
|
+
\`#(description="…")\` renders as helper text under the control but complains about any multi-word
|
|
293070
|
+
value, that the prefix "is not a well-formed route", because a route ends at the first space. That
|
|
293071
|
+
complaint is a **compile** diagnostic on a compile that still succeeds, not a package warning, so
|
|
293072
|
+
step 6 will not show it. Pick by which reader you care about.
|
|
293073
|
+
|
|
293074
|
+
\`control=select\`/\`multiselect\` with a \`suggest\` renders a picker filled from the data;
|
|
293075
|
+
\`range_min\`/\`range_max\` on a \`filter<number>\` renders a slider; a \`date\` or \`timestamp\` renders a
|
|
293076
|
+
date picker. Which controls appear is per-dashboard, decided by which givens the query references.
|
|
293077
|
+
\`skill:malloy-modeling\` and \`docs/givens.md\` cover givens themselves.
|
|
293078
|
+
|
|
293079
|
+
Two per-dashboard options on the artifact tag:
|
|
293080
|
+
|
|
293081
|
+
- \`autorun=false\` batches control changes behind an Apply button. Add it once a page is slow enough
|
|
293082
|
+
that a reader notices two round trips.
|
|
293083
|
+
- \`givens { CATEGORY=f'Outerwear' }\` sets starting values, not a redeclaration. A URL parameter wins.
|
|
293084
|
+
|
|
293085
|
+
A notebook takes both at the file level, as \`## autorun=false\` and \`## givens { CATEGORY=f'Outerwear' }\`,
|
|
293086
|
+
and behaves identically.
|
|
293087
|
+
|
|
293088
|
+
## Drill
|
|
293089
|
+
|
|
293090
|
+
\`# drill\` goes on a model **dimension**, never on a dashboard:
|
|
293091
|
+
|
|
293092
|
+
\`\`\`malloy
|
|
293093
|
+
# drill { to=["category", "self"] given=CATEGORY }
|
|
293094
|
+
dimension: category is products.category
|
|
293095
|
+
\`\`\`
|
|
293096
|
+
|
|
293097
|
+
\`to=<slug>\` navigates to that dashboard with the clicked value written into the named given;
|
|
293098
|
+
\`to=self\` filters in place; two or more destinations pop a menu.
|
|
293099
|
+
|
|
293100
|
+
Declaring it on the dimension is the point: every result that groups by it becomes clickable, in a
|
|
293101
|
+
dashboard tile and in a notebook cell alike. So when a view is meant to be drilled, group by the
|
|
293102
|
+
tagged dimension. Declaring \`dimension: category is products.category\` and grouping by \`category\`
|
|
293103
|
+
gives the identical output field name and the identical numbers, and carries the tag.
|
|
293104
|
+
|
|
293105
|
+
**Always write \`given=\`.** Without it the given name is the dimension name **verbatim**, so a
|
|
293106
|
+
\`dimension: category\` seeds a given called \`category\` rather than a declared \`given: CATEGORY\`. A
|
|
293107
|
+
\`to=self\` survives that, because a surface folds case when it looks up its own given. A \`to=<slug>\`
|
|
293108
|
+
does not: it navigates, still looks like it worked, and arrives as \`?category=…\`, which the
|
|
293109
|
+
destination drops by exact match, so you land on an unfiltered page. Nothing errors, and the lint
|
|
293110
|
+
folds case when it checks, so it stays green too. That silence is specific to a name that folds onto
|
|
293111
|
+
a declared given. A \`to=self\` whose name matches nothing at all is caught loudly and is not offered;
|
|
293112
|
+
a \`to=<slug>\` is not checked either way.
|
|
293113
|
+
|
|
293114
|
+
A drill only lands somewhere useful if the destination declares a control for the given being
|
|
293115
|
+
seeded. **No lint checks that.** It verifies that the target slug is a dashboard in the package, and
|
|
293116
|
+
for \`to=self\` that some model declares the given, and stops there. Nothing reads the destination's
|
|
293117
|
+
own givens, so click it and look.
|
|
293118
|
+
|
|
293119
|
+
Cells in a drillable **table** column show it: pointer cursor, and a blue underline on hover. They are
|
|
293120
|
+
in the tab order and carry a button role too, so a keyboard reaches them, focus is styled the way
|
|
293121
|
+
hover is, and Enter or Space fires the drill. Chart marks get no such affordance in either Publisher
|
|
293122
|
+
or Malloyyo, so a dashboard meant to be drilled wants at least one untagged (table) tile. A destination the
|
|
293123
|
+
surface cannot honor is not marked and not offered, which is why a \`to=self\` reads as plain text in a
|
|
293124
|
+
document that declares no control for its given.
|
|
293125
|
+
|
|
293126
|
+
**One thing quietly switches the marking off, per column: another tile rendering a column with the
|
|
293127
|
+
same header.** Put a "revenue by category" chart beside a drillable \`category\` table, which is the
|
|
293128
|
+
obvious thing to build, and that column's cells stop being marked. Marking matches columns by their
|
|
293129
|
+
rendered header text, so a name a non-drillable field also shows is dropped rather than risk painting
|
|
293130
|
+
a dead link. Only a **non-drillable** column suppresses: a second tile that groups by the same tagged
|
|
293131
|
+
dimension is fine, which is the arrangement the paragraph above already recommends. **Other drillable
|
|
293132
|
+
columns on the same page keep their marking**, so counting marked cells will not tell you: look at the
|
|
293133
|
+
column you care about. The clicks still work, so this is invisible
|
|
293134
|
+
unless you hover. Give the two different headings with \`# label=\`. A transposed table is never marked
|
|
293135
|
+
either, for a different reason.
|
|
293136
|
+
|
|
293137
|
+
## Losing the grid
|
|
293138
|
+
|
|
293139
|
+
A single-query dashboard can come out with its layout wrong in two visibly different ways, and the
|
|
293140
|
+
reload is 200 and the manifest reports the column count you asked for in both.
|
|
293141
|
+
|
|
293142
|
+
**Not a dashboard at all: one plain nested table**, every \`# colspan\` and \`# break\` dropped. Either
|
|
293143
|
+
you tagged a single query with \`dashboard_columns=N\`, which only a composite reads, or a \`f'…'\`
|
|
293144
|
+
filter literal in a \`givens { … }\`
|
|
293145
|
+
block shares a line with \`# dashboard\`. For the second, put \`# dashboard\` on its own line: writing it
|
|
293146
|
+
first on the line does not help, a plain \`'Outerwear'\` or a bare date is fine, and the composite form
|
|
293147
|
+
is immune because its layout comes from the manifest rather than a re-parse.
|
|
293148
|
+
|
|
293149
|
+
**A dashboard, but nothing lines up**: you wrote \`# colspan\` without \`columns=N\`, so the items flow
|
|
293150
|
+
side by side at their natural widths instead of aligning to a grid.
|
|
293151
|
+
|
|
293152
|
+
To tell them apart, run the dashboard's own query, \`{"queryName": "<the manifest's query>"}\`, and read
|
|
293153
|
+
\`renderLogs\` on the response. Like \`warnings\`, the key is absent when there is nothing to say.
|
|
293154
|
+
Single-query dashboards have no \`tiles\` in their manifest, so there is no tile query to run:
|
|
293155
|
+
|
|
293156
|
+
| render log | what it means |
|
|
293157
|
+
|---|---|
|
|
293158
|
+
| \`Unknown render tag 'colspan'\` | the renderer never saw a \`# dashboard\` tag. It does **not** say which of the two causes; check both |
|
|
293159
|
+
| \`Ignored # colspan … only applies in columns mode\` | it saw the tag but there is no count |
|
|
293160
|
+
|
|
293161
|
+
Neither reaches the package warnings, so step 6 will not show either. A **wrapped \`##\` tag** is the
|
|
293162
|
+
one failure in this family that does: the file is absent from the listing and the package warnings
|
|
293163
|
+
say "Tag does not parse (Unclosed '{')".
|
|
293164
|
+
|
|
293165
|
+
## Read the lint
|
|
293166
|
+
|
|
293167
|
+
Package warnings after a reload are the dashboard's test suite. Fix all of them:
|
|
293168
|
+
|
|
293169
|
+
- \`# drill … targets "x", which is not a dashboard in this package\`: a dead click.
|
|
293170
|
+
- \`to=self, but no model in this package declares a given "X"\`: the clicked value has nowhere to go.
|
|
293171
|
+
- \`given "X" suggests options from source "y", which this file does not define\`: the dropdown will
|
|
293172
|
+
be empty, so import it.
|
|
293173
|
+
- \`filters by given "X", which this file does not import, so no control is shown for it\`: the trap
|
|
293174
|
+
under "Importing a given is what makes it bindable", which the lint now names for you, with the file
|
|
293175
|
+
to fix.
|
|
293176
|
+
- A tile that does not resolve to a real view, or a non-positive \`dashboard_columns\`.
|
|
293177
|
+
|
|
293178
|
+
Findings carry a \`severity\`, but \`warn\` is the ordinary default and tells you nothing about how bad
|
|
293179
|
+
one is. Read the text, not the severity and not the count. One message is worth recognising because
|
|
293180
|
+
it changes what the rest of the list means: **"Dashboard lint stopped early, so this list is
|
|
293181
|
+
incomplete"**. A dashboard withheld from \`explores\` also loses its own findings, so a short list for a
|
|
293182
|
+
withheld file is not a clean bill of health.
|
|
293183
|
+
|
|
293184
|
+
**Read the status the reload itself returns, not the listing.** One dashboard that fails to compile
|
|
293185
|
+
fails the whole package load, and the reload answers **424** with the compile error. A package that
|
|
293186
|
+
was already serving then keeps serving its previous version, so the listing still answers 200 and
|
|
293187
|
+
looks perfectly healthy while your edit has silently not taken effect.
|
|
293188
|
+
|
|
293189
|
+
**If you did not catch the 424, \`GET /api/v0/status\` still knows.** A package serving an older model
|
|
293190
|
+
than its files appears in \`loadErrors\` with **\`stale: true\`**, the compile message, and the time it
|
|
293191
|
+
failed, and the entry clears on the next reload that compiles. That is the one check that works after
|
|
293192
|
+
the fact, so make it the first thing you run when a page will not change. A package that never loaded
|
|
293193
|
+
at all appears there too, without \`stale\`, and is absent from the listing entirely.
|
|
293194
|
+
|
|
293195
|
+
If the reload is 200 and the others are listed but yours is not, discovery skipped the file instead,
|
|
293196
|
+
usually a missing or misspelled \`# artifact\` tag, which is the same mechanism that deliberately skips
|
|
293197
|
+
an untagged shared include. There is a second cause if the package's \`publisher.json\` carries an
|
|
293198
|
+
\`explores\` list: a dashboard whose file is missing from it is withheld rather than served, and the
|
|
293199
|
+
warning says so and names the fix. The list is what matters, not the \`queryableSources\` setting, which
|
|
293200
|
+
is \`declared\` by default; a package with no \`explores\` list withholds nothing. Where there is one, a
|
|
293201
|
+
\`suggest\` source has to be queryable as well as resolvable, so it needs to be on the list too.
|
|
293202
|
+
|
|
293203
|
+
**A clean reload is not proof the tags are right.** The checks above read names and resolve them; the
|
|
293204
|
+
separate warning for a tag that does not *parse* is syntax only: it carries no
|
|
293205
|
+
position and says nothing about a name that does not resolve. It catches *a* malformed tag; its
|
|
293206
|
+
absence is not evidence there are none. That is why the last step is opening the page, not reading
|
|
293207
|
+
the warning list.
|
|
293208
|
+
|
|
293209
|
+
## What "done" means
|
|
293210
|
+
|
|
293211
|
+
- Every source, view, and field name came from the model you read in step 1.
|
|
293212
|
+
- The reload returned **200**, not 424. A 424 means the page you are about to look at is the old one.
|
|
293213
|
+
- The package reloads with **zero** dashboard warnings, and your dashboard is in the listing at all.
|
|
293214
|
+
- **The page is a dashboard and its items line up.** This is the check the others cannot make for
|
|
293215
|
+
you: all of them pass both on a page that has become one plain nested table and on a dashboard whose
|
|
293216
|
+
items do not align. See "Losing the grid" for which you have.
|
|
293217
|
+
- You opened the page and every tile shows real numbers: not stuck loading, not an error, not an
|
|
293218
|
+
empty state you did not intend.
|
|
293219
|
+
- Each control renders as the widget you intended (a select shows options; a slider is a slider),
|
|
293220
|
+
and changing one changes the numbers.
|
|
293221
|
+
- If you added a \`# drill\`, you clicked it and landed where you meant to, with the given seeded.
|
|
293222
|
+
- On a single-query dashboard, every card and tile carries a colspan, each row's colspans sum to
|
|
293223
|
+
\`columns\`, and the rows end flush with each other. On a composite, the tiles fill the row rather
|
|
293224
|
+
than leaving a gap. Nothing is clipped, no tile is thousands of pixels tall, and no legend or card
|
|
293225
|
+
label ends in \`…\`.
|
|
293226
|
+
|
|
293227
|
+
## Reference
|
|
293228
|
+
|
|
293229
|
+
- \`docs/dashboards.md\`: the full guide this skill condenses.
|
|
293230
|
+
- \`docs/givens.md\`: the givens the controls are generated from.` }, { name: "malloy-debug", description: 'Fix Malloy compile errors and understand error messages. Use when encountering errors in .malloy files, user says "fix this error", "malloy error", "compile error", "syntax error", or sees 20+ cascading errors.', body: `# Debugging Malloy Errors
|
|
290796
293231
|
|
|
290797
293232
|
> **Tool names** are written bare here - \`get_context\`, \`execute_query\`, \`search_malloy_docs\`. The exact prefixed name depends on the host surface; match each against the tools you actually have.
|
|
290798
293233
|
|
|
@@ -291213,7 +293648,7 @@ view: overview is {
|
|
|
291213
293648
|
- **Only seven palette keys take light/dark.** \`background\`, \`tableHeader\`, \`tableHeaderBackground\`, \`tableBody\`, \`tile\`, \`tileTitle\`, and \`mapColor\` each accept a \`.light\` and/or \`.dark\` variant. \`palette.series\`, \`font.family\`, and \`font.size\` are single values shared across modes; a \`.light\`/\`.dark\` on them does nothing.
|
|
291214
293649
|
- **\`# theme.palette.mapColor.{light,dark}\` recolors choropleths only.** It sets the saturated end of the \`# shape_map\` / \`# segment_map\` gradient (per mode). Rect-mark heatmaps keep their built-in scheme.
|
|
291215
293650
|
- **\`defaultMode\` and \`allowUserToggle\` are instance-only.** No per-chart annotation controls the light/dark default or the toggle lock; set them in the config \`theme\` block or the editor.
|
|
291216
|
-
- **Environment-level theming is not applied yet.** Only the instance theme and the \`# theme.*\` / \`## theme.*\` per-chart annotations take effect today.` }, { name: "malloy-html-data-app-embedding", description: "Embed an in-package HTML data app into a host page or another application, including auto-sizing and auth. Read when embedding a Publisher page via Publisher.embed.", body: '# Embedding an HTML Data App\n\n> `Publisher.embed(selector, { src })` drops a package page into a host page as a sandboxed, auto-resizing iframe. Same-origin embeds authenticate with the browser\'s cookies; cross-origin embeds need a signed token.\n\n## The host-page pattern\n\n```html\n<script src="https://your-publisher/sdk/publisher.js"></script>\n<div id="dashboard"></div>\n<script>\n const handle = Publisher.embed("#dashboard", {\n src: "https://your-publisher/environments/demo/packages/sales/index.html",\n });\n // handle.destroy() removes the iframe and detaches its listeners.\n</script>\n```\n\n`embed(selector, options)` returns `{ iframe, destroy() }`. Options: `src` (required), `token` (a signed token for cross-origin auth, appended as `embed_token`), `height` (omit to auto-size; a number is treated as pixels), and `allow` (the iframe permissions policy).\n\n## Sizing and the resize contract\n\nOmit `height` and the frame auto-sizes. The embedded page measures its real content height and posts a `publisher:resize` message to the host, which resizes the iframe and accepts that message only from the iframe it created. You write none of this; it ships in `/sdk/publisher.js`, so the embedded page only has to load that script.\n\nDo not rely on `body { min-height: 100vh }` to drive the frame height. The runtime deliberately measures the content\'s bottom edge, not the viewport, to avoid a grow-forever loop.\n\n## Auth\n\n- Same-origin or same-tenant: pass no token. The browser\'s cookies authenticate the iframe.\n- Cross-origin: mint a short-lived signed token server-side and pass it as `options.token`. The runtime appends it to the iframe URL as `embed_token`; the embedded page must read it (from `location.search`) and call `Publisher.setToken(token)`. Because it rides in the URL, it can land in browser history, Referer headers, and server logs, so keep it short-lived and scoped to that one embed, and never put a long-lived or admin token in client HTML.\n\n## Guardrails (v1)\n\n- The iframe is sandboxed (`allow-scripts allow-same-origin allow-forms`). Design for that: no top-level navigation, no popups.\n- Embedded author JavaScript runs with the viewing user\'s data authority, so treat everything under `public/` as strictly first-party code: do not load untrusted third-party scripts, and do not move query results off to other hosts. Tighter per-embed isolation is planned.' }, { name: "malloy-html-data-app-runtime", description: "Write the JavaScript that drives an in-package HTML data app, calling Publisher.query, building queries from filter state, and handling results and errors. Read before writing the page's data code.", body: '# HTML Data App Runtime\n\n> `Publisher.query(modelPath, malloy)` returns an array of plain row objects. Build the Malloy string, let the model do the work, and render the rows with whatever front-end code you like.\n\nThe runtime loads from the root-relative `<script src="/sdk/publisher.js">` and adds one global, `window.Publisher`.\n\n## The query contract\n\n| Call | Returns | Use for |\n|---|---|---|\n| `Publisher.query(modelPath, malloy, opts?)` | `Promise<Array>` of rows | driving your own charts and tables |\n| `Publisher.queryFull(modelPath, malloy, opts?)` | `Promise<MalloyResult>` | handing to `<malloy-render>` |\n\n- `modelPath` is the model FILE path within the package, with `/` separators (`"subscriptions.malloy"`, `"models/events.malloy"`). It is not the source name.\n- `malloy` is any query string, written in standard Malloy. This skill covers only the JavaScript glue, not Malloy syntax.\n- `opts` (all optional): `sourceName`, `queryName`, `givens` (a `{ name: value }` map bound to the model\'s Malloy `given:` runtime parameters for this query; safe parameterization, values are bound by the runtime, not string-interpolated), `filterParams` (values for the model\'s legacy `#(filter)` source filters), `bypassFilters`, and `environment` / `package` (only if the page is served from outside `/environments/<env>/packages/<pkg>/`). `givens` and `filterParams` compose (both apply).\n\n## Structure the app as modules, not one inline script\n\nPast a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: put your shared libraries first as plain globals, then one ES-module entry point that `import`s your own files.\n\n```html\n<!-- Globals first: the runtime, then any vendored chart library. -->\n<script src="/sdk/publisher.js"></script>\n<script src="./vendor/chart.umd.js"></script>\n<!-- One module entry; it imports the rest. ES modules resolve with no bundler. -->\n<script type="module" src="./app.js"></script>\n```\n\nA separation that keeps each piece testable and changeable on its own:\n\n- **`format.js`**. Pure functions only: number/date formatting, a series-align-by-month helper, status thresholds. No DOM, no globals. This is the file `node --test` can cover directly.\n- **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library.\n- **`tiles.js`**. Your tiles as *data*: for each, its model/source/view (and target source, if any), plus a pure `build(rows)` that shapes query rows for the chart. This is the single source of truth for what each tile queries.\n- **`app.js`**. The thin entry point: reads `tiles.js`, runs the queries, wires results to the DOM. Adding a tile means adding a `tiles.js` entry, not editing `app.js`.\n\nDeclare each tile\'s source and view names once, in `tiles.js`, and have everything else (render code, any agent prompt, tests) read from there. A second copy of those names in another file is the classic drift bug, and a *derived* name (`okr_4_4_2_targets` invented from a tile code) is simply wrong: a target source may have an irregular name or not exist at all. Read the model; don\'t compute names.\n\n## Patterns that work\n\nThese run against the example `html-data-app` package (source `subscriptions`; views `plan_mix`, `mrr_by_industry`, `kpis`). Swap in your own model and view names.\n\nRun a named view:\n\n```js\nconst rows = await Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nRefine a view from UI state by appending a `where:`. Restrict the values to ones you control (for example a dropdown populated from the model\'s own distinct values) and escape each interpolated value with a backslash before quotes and backslashes (Malloy rejects the SQL-style `\'\'` doubling). An unescaped apostrophe in a value breaks out of the literal:\n\n```js\nfunction whereClause(state) {\n const q = (s) => s.replace(/\\\\/g, "\\\\\\\\").replace(/\'/g, "\\\\\'"); // backslash-escape for Malloy\n const parts = [];\n if (state.plan) parts.push(`plan = \'${q(state.plan)}\'`);\n if (state.industry) parts.push(`industry = \'${q(state.industry)}\'`);\n return parts.length ? `where: ${parts.join(", ")}` : "";\n}\nconst rows = await Publisher.query(\n "subscriptions.malloy",\n `run: subscriptions -> plan_mix + { ${whereClause(state)} }`,\n);\n```\n\nDo not interpolate free-text or otherwise untrusted input into the query string. Route parameterized input through `opts.givens` (or the legacy `opts.filterParams`) instead: those values are bound by the runtime as typed parameters, not string-interpolated, so they can\'t inject query syntax. (One nuance: a `filter<T>`-typed given takes Malloy filter syntax as its value, so validate it against a known set like any other input; scalar givens carry no syntax at all.) `opts.givens` is safe *parameterization*, not an authorization boundary: a client-supplied given is client-trusted unless a server upstream (a trusted gateway, or an operator\'s per-package config) strips or finalizes it. Where you must build query text from input, constrain it to a known set and escape it, or keep the filtering in model-defined views.\n\nKPI or single-row view. Destructure element zero:\n\n```js\nconst [kpis] = await Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis");\nel.textContent = kpis.active_mrr; // the result is an array; kpis.active_mrr, not rows.active_mrr\n```\n\nRefresh a dashboard. Fire the tiles together:\n\n```js\nconst [planMix, byIndustry, kpisRows] = await Promise.all([\n Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> mrr_by_industry"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis"),\n]);\n```\n\nPrefer defining the views in the model (one per tile, pre-aggregated and sorted) over building long query strings in JS.\n\nGet the numbers right. The fastest way to ship a wrong-but-convincing dashboard is to paper over missing data:\n\n- **Missing is not zero.** When you join two series (actuals to a separately-keyed target) and a key is absent, leave it `null` so the chart skips it. Do not `|| 0`, which plots a real-looking zero and reads as "we hit nothing that month." Align on a normalized key (`"YYYY-MM"`), and let the renderer omit null points:\n\n ```js\n // monthKey/monthLabel are your own format.js helpers: monthKey normalizes a\n // date to a "YYYY-MM" string; monthLabel formats it for display.\n // target may not cover every actual month; an absent month stays null, never 0.\n const target = new Map(planRows.map((r) => [monthKey(r.plan_month), Number(r.target_revenue)]));\n const data = actualRows.map((r) => ({\n label: monthLabel(r.order_month),\n actual: Number(r.revenue),\n target: target.has(monthKey(r.order_month)) ? target.get(monthKey(r.order_month)) : null,\n }));\n ```\n\n- **"Current" means latest non-null.** For a KPI scorecard, scan back to the last month that actually has data rather than reading the final row, which may be an incomplete current month.\n- **Guard division in Malloy, not after.** `avg(paid / nullif(active, 0))`. A `nullif` in the query beats catching `Infinity`/`NaN` in JS.\n- **Convert units explicitly.** If the model stores a 0 to 1 fraction and you show a percent, multiply once in `build()` and comment it. Mismatched units are a silent off-by-100.\n\nLoading, empty, and error states. Handle all three; a bare `.then()` that assumes rows leaves the page blank when the query is slow or fails:\n\n```js\nconst el = document.getElementById("out");\nel.textContent = "Loading...";\nPublisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix")\n .then((rows) => {\n if (!rows.length) { el.textContent = "No data."; return; }\n render(rows);\n })\n .catch((err) => {\n el.textContent = `Query failed (${err.status ?? ""}): ${err.response?.message ?? err.message}`;\n });\n```\n\nRender through `<malloy-render>`. `queryFull` returns the full Malloy result envelope (the JSON form of the server\'s result, not a live result object) to hand to the component:\n\n```js\nconst el = document.querySelector("malloy-render");\nel.result = await Publisher.queryFull("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nPublisher does not serve or bundle `<malloy-render>`; you must obtain a built component bundle matched to your model\'s Malloy version and vendor it into `public/` yourself, then confirm it accepts the envelope as-is. The shipped example renders rows with a plain chart library instead, so this path is not exercised there. A view tagged in the model (for example `# bar_chart`) drives how it draws.\n\nValidate every query before wiring it into render code, using whatever query tool your environment provides, or by POSTing the query to a running Publisher at `/api/v0/environments/<env>/packages/<pkg>/models/<modelPath>/query` with body `{"compactJson":true,"query":"..."}`, or by running `Publisher.query` once and logging the rows. Malloy names result columns after the `group_by` / `aggregate` field names (`group_by: plan` gives a `plan` column; `aggregate: account_count` gives an `account_count` column), so confirm those names against real output before you read them.\n\nIf you validate the rendered page in a headless browser (Playwright or Puppeteer), do not wait for network idle: `publisher.js` holds the live-reload SSE stream open, so the page never reaches it. Wait on `domcontentloaded` or `load` plus a content selector instead.\n\n## Context, auth, live reload (all automatic)\n\n- Context. A page served under `/environments/<env>/packages/<pkg>/...` infers its environment and package, so `query` needs no env or package args. Serving from elsewhere? Pass `opts.environment` and `opts.package`.\n- Auth. By default the runtime sends cookies (`credentials: include`), so a signed-in user is authenticated with no code. For a bearer token, call `Publisher.setToken(token)` first; `Publisher.setToken(null)` reverts to cookies.\n- Live reload. Under `--watch-env`, the page reloads on package changes by itself. Nothing to wire.\n\n## When the app fails\n\n| Symptom | Likely cause and fix |\n|---|---|\n| 404 or "model not found" | `modelPath` wrong. It is the file path (`"subscriptions.malloy"`), with `/` separators, not the source name. |\n| "source/view not defined" | View or source name guessed. Read the model (your environment\'s context tool, or open the `.malloy` file) and use the real names. |\n| Promise rejects, message starts `Publisher.query:` | Read `error.status` and `error.response` for the server\'s reason (compile error, missing required parameter, permission). |\n| Empty array when you expect rows | Filter value mismatch (case, spelling, type, or a non-ASCII character like `≤` or an en-dash in the literal). Copy the literal verbatim from the model, do not retype the user\'s paraphrase, and confirm it with a distinct-values query (`run: src -> { group_by: the_dimension }`). Quote strings, use `@` for dates. |\n| 400 on a given (ungated source) | An unknown given name (check spelling; names are case-sensitive), a required given left unset, or a value that doesn\'t fit the declared type. Malloy rejects it when preparing the query; supply declared givens via `opts.givens` with the right shape (see the givens type table). |\n| 403 on a query that should be allowed, when passing givens to a gated source | On a source with `#(authorize)`, a bad given (unknown name or wrong-typed value) fails closed in the authorize check, so it looks like access denied rather than validation. Check the given names and values against the model. |\n| KPI shows `undefined` | The result is an array. Read `rows[0].field` (or destructure `const [k] = ...`), not `rows.field`. |\n| Page loads in dev but is not listed or not served | The file is not under the package\'s `public/` directory. Publisher serves only `public/`; a page written anywhere else (for example `/tmp`) is never reachable at `/environments/<env>/packages/<pkg>/<file>`. |\n| Queries fail only when embedded cross-origin | Cookies are not sent cross-site. Serve same-origin, or pass a bearer token. |\n| No live reload | Watch mode is off. Start with `--watch-env <env>`; without it the events stream reports `mode: disabled` and never reloads. |' }, { name: "malloy-html-data-apps", description: "Build or modify an in-package HTML data app for a Malloy Publisher package (a public/ directory the package serves). Use when the user wants a hand-authored HTML dashboard or web page backed by a package's Malloy models, with no build step.", body: `# In-Package HTML Data Apps
|
|
293651
|
+
- **Environment-level theming is not applied yet.** Only the instance theme and the \`# theme.*\` / \`## theme.*\` per-chart annotations take effect today.` }, { name: "malloy-html-data-app-embedding", description: "Embed an in-package HTML data app into a host page or another application, including auto-sizing and auth. Read when embedding a Publisher page via Publisher.embed.", body: '# Embedding an HTML Data App\n\n> `Publisher.embed(selector, { src })` drops a package page into a host page as a sandboxed, auto-resizing iframe. Same-origin embeds authenticate with the browser\'s cookies; cross-origin embeds need a signed token.\n\n## The host-page pattern\n\n```html\n<script src="https://your-publisher/sdk/publisher.js"></script>\n<div id="dashboard"></div>\n<script>\n const handle = Publisher.embed("#dashboard", {\n src: "https://your-publisher/environments/demo/packages/sales/index.html",\n });\n // handle.destroy() removes the iframe and detaches its listeners.\n</script>\n```\n\n`embed(selector, options)` returns `{ iframe, destroy() }`. Options: `src` (required), `token` (a signed token for cross-origin auth, appended as `embed_token`), `height` (omit to auto-size; a number is treated as pixels), and `allow` (the iframe permissions policy).\n\n## Sizing and the resize contract\n\nOmit `height` and the frame auto-sizes. The embedded page measures its real content height and posts a `publisher:resize` message to the host, which resizes the iframe and accepts that message only from the iframe it created. You write none of this; it ships in `/sdk/publisher.js`, so the embedded page only has to load that script.\n\nDo not rely on `body { min-height: 100vh }` to drive the frame height. The runtime deliberately measures the content\'s bottom edge, not the viewport, to avoid a grow-forever loop.\n\n## Auth\n\n- Same-origin or same-tenant: pass no token. The browser\'s cookies authenticate the iframe.\n- Cross-origin: mint a short-lived signed token server-side and pass it as `options.token`. The runtime appends it to the iframe URL as `embed_token`; the embedded page must read it (from `location.search`) and call `Publisher.setToken(token)`. Because it rides in the URL, it can land in browser history, Referer headers, and server logs, so keep it short-lived and scoped to that one embed, and never put a long-lived or admin token in client HTML.\n\n## Guardrails (v1)\n\n- The iframe is sandboxed (`allow-scripts allow-same-origin allow-forms`). Design for that: no top-level navigation, no popups.\n- Embedded author JavaScript runs with the viewing user\'s data authority, so treat everything under `public/` as strictly first-party code: do not load untrusted third-party scripts, and do not move query results off to other hosts. Tighter per-embed isolation is planned.' }, { name: "malloy-html-data-app-runtime", description: "Write the JavaScript that drives an in-package HTML data app, calling Publisher.query, building queries from filter state, and handling results and errors. Read before writing the page's data code.", body: '# HTML Data App Runtime\n\n> `Publisher.query(modelPath, malloy)` returns an array of plain row objects. Build the Malloy string, let the model do the work, and render the rows with whatever front-end code you like.\n\nThe runtime loads from the root-relative `<script src="/sdk/publisher.js">` and adds one global, `window.Publisher`.\n\n## The query contract\n\n| Call | Returns | Use for |\n|---|---|---|\n| `Publisher.query(modelPath, malloy, opts?)` | `Promise<Array>` of rows | driving your own charts and tables |\n| `Publisher.queryFull(modelPath, malloy, opts?)` | `Promise<MalloyResult>` | handing to `<malloy-render>` |\n\n- `modelPath` is the model FILE path within the package, with `/` separators (`"subscriptions.malloy"`, `"models/events.malloy"`). It is not the source name.\n- `malloy` is any query string, written in standard Malloy. This skill covers only the JavaScript glue, not Malloy syntax.\n- `opts` (all optional): `sourceName`, `queryName` (`queryName` runs a saved query, with `sourceName` qualifying the source a view hangs off; `sourceName` on its own is a 400), `givens` (a `{ name: value }` map bound to the model\'s Malloy `given:` runtime parameters for this query; safe parameterization, values are bound by Publisher server-side, not string-interpolated), `filterParams` (values for the model\'s legacy `#(filter)` source filters), `bypassFilters`, and `environment` / `package` (only if the page is served from outside `/environments/<env>/packages/<pkg>/`). `givens` and `filterParams` compose (both apply).\n\n## Structure the app as modules, not one inline script\n\nPast a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: put your shared libraries first as plain globals, then one ES-module entry point that `import`s your own files.\n\n```html\n<!-- Globals first: the runtime, then any vendored chart library. -->\n<script src="/sdk/publisher.js"></script>\n<script src="./vendor/chart.umd.js"></script>\n<!-- One module entry; it imports the rest. ES modules resolve with no bundler. -->\n<script type="module" src="./app.js"></script>\n```\n\nA separation that keeps each piece testable and changeable on its own:\n\n- **`format.js`**. Pure functions only: number/date formatting, a series-align-by-month helper, status thresholds. No DOM, no globals. This is the file `node --test` can cover directly.\n- **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library.\n- **`tiles.js`**. Your tiles as *data*: for each, its model/source/view (and target source, if any), plus a pure `build(rows)` that shapes query rows for the chart. This is the single source of truth for what each tile queries.\n- **`app.js`**. The thin entry point: reads `tiles.js`, runs the queries, wires results to the DOM. Adding a tile means adding a `tiles.js` entry, not editing `app.js`.\n\nDeclare each tile\'s source and view names once, in `tiles.js`, and have everything else (render code, any agent prompt, tests) read from there. A second copy of those names in another file is the classic drift bug, and a *derived* name (`okr_4_4_2_targets` invented from a tile code) is simply wrong: a target source may have an irregular name or not exist at all. Read the model; don\'t compute names.\n\n## Patterns that work\n\nThese assume a `subscriptions.malloy` model whose `subscriptions` source defines the views `plan_mix`, `mrr_by_industry`, and `kpis`. The names are illustrative; swap in your own model and view names.\n\nRun a named view:\n\n```js\nconst rows = await Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nRefine a view from UI state by appending a `where:`. Restrict the values to ones you control (for example a dropdown populated from the model\'s own distinct values) and escape each interpolated value with a backslash before quotes and backslashes (Malloy rejects the SQL-style `\'\'` doubling). An unescaped apostrophe in a value breaks out of the literal:\n\n```js\nfunction whereClause(state) {\n const q = (s) => s.replace(/\\\\/g, "\\\\\\\\").replace(/\'/g, "\\\\\'"); // backslash-escape for Malloy\n const parts = [];\n if (state.plan) parts.push(`plan = \'${q(state.plan)}\'`);\n if (state.industry) parts.push(`industry = \'${q(state.industry)}\'`);\n return parts.length ? `where: ${parts.join(", ")}` : "";\n}\nconst rows = await Publisher.query(\n "subscriptions.malloy",\n `run: subscriptions -> plan_mix + { ${whereClause(state)} }`,\n);\n```\n\nDo not interpolate free-text or otherwise untrusted input into the query string. Route parameterized input through `opts.givens` (or the legacy `opts.filterParams`) instead: Publisher binds those values server-side as typed parameters, so they are never concatenated into query text and can\'t inject query syntax. (One nuance: a `filter<T>`-typed given takes Malloy filter syntax as its value, so validate it against a known set like any other input; scalar givens carry no syntax at all.) `opts.givens` is safe *parameterization*, not an authorization boundary: a client-supplied given is client-trusted unless a trusted tier upstream sets it from verified identity. Publisher has no per-package control that strips or finalizes one; identity-bound givens are a planned milestone, not a shipped feature. Where you must build query text from input, constrain it to a known set and escape it, or keep the filtering in model-defined views.\n\nKPI or single-row view. Destructure element zero:\n\n```js\nconst [kpis] = await Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis");\nel.textContent = kpis.active_mrr; // the result is an array; kpis.active_mrr, not rows.active_mrr\n```\n\nRefresh a dashboard. Fire the tiles together:\n\n```js\nconst [planMix, byIndustry, kpisRows] = await Promise.all([\n Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> mrr_by_industry"),\n Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis"),\n]);\n```\n\nPrefer defining the views in the model (one per tile, pre-aggregated and sorted) over building long query strings in JS.\n\nGet the numbers right. The fastest way to ship a wrong-but-convincing dashboard is to paper over missing data:\n\n- **Missing is not zero.** When you join two series (actuals to a separately-keyed target) and a key is absent, leave it `null` so the chart skips it. Do not `|| 0`, which plots a real-looking zero and reads as "we hit nothing that month." Align on a normalized key (`"YYYY-MM"`), and let the renderer omit null points:\n\n ```js\n // monthKey/monthLabel are your own format.js helpers: monthKey normalizes a\n // date to a "YYYY-MM" string; monthLabel formats it for display.\n // target may not cover every actual month; an absent month stays null, never 0.\n const target = new Map(planRows.map((r) => [monthKey(r.plan_month), Number(r.target_revenue)]));\n const data = actualRows.map((r) => ({\n label: monthLabel(r.order_month),\n actual: Number(r.revenue),\n target: target.has(monthKey(r.order_month)) ? target.get(monthKey(r.order_month)) : null,\n }));\n ```\n\n- **"Current" means latest non-null.** For a KPI scorecard, scan back to the last month that actually has data rather than reading the final row, which may be an incomplete current month.\n- **Guard division in Malloy, not after.** `avg(paid / nullif(active, 0))`. A `nullif` in the query beats catching `Infinity`/`NaN` in JS.\n- **Convert units explicitly.** If the model stores a 0 to 1 fraction and you show a percent, multiply once in `build()` and comment it. Mismatched units are a silent off-by-100.\n\nLoading, empty, and error states. Handle all three; a bare `.then()` that assumes rows leaves the page blank when the query is slow or fails:\n\n```js\nconst el = document.getElementById("out");\nel.textContent = "Loading...";\nPublisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix")\n .then((rows) => {\n if (!rows.length) { el.textContent = "No data."; return; }\n render(rows);\n })\n .catch((err) => {\n el.textContent = `Query failed (${err.status ?? ""}): ${err.response?.message ?? err.message}`;\n });\n```\n\nRender through `<malloy-render>`. `queryFull` returns the full Malloy result envelope (the JSON form of the server\'s result, not a live result object) to hand to the component:\n\n```js\nconst el = document.querySelector("malloy-render");\nel.result = await Publisher.queryFull("subscriptions.malloy", "run: subscriptions -> plan_mix");\n```\n\nPublisher does not serve or bundle `<malloy-render>`; you must obtain a built component bundle matched to your model\'s Malloy version and vendor it into `public/` yourself, then confirm it accepts the envelope as-is. The `storefront` example draws with Chart.js and has no `<malloy-render>` element in its pages, so the component itself is not exercised there. A view tagged in the model (for example `# bar_chart`) drives how it draws.\n\nValidate every query before wiring it into render code, using whatever query tool your environment provides, or by POSTing the query to a running Publisher at `/api/v0/environments/<env>/packages/<pkg>/models/<modelPath>/query` with body `{"compactJson":true,"query":"..."}`, or by running `Publisher.query` once and logging the rows. Malloy names result columns after the `group_by` / `aggregate` field names (`group_by: plan` gives a `plan` column; `aggregate: account_count` gives an `account_count` column), so confirm those names against real output before you read them.\n\nIf you validate the rendered page in a headless browser (Playwright or Puppeteer), do not wait for network idle: `publisher.js` holds the live-reload SSE stream open, so the page never reaches it. Wait on `domcontentloaded` or `load` plus a content selector instead.\n\n## Context, auth, live reload (all automatic)\n\n- Context. A page served under `/environments/<env>/packages/<pkg>/...` infers its environment and package, so `query` needs no env or package args. Serving from elsewhere? Pass `opts.environment` and `opts.package`.\n- Auth. By default the runtime sends cookies (`credentials: include`), so a signed-in user is authenticated with no code. For a bearer token, call `Publisher.setToken(token)` first; `Publisher.setToken(null)` reverts to cookies.\n- Live reload. Under `--watch-env`, the page reloads on package changes by itself. Nothing to wire.\n\n## When the app fails\n\n| Symptom | Likely cause and fix |\n|---|---|\n| 404 or "model not found" | `modelPath` wrong. It is the file path (`"subscriptions.malloy"`), with `/` separators, not the source name. |\n| "source/view not defined" | View or source name guessed. Read the model (your environment\'s context tool, or open the `.malloy` file) and use the real names. |\n| Promise rejects, message starts `Publisher.query:` | Read `error.status` and `error.response` for the server\'s reason (compile error, missing required parameter, permission). |\n| Empty array when you expect rows | Filter value mismatch (case, spelling, type, or a non-ASCII character like `≤` or an en-dash in the literal). Copy the literal verbatim from the model, do not retype the user\'s paraphrase, and confirm it with a distinct-values query (`run: src -> { group_by: the_dimension }`). Quote strings, use `@` for dates. |\n| 400 on a given (ungated source) | An unknown given name (check spelling; names are case-sensitive), a required given left unset, or a value that doesn\'t fit the declared type. Malloy rejects it when preparing the query; supply declared givens via `opts.givens` with the right shape (see the givens type table). |\n| 403 on a query that should be allowed, when passing givens to a gated source | On a source with `#(authorize)`, a bad given (unknown name or wrong-typed value) fails closed in the authorize check, so it looks like access denied rather than validation. Check the given names and values against the model. |\n| KPI shows `undefined` | The result is an array. Read `rows[0].field` (or destructure `const [k] = ...`), not `rows.field`. |\n| Page loads in dev but is not listed or not served | The file is not under the package\'s `public/` directory. Publisher serves only `public/`; a page written anywhere else (for example `/tmp`) is never reachable at `/environments/<env>/packages/<pkg>/<file>`. |\n| Queries fail only when embedded cross-origin | Cookies are not sent cross-site. Serve same-origin, or pass a bearer token. |\n| No live reload | Watch mode is off. Start with `--watch-env <env>`; without it the events stream reports `mode: disabled` and never reloads. |' }, { name: "malloy-html-data-apps", description: "Build or modify an in-package HTML data app for a Malloy Publisher package (a public/ directory the package serves). Use when the user wants a hand-authored HTML dashboard or web page backed by a package's Malloy models, with no build step.", body: `# In-Package HTML Data Apps
|
|
291217
293652
|
|
|
291218
293653
|
> A package becomes a web app by adding a \`public/\` directory. Publisher serves those files and gives the page \`Publisher.query(...)\` to run Malloy against the package's models. No build step, no npm, no framework.
|
|
291219
293654
|
|
|
@@ -291251,7 +293686,7 @@ The agent orchestrates these. Each query and chart step hands off to a focused s
|
|
|
291251
293686
|
1. READ THE MODEL FIRST. Get the model's real source and view names, through your environment's context tool if it has one, or by opening the \`.malloy\` file directly. Never guess field or view names.
|
|
291252
293687
|
2. SCAFFOLD the package (template below).
|
|
291253
293688
|
3. WRITE THE QUERIES with \`skill:malloy-html-data-app-runtime\`. Validate each before pasting it into the page, using whatever query tool your environment provides or a running Publisher (see \`skill:malloy-html-data-app-runtime\`). Malloy syntax questions go to \`skill:malloy-queries\`.
|
|
291254
|
-
4. CHOOSE CHARTS with \`skill:malloy-charts\` when rendering through \`<malloy-render>\`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into \`public/\` and load it locally, not from a CDN. Two reasons: embedded author JavaScript runs with the viewing user's data authority, and a blocked CDN (agent sandboxes and many corporate networks block them) is easy to miss, because the script never runs and the charts come up empty. The \`
|
|
293689
|
+
4. CHOOSE CHARTS with \`skill:malloy-charts\` when rendering through \`<malloy-render>\`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into \`public/\` and load it locally, not from a CDN. Two reasons: embedded author JavaScript runs with the viewing user's data authority, and a blocked CDN (agent sandboxes and many corporate networks block them) is easy to miss, because the script never runs and the charts come up empty. The \`storefront\` example ships its chart library in \`public/vendor/\` and loads it from \`public/index.html\` as \`./vendor/chart.umd.js\`. Copy that, but resolve the path against the page's own directory: a page in a subdirectory (\`public/reports/index.html\`) needs \`../vendor/chart.umd.js\`. A wrong relative path 404s and leaves the charts blank, which is the failure you are trying to avoid.
|
|
291255
293690
|
5. EMBED (optional) with \`skill:malloy-html-data-app-embedding\`.
|
|
291256
293691
|
6. PREVIEW with the local authoring loop (below).
|
|
291257
293692
|
7. VERIFY before you call it done (see "What 'done' means" below). This step is not optional.
|
|
@@ -291288,7 +293723,7 @@ You are building for someone who cannot tell a correct dashboard from a broken o
|
|
|
291288
293723
|
{ "name": "my-package", "version": "0.0.1", "description": "..." }
|
|
291289
293724
|
\`\`\`
|
|
291290
293725
|
|
|
291291
|
-
\`public/index.html\` is a NEW file you create (make the \`public/\` directory if it does not exist). Load the runtime root-relative, then query. The examples below
|
|
293726
|
+
\`public/index.html\` is a NEW file you create (make the \`public/\` directory if it does not exist). Load the runtime root-relative, then query. The examples below assume a \`subscriptions.malloy\` model with a \`subscriptions\` source; the names are illustrative, so swap in your own model and a view it defines.
|
|
291292
293727
|
|
|
291293
293728
|
Start with the smallest page that proves the wiring, dumping the rows:
|
|
291294
293729
|
|
|
@@ -295572,6 +298007,8 @@ class MaterializationService {
|
|
|
295572
298007
|
const destination = resolveStorageDestination(persistSource);
|
|
295573
298008
|
if (destination) {
|
|
295574
298009
|
assertMaterializationEligible(persistSource);
|
|
298010
|
+
} else {
|
|
298011
|
+
assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, compiled.preaggregatePlans?.[persistSource.sourceID] ? "preaggregate" : "persist");
|
|
295575
298012
|
}
|
|
295576
298013
|
const sourceEntityId = computeSourceEntityId(persistSource, compiled.connectionDigests);
|
|
295577
298014
|
if (seen.has(sourceEntityId))
|
|
@@ -295834,6 +298271,8 @@ class MaterializationService {
|
|
|
295834
298271
|
const orchestratedInstruction = bySourceID.get(persistSource.sourceID);
|
|
295835
298272
|
if (orchestratedInstruction?.destination && getPersistStorageMode() !== "off") {
|
|
295836
298273
|
assertMaterializationEligible(persistSource);
|
|
298274
|
+
} else {
|
|
298275
|
+
assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, compiled.preaggregatePlans?.[persistSource.sourceID] ? "preaggregate" : "persist");
|
|
295837
298276
|
}
|
|
295838
298277
|
const sourceEntityId = computeSourceEntityId(persistSource, connectionDigests);
|
|
295839
298278
|
const instruction = orchestratedInstruction ?? bySourceEntityId.get(sourceEntityId);
|
|
@@ -296744,7 +299183,12 @@ var ASSET_EXTENSIONS = new Set([
|
|
|
296744
299183
|
"woff2",
|
|
296745
299184
|
"xlsx"
|
|
296746
299185
|
]);
|
|
296747
|
-
var SPA_OWNED_SEGMENTS = new Set([
|
|
299186
|
+
var SPA_OWNED_SEGMENTS = new Set([
|
|
299187
|
+
"dashboards",
|
|
299188
|
+
"data-apps",
|
|
299189
|
+
"pages",
|
|
299190
|
+
"workbook"
|
|
299191
|
+
]);
|
|
296748
299192
|
function extensionOf(segment) {
|
|
296749
299193
|
const dot = segment.lastIndexOf(".");
|
|
296750
299194
|
if (dot <= 0 || dot === segment.length - 1)
|
|
@@ -296893,6 +299337,7 @@ var memoryGovernor = memoryGovernorConfig ? new PackageMemoryGovernor(memoryGove
|
|
|
296893
299337
|
memoryGovernor?.start();
|
|
296894
299338
|
environmentStore.setMemoryGovernor(memoryGovernor);
|
|
296895
299339
|
var packageController = new PackageController(environmentStore);
|
|
299340
|
+
var dashboardController = new DashboardController(environmentStore);
|
|
296896
299341
|
var databaseController = new DatabaseController(environmentStore);
|
|
296897
299342
|
var queryController = new QueryController(environmentStore);
|
|
296898
299343
|
var compileController = new CompileController(environmentStore);
|
|
@@ -297608,6 +300053,32 @@ app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/mode
|
|
|
297608
300053
|
res.status(status).json(json);
|
|
297609
300054
|
}
|
|
297610
300055
|
});
|
|
300056
|
+
app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/dashboards`, async (req, res) => {
|
|
300057
|
+
if (req.query.versionId) {
|
|
300058
|
+
setVersionIdError2(res);
|
|
300059
|
+
return;
|
|
300060
|
+
}
|
|
300061
|
+
try {
|
|
300062
|
+
res.status(200).json(await dashboardController.listDashboards(req.params.environmentName, req.params.packageName));
|
|
300063
|
+
} catch (error) {
|
|
300064
|
+
logger.error(error);
|
|
300065
|
+
const { json, status } = internalErrorToHttpError(error);
|
|
300066
|
+
res.status(status).json(json);
|
|
300067
|
+
}
|
|
300068
|
+
});
|
|
300069
|
+
app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/dashboards/:dashboardName`, async (req, res) => {
|
|
300070
|
+
if (req.query.versionId) {
|
|
300071
|
+
setVersionIdError2(res);
|
|
300072
|
+
return;
|
|
300073
|
+
}
|
|
300074
|
+
try {
|
|
300075
|
+
res.status(200).json(await dashboardController.getDashboard(req.params.environmentName, req.params.packageName, req.params.dashboardName));
|
|
300076
|
+
} catch (error) {
|
|
300077
|
+
logger.error(error);
|
|
300078
|
+
const { json, status } = internalErrorToHttpError(error);
|
|
300079
|
+
res.status(status).json(json);
|
|
300080
|
+
}
|
|
300081
|
+
});
|
|
297611
300082
|
app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/notebooks`, async (req, res) => {
|
|
297612
300083
|
if (req.query.versionId) {
|
|
297613
300084
|
setVersionIdError2(res);
|