@malloydata/malloyyo 0.2.11 → 0.2.12
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/index.js +568 -206
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -62,82 +62,6 @@ function resolveInstance(dir, arg) {
|
|
|
62
62
|
import { readFileSync as readFileSync2, existsSync as existsSync2, readdirSync, statSync } from "node:fs";
|
|
63
63
|
import { join as join2, relative, sep } from "node:path";
|
|
64
64
|
import { execFileSync } from "node:child_process";
|
|
65
|
-
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
66
|
-
function gatherDirectory(dir) {
|
|
67
|
-
const files = [];
|
|
68
|
-
const walk = (cur) => {
|
|
69
|
-
for (const entry of readdirSync(cur)) {
|
|
70
|
-
if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
|
|
71
|
-
const full = join2(cur, entry);
|
|
72
|
-
if (statSync(full).isDirectory()) {
|
|
73
|
-
walk(full);
|
|
74
|
-
} else if (entry.endsWith(".malloy")) {
|
|
75
|
-
files.push({
|
|
76
|
-
path: relative(dir, full).split(sep).join("/"),
|
|
77
|
-
content: readFileSync2(full, "utf8")
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
walk(dir);
|
|
83
|
-
const configPath = join2(dir, "malloy-config.json");
|
|
84
|
-
const config = existsSync2(configPath) ? readFileSync2(configPath, "utf8") : void 0;
|
|
85
|
-
return { files, config };
|
|
86
|
-
}
|
|
87
|
-
function listDashboardDirs(dir) {
|
|
88
|
-
const base = join2(dir, "dashboards");
|
|
89
|
-
if (!existsSync2(base)) return [];
|
|
90
|
-
return readdirSync(base).filter((name) => {
|
|
91
|
-
const d = join2(base, name);
|
|
92
|
-
return statSync(d).isDirectory() && existsSync2(join2(d, "manifest.json"));
|
|
93
|
-
}).sort();
|
|
94
|
-
}
|
|
95
|
-
function gatherDashboards(dir) {
|
|
96
|
-
const base = join2(dir, "dashboards");
|
|
97
|
-
return listDashboardDirs(dir).map((name) => {
|
|
98
|
-
const raw = readFileSync2(join2(base, name, "manifest.json"), "utf8");
|
|
99
|
-
let manifest;
|
|
100
|
-
try {
|
|
101
|
-
manifest = JSON.parse(raw);
|
|
102
|
-
} catch (e) {
|
|
103
|
-
throw new Error(`dashboards/${name}/manifest.json: invalid JSON (${e.message})`);
|
|
104
|
-
}
|
|
105
|
-
const tsxPath = join2(base, name, "Dashboard.tsx");
|
|
106
|
-
if (!existsSync2(tsxPath)) throw new Error(`dashboards/${name}: missing Dashboard.tsx`);
|
|
107
|
-
return { name, manifest, source: readFileSync2(tsxPath, "utf8") };
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
function gitInfo(dir) {
|
|
111
|
-
const git = (args) => execFileSync("git", args, {
|
|
112
|
-
cwd: dir,
|
|
113
|
-
encoding: "utf8",
|
|
114
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
115
|
-
// suppress git's own stderr (e.g. "no remote 'origin'")
|
|
116
|
-
}).trim();
|
|
117
|
-
try {
|
|
118
|
-
let repo;
|
|
119
|
-
try {
|
|
120
|
-
repo = git(["remote", "get-url", "origin"]).replace(
|
|
121
|
-
/^.*[:/]([^/]+\/[^/]+?)(?:\.git)?$/,
|
|
122
|
-
"$1"
|
|
123
|
-
);
|
|
124
|
-
} catch {
|
|
125
|
-
}
|
|
126
|
-
return {
|
|
127
|
-
repo,
|
|
128
|
-
branch: git(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
129
|
-
sha: git(["rev-parse", "HEAD"]),
|
|
130
|
-
dirty: git(["status", "--porcelain"]).length > 0
|
|
131
|
-
};
|
|
132
|
-
} catch {
|
|
133
|
-
return {};
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// src/lint.ts
|
|
138
|
-
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
139
|
-
import { join as join3, resolve } from "node:path";
|
|
140
|
-
import * as esbuild from "esbuild";
|
|
141
65
|
|
|
142
66
|
// src/host.ts
|
|
143
67
|
import fs from "node:fs";
|
|
@@ -1135,6 +1059,146 @@ async function runRestricted(runtime, entry, query, opts = {}) {
|
|
|
1135
1059
|
return { ok: false, problems: [...loadProblems, errorProblem(e, entry.href)] };
|
|
1136
1060
|
}
|
|
1137
1061
|
}
|
|
1062
|
+
function renderGivenType2(t) {
|
|
1063
|
+
if (t.type === "filter expression") {
|
|
1064
|
+
return t.filterType ? `filter<${t.filterType}>` : "filter";
|
|
1065
|
+
}
|
|
1066
|
+
if (t.type === "array") {
|
|
1067
|
+
const elem = t.elementTypeDef;
|
|
1068
|
+
if (!elem) return "array";
|
|
1069
|
+
if (elem.type === "record_element") return "record[]";
|
|
1070
|
+
return `${renderGivenType2(elem)}[]`;
|
|
1071
|
+
}
|
|
1072
|
+
return t.type;
|
|
1073
|
+
}
|
|
1074
|
+
function defaultValue(e) {
|
|
1075
|
+
if (!e) return void 0;
|
|
1076
|
+
switch (e.node) {
|
|
1077
|
+
case "filterLiteral":
|
|
1078
|
+
return e.filterSrc;
|
|
1079
|
+
case "stringLiteral":
|
|
1080
|
+
return e.literal;
|
|
1081
|
+
case "numberLiteral":
|
|
1082
|
+
return Number(e.literal);
|
|
1083
|
+
case "true":
|
|
1084
|
+
return true;
|
|
1085
|
+
case "false":
|
|
1086
|
+
return false;
|
|
1087
|
+
case "dateLiteral":
|
|
1088
|
+
case "timestampLiteral":
|
|
1089
|
+
case "timestamptzLiteral":
|
|
1090
|
+
return e.literal;
|
|
1091
|
+
default:
|
|
1092
|
+
return void 0;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
function describeGivenSpec(name, g) {
|
|
1096
|
+
const spec = { name, type: renderGivenType2(g.type) };
|
|
1097
|
+
if (g.type.type === "filter expression" && g.type.filterType) {
|
|
1098
|
+
spec.filterType = g.type.filterType;
|
|
1099
|
+
}
|
|
1100
|
+
const dflt = defaultValue(g.default);
|
|
1101
|
+
if (dflt !== void 0) spec.default = dflt;
|
|
1102
|
+
try {
|
|
1103
|
+
const docs = g.annotations.forRoute('"').map((n) => n.content.trim()).filter(Boolean);
|
|
1104
|
+
if (docs.length) spec.description = docs.join("\n");
|
|
1105
|
+
} catch {
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
const dict = g.annotations.parseAsTag().tag.dict ?? {};
|
|
1109
|
+
const tags = {};
|
|
1110
|
+
for (const [key, t] of Object.entries(dict)) {
|
|
1111
|
+
const eq = t.eq;
|
|
1112
|
+
if (typeof eq === "string" || typeof eq === "number" || typeof eq === "boolean") {
|
|
1113
|
+
tags[key] = eq;
|
|
1114
|
+
} else if (eq === void 0 && !t.dict) {
|
|
1115
|
+
tags[key] = true;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (Object.keys(tags).length) spec.tags = tags;
|
|
1119
|
+
const suggestDict = dict.suggest?.dict;
|
|
1120
|
+
if (suggestDict) {
|
|
1121
|
+
const suggest = {};
|
|
1122
|
+
for (const key of ["source", "dimension", "query"]) {
|
|
1123
|
+
const eq = suggestDict[key]?.eq;
|
|
1124
|
+
if (typeof eq === "string") suggest[key] = eq;
|
|
1125
|
+
}
|
|
1126
|
+
if (suggest.source || suggest.query) spec.suggest = suggest;
|
|
1127
|
+
}
|
|
1128
|
+
} catch {
|
|
1129
|
+
}
|
|
1130
|
+
return spec;
|
|
1131
|
+
}
|
|
1132
|
+
async function dashboardGivenSpecs(runtime, entry, queryName) {
|
|
1133
|
+
try {
|
|
1134
|
+
const mm = runtime.loadModel(entry);
|
|
1135
|
+
const model = await mm.getModel();
|
|
1136
|
+
const named = [...model.queries().named];
|
|
1137
|
+
if (!named.includes(queryName)) {
|
|
1138
|
+
return {
|
|
1139
|
+
ok: false,
|
|
1140
|
+
error: `no query named '${queryName}' (model has: ${named.join(", ") || "none"})`
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
const pq = await mm.loadQueryByName(queryName).getPreparedQuery();
|
|
1144
|
+
const specs = [];
|
|
1145
|
+
for (const [name, g] of pq.givens) {
|
|
1146
|
+
specs.push(describeGivenSpec(name, g));
|
|
1147
|
+
}
|
|
1148
|
+
return { ok: true, givens: specs };
|
|
1149
|
+
} catch (e) {
|
|
1150
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
function docText(t) {
|
|
1154
|
+
try {
|
|
1155
|
+
const docs = t.annotations.forRoute('"').map((n) => n.content.trim()).filter(Boolean);
|
|
1156
|
+
return docs.length ? docs.join("\n") : void 0;
|
|
1157
|
+
} catch {
|
|
1158
|
+
return void 0;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
function readArtifactTag(queryName, q) {
|
|
1162
|
+
let tag;
|
|
1163
|
+
try {
|
|
1164
|
+
tag = q.annotations.parseAsTag().tag;
|
|
1165
|
+
} catch {
|
|
1166
|
+
return void 0;
|
|
1167
|
+
}
|
|
1168
|
+
if (!tag.has("artifact")) return void 0;
|
|
1169
|
+
const nested = tag.tag("artifact");
|
|
1170
|
+
const description = docText(q);
|
|
1171
|
+
const name = nested?.text("name") ?? tag.text("name") ?? queryName;
|
|
1172
|
+
const title = nested?.text("title") ?? tag.text("title") ?? description?.split("\n")[0] ?? queryName;
|
|
1173
|
+
const info = { name, query: queryName, title };
|
|
1174
|
+
if (description) info.description = description;
|
|
1175
|
+
const givensTag = tag.tag("artifact", "givens");
|
|
1176
|
+
if (givensTag) {
|
|
1177
|
+
const givens = {};
|
|
1178
|
+
for (const [key, t] of Object.entries(givensTag.dict ?? {})) {
|
|
1179
|
+
const eq = t.eq;
|
|
1180
|
+
if (typeof eq === "string" || typeof eq === "number" || typeof eq === "boolean") {
|
|
1181
|
+
givens[key] = eq;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (Object.keys(givens).length) info.givens = givens;
|
|
1185
|
+
}
|
|
1186
|
+
return info;
|
|
1187
|
+
}
|
|
1188
|
+
async function artifactQueries(runtime, entry) {
|
|
1189
|
+
try {
|
|
1190
|
+
const model = await runtime.loadModel(entry).getModel();
|
|
1191
|
+
const artifacts = [];
|
|
1192
|
+
for (const queryName of model.queries().named) {
|
|
1193
|
+
const pq = model.getPreparedQueryByName(queryName);
|
|
1194
|
+
const info = readArtifactTag(queryName, pq);
|
|
1195
|
+
if (info) artifacts.push(info);
|
|
1196
|
+
}
|
|
1197
|
+
return { ok: true, artifacts };
|
|
1198
|
+
} catch (e) {
|
|
1199
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1138
1202
|
var INSTANCE_PLACEHOLDER = "{{INSTANCE_NAME}}";
|
|
1139
1203
|
function renderInstructions(text, instanceName) {
|
|
1140
1204
|
return text.replaceAll(INSTANCE_PLACEHOLDER, instanceName);
|
|
@@ -1621,6 +1685,25 @@ async function makeRunner(root) {
|
|
|
1621
1685
|
(runtime, entry) => run(runtime, entry, { name: queryName, givens, stableResult: true, rowLimit: 5e3 })
|
|
1622
1686
|
);
|
|
1623
1687
|
},
|
|
1688
|
+
runText(malloy, givens) {
|
|
1689
|
+
return lease(
|
|
1690
|
+
(runtime, entry) => runRestricted(runtime, entry, malloy, { givens, stableResult: true, rowLimit: 5e3 })
|
|
1691
|
+
);
|
|
1692
|
+
},
|
|
1693
|
+
validateText(malloy) {
|
|
1694
|
+
return lease(async (runtime, entry) => {
|
|
1695
|
+
const v = await validateRestricted(runtime, entry, malloy);
|
|
1696
|
+
if (v.ok) return { ok: true };
|
|
1697
|
+
const msg = v.problems.filter((p) => p.severity === "error").map((p) => p.message).join("; ");
|
|
1698
|
+
return { ok: false, error: msg || "restricted query failed to compile" };
|
|
1699
|
+
});
|
|
1700
|
+
},
|
|
1701
|
+
givensForQuery(queryName) {
|
|
1702
|
+
return lease((runtime, entry) => dashboardGivenSpecs(runtime, entry, queryName));
|
|
1703
|
+
},
|
|
1704
|
+
artifacts() {
|
|
1705
|
+
return lease((runtime, entry) => artifactQueries(runtime, entry));
|
|
1706
|
+
},
|
|
1624
1707
|
validate(queryName, givens) {
|
|
1625
1708
|
return lease(async (runtime, entry) => {
|
|
1626
1709
|
try {
|
|
@@ -1642,51 +1725,138 @@ async function makeRunner(root) {
|
|
|
1642
1725
|
};
|
|
1643
1726
|
}
|
|
1644
1727
|
|
|
1728
|
+
// src/gather.ts
|
|
1729
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
1730
|
+
function gatherDirectory(dir) {
|
|
1731
|
+
const files = [];
|
|
1732
|
+
const walk = (cur) => {
|
|
1733
|
+
for (const entry of readdirSync(cur)) {
|
|
1734
|
+
if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
|
|
1735
|
+
const full = join2(cur, entry);
|
|
1736
|
+
if (statSync(full).isDirectory()) {
|
|
1737
|
+
walk(full);
|
|
1738
|
+
} else if (entry.endsWith(".malloy")) {
|
|
1739
|
+
files.push({
|
|
1740
|
+
path: relative(dir, full).split(sep).join("/"),
|
|
1741
|
+
content: readFileSync2(full, "utf8")
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
walk(dir);
|
|
1747
|
+
const configPath = join2(dir, "malloy-config.json");
|
|
1748
|
+
const config = existsSync2(configPath) ? readFileSync2(configPath, "utf8") : void 0;
|
|
1749
|
+
return { files, config };
|
|
1750
|
+
}
|
|
1751
|
+
function listDashboardDirs(dir) {
|
|
1752
|
+
const base = join2(dir, "dashboards");
|
|
1753
|
+
if (!existsSync2(base)) return [];
|
|
1754
|
+
return readdirSync(base).filter((name) => statSync(join2(base, name)).isDirectory()).sort();
|
|
1755
|
+
}
|
|
1756
|
+
async function gatherDashboards(dir) {
|
|
1757
|
+
const runner = await makeRunner(dir);
|
|
1758
|
+
const res = await runner.artifacts();
|
|
1759
|
+
if (!res.ok) throw new Error(`model error: ${res.error}`);
|
|
1760
|
+
return res.artifacts.map((a) => {
|
|
1761
|
+
const tsxPath = join2(dir, "dashboards", a.name, "Dashboard.tsx");
|
|
1762
|
+
const manifest = { title: a.title, query: a.query };
|
|
1763
|
+
if (a.description) manifest.description = a.description;
|
|
1764
|
+
if (a.givens) manifest.givens = a.givens;
|
|
1765
|
+
return {
|
|
1766
|
+
name: a.name,
|
|
1767
|
+
manifest,
|
|
1768
|
+
source: existsSync2(tsxPath) ? readFileSync2(tsxPath, "utf8") : ""
|
|
1769
|
+
};
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
function gitInfo(dir) {
|
|
1773
|
+
const git = (args) => execFileSync("git", args, {
|
|
1774
|
+
cwd: dir,
|
|
1775
|
+
encoding: "utf8",
|
|
1776
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1777
|
+
// suppress git's own stderr (e.g. "no remote 'origin'")
|
|
1778
|
+
}).trim();
|
|
1779
|
+
try {
|
|
1780
|
+
let repo;
|
|
1781
|
+
try {
|
|
1782
|
+
repo = git(["remote", "get-url", "origin"]).replace(
|
|
1783
|
+
/^.*[:/]([^/]+\/[^/]+?)(?:\.git)?$/,
|
|
1784
|
+
"$1"
|
|
1785
|
+
);
|
|
1786
|
+
} catch {
|
|
1787
|
+
}
|
|
1788
|
+
return {
|
|
1789
|
+
repo,
|
|
1790
|
+
branch: git(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
1791
|
+
sha: git(["rev-parse", "HEAD"]),
|
|
1792
|
+
dirty: git(["status", "--porcelain"]).length > 0
|
|
1793
|
+
};
|
|
1794
|
+
} catch {
|
|
1795
|
+
return {};
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1645
1799
|
// src/lint.ts
|
|
1800
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
1801
|
+
import { join as join3, resolve } from "node:path";
|
|
1802
|
+
import * as esbuild from "esbuild";
|
|
1803
|
+
var quoteField = (f) => /^[A-Za-z_]\w*$/.test(f) ? f : `\`${f}\``;
|
|
1646
1804
|
async function lintDashboards(root) {
|
|
1647
1805
|
const abs = resolve(root);
|
|
1648
|
-
const names = listDashboardDirs(abs);
|
|
1649
1806
|
const dashboards = [];
|
|
1650
|
-
if (names.length === 0) return { ok: true, dashboards };
|
|
1651
1807
|
const runner = await makeRunner(abs);
|
|
1652
1808
|
if (!runner.entryExists()) {
|
|
1653
1809
|
return { ok: false, dashboards: [{ name: "(model)", errors: [`no index.malloy at ${abs}`] }] };
|
|
1654
1810
|
}
|
|
1655
|
-
|
|
1811
|
+
const arts = await runner.artifacts();
|
|
1812
|
+
if (!arts.ok) {
|
|
1813
|
+
return { ok: false, dashboards: [{ name: "(model)", errors: [arts.error] }] };
|
|
1814
|
+
}
|
|
1815
|
+
const artifacts = arts.artifacts;
|
|
1816
|
+
const dirs = listDashboardDirs(abs);
|
|
1817
|
+
if (artifacts.length === 0 && dirs.length === 0) return { ok: true, dashboards };
|
|
1818
|
+
const declared = new Set(artifacts.map((a) => a.name));
|
|
1819
|
+
for (const dir of dirs) {
|
|
1656
1820
|
const errors = [];
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1821
|
+
if (existsSync3(join3(abs, "dashboards", dir, "manifest.json"))) {
|
|
1822
|
+
errors.push(
|
|
1823
|
+
`manifest.json is obsolete \u2014 delete it; the model's \`# artifact title="\u2026"\` tag on the query is the manifest now`
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
if (!declared.has(dir)) {
|
|
1827
|
+
errors.push(
|
|
1828
|
+
`no query is tagged for this dashboard \u2014 tag one with \`# artifact\` (or \`# artifact name="${dir}"\`) in the model`
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
if (errors.length) dashboards.push({ name: dir, errors });
|
|
1832
|
+
}
|
|
1833
|
+
for (const artifact of artifacts) {
|
|
1834
|
+
const errors = [];
|
|
1835
|
+
const v = await runner.validate(artifact.query, {});
|
|
1836
|
+
if (!v.ok) errors.push(v.error);
|
|
1837
|
+
const specs = await runner.givensForQuery(artifact.query);
|
|
1838
|
+
if (specs.ok) {
|
|
1839
|
+
for (const spec of specs.givens) {
|
|
1840
|
+
if (spec.tags?.suggest_query !== void 0) {
|
|
1841
|
+
errors.push(
|
|
1842
|
+
`given "${spec.name}": suggest_query is obsolete \u2014 declare # suggest { source=\u2026 dimension=\u2026 } or # suggest { query=\u2026 }`
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
const suggest = spec.suggest;
|
|
1846
|
+
if (!suggest) continue;
|
|
1847
|
+
const base = suggest.query ? `run: ${suggest.query}` : suggest.source && suggest.dimension ? `run: ${suggest.source} -> ${quoteField(suggest.dimension)}` : null;
|
|
1848
|
+
if (base === null) {
|
|
1849
|
+
errors.push(
|
|
1850
|
+
`given "${spec.name}": suggest must be \`suggest { source=<source> dimension=<field> }\` or \`suggest { query=<named_query> [dimension=<field>] }\``
|
|
1851
|
+
);
|
|
1852
|
+
continue;
|
|
1683
1853
|
}
|
|
1854
|
+
const sv = await runner.validateText(base);
|
|
1855
|
+
if (!sv.ok) errors.push(`given "${spec.name}": suggest does not compile \u2014 ${sv.error}`);
|
|
1684
1856
|
}
|
|
1685
1857
|
}
|
|
1686
|
-
const tsxPath = join3(
|
|
1687
|
-
if (
|
|
1688
|
-
errors.push(`missing Dashboard.tsx`);
|
|
1689
|
-
} else {
|
|
1858
|
+
const tsxPath = join3(abs, "dashboards", artifact.name, "Dashboard.tsx");
|
|
1859
|
+
if (existsSync3(tsxPath)) {
|
|
1690
1860
|
try {
|
|
1691
1861
|
await esbuild.transform(readFileSync3(tsxPath, "utf8"), { loader: "tsx", jsx: "automatic" });
|
|
1692
1862
|
} catch (e) {
|
|
@@ -1694,11 +1864,7 @@ async function lintDashboards(root) {
|
|
|
1694
1864
|
errors.push(`Dashboard.tsx: ${msg}`);
|
|
1695
1865
|
}
|
|
1696
1866
|
}
|
|
1697
|
-
|
|
1698
|
-
const v = await runner.validate(query, givenValues);
|
|
1699
|
-
if (!v.ok) errors.push(v.error);
|
|
1700
|
-
}
|
|
1701
|
-
dashboards.push({ name, errors });
|
|
1867
|
+
dashboards.push({ name: artifact.name, errors });
|
|
1702
1868
|
}
|
|
1703
1869
|
return { ok: dashboards.every((d) => d.errors.length === 0), dashboards };
|
|
1704
1870
|
}
|
|
@@ -1998,59 +2164,228 @@ var DASHBOARD_GUIDANCE = `
|
|
|
1998
2164
|
|
|
1999
2165
|
# Authoring dashboards
|
|
2000
2166
|
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2167
|
+
A dashboard is DECLARED IN THE MODEL \u2014 there is no manifest file and, for the
|
|
2168
|
+
basic case, no JavaScript at all. Preview with \`malloyyo dashboard dev\`; check
|
|
2169
|
+
with \`malloyyo lint\`.
|
|
2170
|
+
|
|
2171
|
+
## Make dashboards discoverable: the entry model
|
|
2172
|
+
|
|
2173
|
+
**The entry is \`index.malloy\`.** \`dashboard dev\`, \`lint\`, and the hosted
|
|
2174
|
+
server only see what that file EXPORTS. Three things must all be surfaced
|
|
2175
|
+
(imported AND exported) through it, or the feature looks broken:
|
|
2176
|
+
|
|
2177
|
+
1. **The \`# artifact\`-tagged queries.** Declared in another file and not
|
|
2178
|
+
exported \u2192 \`dashboard dev\` says "No dashboards declared" and \`lint\` says
|
|
2179
|
+
"no dashboards to lint", even though the model compiles clean.
|
|
2180
|
+
2. **Every filter given the dashboards reference.** An unexported given
|
|
2181
|
+
silently resolves to its declaration default \u2014 the control still renders
|
|
2182
|
+
but CAN'T CHANGE THE QUERY (the filter looks inert).
|
|
2183
|
+
3. **Whatever backs each \`suggest\`** \u2014 the named query (\`suggest {query=\u2026}\`)
|
|
2184
|
+
or source (\`suggest {source=\u2026}\`). Suggestions run against the entry model;
|
|
2185
|
+
an unexported one fails lint with "Reference to undefined object".
|
|
2186
|
+
|
|
2187
|
+
\`\`\`malloy
|
|
2188
|
+
##! experimental.givens
|
|
2189
|
+
import {
|
|
2190
|
+
order_items,
|
|
2191
|
+
BRAND, CATEGORY, PERIOD, // the filter givens
|
|
2192
|
+
brand_suggest, // backs a suggest {query=\u2026}
|
|
2193
|
+
overview_dashboard // the # artifact query
|
|
2194
|
+
} from 'ecommerce.malloy'
|
|
2195
|
+
export { order_items, BRAND, CATEGORY, PERIOD, brand_suggest, overview_dashboard }
|
|
2196
|
+
\`\`\`
|
|
2004
2197
|
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
A dashboard may ONLY run named queries the model exposes, driven by givens \u2014
|
|
2009
|
-
never invent Malloy in the dashboard.
|
|
2010
|
-
2. If the query or givens you need don't exist yet, add them to the \`.malloy\`
|
|
2011
|
-
model first (a top-level \`query:\` that references \`$GIVEN\` in its filters),
|
|
2012
|
-
then re-check with \`describe_source\`.
|
|
2198
|
+
Prefer \`suggest { query=<named-query> \u2026 }\` over \`suggest { source=\u2026 }\` for
|
|
2199
|
+
anything beyond a throwaway: you export one small governed query instead of a
|
|
2200
|
+
whole base source.
|
|
2013
2201
|
|
|
2014
|
-
##
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2202
|
+
## The model is the whole contract
|
|
2203
|
+
|
|
2204
|
+
**1. Tag a top-level query** with \`# artifact\` to declare a dashboard. For
|
|
2205
|
+
the common overview shape (top-level aggregates + nests), ALSO tag it
|
|
2206
|
+
\`# dashboard\` so the result renders as KPI tiles + a card grid instead of one
|
|
2207
|
+
flat table \u2014 they're partners: \`# artifact\` declares the dashboard,
|
|
2208
|
+
\`# dashboard\` is the renderer tag that draws it like one:
|
|
2209
|
+
|
|
2210
|
+
\`\`\`malloy
|
|
2211
|
+
#" Business health at a glance \u2014 sales, margin, orders.
|
|
2212
|
+
# artifact { title="Business Overview" } dashboard
|
|
2213
|
+
query: overview_dashboard is order_items -> {
|
|
2214
|
+
where:
|
|
2215
|
+
inventory_items.product_brand ~ $BRAND, // multi-filter where: is
|
|
2216
|
+
inventory_items.product_category ~ $CATEGORY, // COMMA separated
|
|
2217
|
+
created_at ~ $PERIOD
|
|
2218
|
+
aggregate: total_sales, total_gross_margin, order_count
|
|
2219
|
+
nest:
|
|
2220
|
+
# line_chart
|
|
2221
|
+
sales_trend is by_month
|
|
2222
|
+
top_brands
|
|
2223
|
+
# shape_map
|
|
2224
|
+
sales_by_state
|
|
2026
2225
|
}
|
|
2027
2226
|
\`\`\`
|
|
2028
|
-
Given \`name\`s must match the model's given names exactly; \`type\` must match.
|
|
2029
2227
|
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2228
|
+
That's a complete dashboard: the runtime auto-renders a title (the tag's
|
|
2229
|
+
\`title\`, else the \`#"\` doc comment), a control for every given the query
|
|
2230
|
+
references, and the result panel. \`name="slug"\` overrides the URL/directory
|
|
2231
|
+
slug (default: the query name). Note the \`where:\` clauses applying givens are
|
|
2232
|
+
COMMA-separated \u2014 newline-separated conditions do not parse.
|
|
2233
|
+
|
|
2234
|
+
Two dashboards can share a given but start on different values \u2014 a \`givens\`
|
|
2235
|
+
block in the tag sets PER-DASHBOARD defaults (given values, i.e. filter
|
|
2236
|
+
expressions; URL params still win):
|
|
2237
|
+
|
|
2238
|
+
\`\`\`malloy
|
|
2239
|
+
# artifact { name="manufacturer" title="Manufacturer Recall Profile" givens { MANUFACTURER="Ford Motor Company" } }
|
|
2240
|
+
\`\`\`
|
|
2241
|
+
|
|
2242
|
+
This replaces the "declare the given's default per dashboard" role the old
|
|
2243
|
+
manifests had: declare the given once with a neutral default (often \`f''\` =
|
|
2244
|
+
no filter), and let each tag pick its landing state.
|
|
2245
|
+
|
|
2246
|
+
**2. Declare the filters as \`filter<T>\` givens** \u2014 never raw strings/numbers.
|
|
2247
|
+
A \`filter<string>\` value accepts one value ('NY'), alternatives ('NY, CA'),
|
|
2248
|
+
wildcards ('Ann%'), negation ('-NY'); a \`filter<number>\` accepts ranges
|
|
2249
|
+
('[1910 to 1930]') and comparisons ('> 200'); a \`filter<timestamp>\` /
|
|
2250
|
+
\`filter<date>\` accepts relative windows ('7 days' = the last 7 days, 'today',
|
|
2251
|
+
'last month') and literal ranges ('2026-01-01 to 2026-07-01' \u2014 NO \`@\` in
|
|
2252
|
+
filter literals). Apply with \`~\`; \`f''\` = empty = no filter (the natural
|
|
2253
|
+
"All"/"all time" \u2014 just \`col ~ $X\`, no \`$X = '' or \u2026\` dance):
|
|
2254
|
+
|
|
2255
|
+
\`\`\`malloy
|
|
2256
|
+
##! experimental { givens }
|
|
2257
|
+
given:
|
|
2258
|
+
# label="State" control=select suggest { source=baby_names dimension=state }
|
|
2259
|
+
STATE :: filter<string> is f'NY'
|
|
2260
|
+
# label="Brand" suggest { query=brand_suggest dimension=product_brand }
|
|
2261
|
+
BRAND :: filter<string> is f''
|
|
2262
|
+
# label="Years" range_min=1910 range_max=2025
|
|
2263
|
+
YEAR_RANGE :: filter<number> is f'[1910 to 1930]'
|
|
2264
|
+
# label="Time period"
|
|
2265
|
+
PERIOD :: filter<timestamp> is f''
|
|
2266
|
+
# label="Include rare names"
|
|
2267
|
+
INCLUDE_RARE :: boolean is false
|
|
2268
|
+
\`\`\`
|
|
2269
|
+
|
|
2270
|
+
Tags on the declaration drive the control (tag syntax is \`key="value"\` \u2014
|
|
2271
|
+
equals, not colon):
|
|
2272
|
+
- \`label\` \u2014 control caption (defaults to the given's name)
|
|
2273
|
+
- \`suggest { \u2026 }\` \u2014 where the control's options come from. NO Malloy code in
|
|
2274
|
+
strings \u2014 just names:
|
|
2275
|
+
- \`suggest { query=brand_suggest dimension=product_brand }\` \u2014 the FIRST
|
|
2276
|
+
COLUMN of a named query (declare the query in the model \u2014 governed,
|
|
2277
|
+
reviewable, and only that query needs exporting). PREFER THIS FORM.
|
|
2278
|
+
- \`suggest { source=baby_names dimension=state }\` \u2014 the DISTINCT VALUES of
|
|
2279
|
+
a dimension on a source (the whole source must be exported)
|
|
2280
|
+
A \`dimension\` (in either form) is what enables SERVER-SIDE TYPEAHEAD: the
|
|
2281
|
+
runtime refines the base query with what the user has typed
|
|
2282
|
+
(\`\u2026 + { where: lower(field) ~ f'll%'; limit: 50 }\`, case-insensitive,
|
|
2283
|
+
escaped). Without a dimension the fetched list is filtered client-side.
|
|
2284
|
+
Runs as a restricted query; lint checks the declaration compiles.
|
|
2285
|
+
- \`control=select\` \u2014 a fixed dropdown instead of a typeahead search box
|
|
2286
|
+
- \`range_min\` / \`range_max\` \u2014 bounds; makes a filter<number> given a
|
|
2287
|
+
dual-thumb range slider
|
|
2288
|
+
- anything else passes through in \`spec.tags\` for custom components
|
|
2289
|
+
|
|
2290
|
+
Control picked from the declaration automatically: numeric range tags \u2192
|
|
2291
|
+
dual-thumb slider; \`filter<timestamp|timestamptz|date>\` \u2192 the TimeRange
|
|
2292
|
+
widget (relative presets: Today / Last 7 days / Last 30 days / \u2026 plus a
|
|
2293
|
+
"Custom range\u2026" from/to date picker); suggest + control=select \u2192 dropdown;
|
|
2294
|
+
boolean \u2192 checkbox; anything else \u2192 committing search box with typeahead.
|
|
2295
|
+
The suggest-driven options are DATA VALUES only \u2014 options that aren't column
|
|
2296
|
+
values (custom time presets, threshold buckets) need a custom component
|
|
2297
|
+
(below) with explicit \`{value, text}\` options where value is a filter
|
|
2298
|
+
expression built with \`filters.*\`.
|
|
2299
|
+
|
|
2300
|
+
## Custom components (optional): ./dashboards/<slug>/Dashboard.tsx
|
|
2301
|
+
|
|
2302
|
+
When the default UI isn't enough, add ONE file. It composes the runtime's
|
|
2303
|
+
widgets/hooks with your own React \u2014 you own layout, copy, and theming; the
|
|
2304
|
+
model still owns every query and filter:
|
|
2305
|
+
|
|
2033
2306
|
\`\`\`tsx
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2307
|
+
import React from "react";
|
|
2308
|
+
import { Controls, Given, Search, Select, TimeRange, Panel, filters, useGiven } from "@malloyyo/dashboard";
|
|
2309
|
+
|
|
2310
|
+
export default function Dashboard({ dashboard, givens }) {
|
|
2311
|
+
return (
|
|
2312
|
+
<div style={{ maxWidth: 860, margin: "0 auto", padding: 24 }}>
|
|
2313
|
+
<h1>{dashboard.title}</h1>
|
|
2314
|
+
<Controls>
|
|
2315
|
+
<Given name="STATE" /> {/* picks the control from the declaration */}
|
|
2316
|
+
<Search given="NAME" /> {/* committing input + typeahead + validation */}
|
|
2317
|
+
<TimeRange given="PERIOD" presets={[
|
|
2318
|
+
{ value: "", text: "All time" },
|
|
2319
|
+
{ value: filters.lastN(1, "day"), text: "Last day" },
|
|
2320
|
+
{ value: filters.lastN(1, "week"), text: "Last week" },
|
|
2321
|
+
{ value: filters.lastN(1, "month"), text: "Last month" },
|
|
2322
|
+
]} /> {/* "Custom range\u2026" is always appended */}
|
|
2323
|
+
<Select given="MIN_SAMPLE"
|
|
2324
|
+
options={[10, 200, 1000].map(n => ({ value: filters.greaterThan(n), text: \`> \${n}\` }))} />
|
|
2325
|
+
</Controls>
|
|
2326
|
+
<Panel givens={givens} /> {/* the tagged query, Malloy renderer */}
|
|
2327
|
+
<Panel malloy="baby_names -> births_by_decade" givens={givens} /> {/* restricted text */}
|
|
2328
|
+
</div>
|
|
2329
|
+
);
|
|
2040
2330
|
}
|
|
2041
2331
|
\`\`\`
|
|
2042
2332
|
|
|
2333
|
+
From \`@malloyyo/dashboard\` (also handed to the component as props):
|
|
2334
|
+
- **Widgets** (headless-ish; restyle via className/style or CSS vars
|
|
2335
|
+
\`--dash-fg/-muted/-border/-accent/-control-bg/-controls-bg\`):
|
|
2336
|
+
\`<Controls/>\` (all givens, or compose children), \`<Given name/>\`,
|
|
2337
|
+
\`<Select given [options]/>\`, \`<Search given/>\`, \`<Range given [min max]/>\`,
|
|
2338
|
+
\`<TimeRange given [presets]/>\` (temporal presets + custom range),
|
|
2339
|
+
\`<Checkbox given/>\` (bound to a boolean given)
|
|
2340
|
+
- **Hooks**: \`useGiven(name)\` \u2192 {value, set, spec};
|
|
2341
|
+
\`useOptions(name, typed?)\` \u2192 {options, loading} (typeahead);
|
|
2342
|
+
\`useQuery({query|malloy, givens})\` \u2192 {rows, loading, error} \u2014 plain rows
|
|
2343
|
+
for your own visuals
|
|
2344
|
+
- **Helpers**: \`filters.oneOf/contains/between/atLeast/\u2026\` build
|
|
2345
|
+
filter-expression strings with correct escaping; temporal:
|
|
2346
|
+
\`filters.lastN(7, "day")\` \u2192 \`'7 days'\`, \`filters.dateRange("2026-01-01",
|
|
2347
|
+
"2026-07-01")\`, \`filters.afterDate/beforeDate\`; read back with
|
|
2348
|
+
\`filters.values/numberRange/threshold/inLast/temporalRange\`;
|
|
2349
|
+
\`filters.isValid(type, src)\` checks typed input.
|
|
2350
|
+
Never hand-concatenate a filter string.
|
|
2351
|
+
**Escaping rule for custom controls:** a filter given's value is an
|
|
2352
|
+
EXPRESSION, so committing a raw column value is wrong the moment it contains
|
|
2353
|
+
a comma/percent/dash ('Tesla, Inc.' parses as two alternatives and matches
|
|
2354
|
+
nothing). Commit \`filters.oneOf(value)\` (exact) or
|
|
2355
|
+
\`filters.contains(term)\` (substring), and unwrap for display with
|
|
2356
|
+
\`filters.values(src)\`. The stock \`<Select/>\` does this automatically;
|
|
2357
|
+
\`<Search/>\` deliberately commits raw text (its input IS a filter
|
|
2358
|
+
expression).
|
|
2359
|
+
- \`<Panel/>\` and \`runData(text, givens)\` \u2014 named queries are the primary
|
|
2360
|
+
form; arbitrary Malloy runs as a RESTRICTED query (no import / given: /
|
|
2361
|
+
connection.* / raw SQL / ##! flags \u2014 the model's published surface only).
|
|
2362
|
+
|
|
2043
2363
|
## Rules
|
|
2044
|
-
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
-
|
|
2049
|
-
|
|
2364
|
+
- Declare data in the model: givens are \`filter<T>\`, options come from
|
|
2365
|
+
\`# suggest {\u2026}\` declarations, dashboards are \`# artifact\` tags. If a query or given you
|
|
2366
|
+
need is missing, add it to the \`.malloy\` file first (check with
|
|
2367
|
+
\`describe_source\`).
|
|
2368
|
+
- Surface everything through the entry model (see the top section).
|
|
2369
|
+
- Only React + \`@malloyyo/dashboard\` are importable. No other imports, no
|
|
2370
|
+
network \u2014 the runtime sandboxes the component.
|
|
2371
|
+
- Interactivity = setting given values (filter-expression strings), not
|
|
2372
|
+
rewriting query text per interaction.
|
|
2050
2373
|
|
|
2051
|
-
## Preview
|
|
2052
|
-
|
|
2053
|
-
\`Dashboard.tsx\`
|
|
2374
|
+
## Preview & validate
|
|
2375
|
+
\`malloyyo dashboard dev\` \u2192 open the printed URL. Edits to \`.malloy\` (tags,
|
|
2376
|
+
givens, queries) and \`Dashboard.tsx\` hot-reload. \`malloyyo lint\` validates
|
|
2377
|
+
the tagged queries, given \`suggest\` declarations, and any Dashboard.tsx \u2014
|
|
2378
|
+
but only for dashboards REACHABLE FROM THE ENTRY: "no dashboards to lint"
|
|
2379
|
+
usually means the \`# artifact\` queries aren't exported through
|
|
2380
|
+
\`index.malloy\`, not that they don't exist.
|
|
2381
|
+
|
|
2382
|
+
Validation loop that works well: the local \`malloyyo mcp\` server hot-reloads
|
|
2383
|
+
working-directory edits \u2014 \`query(execute:false)\` to compile-check,
|
|
2384
|
+
\`execute:true\` to run. A top-level \`# artifact\` query runs as
|
|
2385
|
+
\`run: <name>\` (not \`source -> <name>\`), and is only visible once exported
|
|
2386
|
+
through the entry. Don't validate local edits against a hosted/claude.ai
|
|
2387
|
+
connector \u2014 that serves the PUBLISHED model, which is stale until
|
|
2388
|
+
\`malloyyo publish\`.
|
|
2054
2389
|
`;
|
|
2055
2390
|
|
|
2056
2391
|
// src/mcp.ts
|
|
@@ -2184,7 +2519,8 @@ var HOST_LIBS = [
|
|
|
2184
2519
|
"react-dom/client",
|
|
2185
2520
|
"react/jsx-runtime",
|
|
2186
2521
|
"react/jsx-dev-runtime",
|
|
2187
|
-
"@malloydata/render"
|
|
2522
|
+
"@malloydata/render",
|
|
2523
|
+
"@malloydata/malloy-filter"
|
|
2188
2524
|
];
|
|
2189
2525
|
var HOST_ALIAS = {};
|
|
2190
2526
|
for (const spec of HOST_LIBS) {
|
|
@@ -2193,46 +2529,41 @@ for (const spec of HOST_LIBS) {
|
|
|
2193
2529
|
} catch {
|
|
2194
2530
|
}
|
|
2195
2531
|
}
|
|
2196
|
-
function
|
|
2532
|
+
function resolveRuntimeDir() {
|
|
2197
2533
|
const candidates = [
|
|
2198
|
-
new URL("./frame-
|
|
2534
|
+
new URL("./frame-runtime/", import.meta.url),
|
|
2199
2535
|
// dev: src/dashboard.ts
|
|
2200
|
-
new URL("../src/frame-
|
|
2536
|
+
new URL("../src/frame-runtime/", import.meta.url)
|
|
2201
2537
|
// built: dist/index.js
|
|
2202
2538
|
].map((u) => fileURLToPath(u));
|
|
2203
2539
|
const found = candidates.find((c) => fs3.existsSync(c));
|
|
2204
2540
|
if (!found) {
|
|
2205
2541
|
throw new Error(
|
|
2206
|
-
"frame-
|
|
2542
|
+
"frame-runtime/ not found \u2014 `dashboard dev` currently needs the CLI source checkout (looked in ./ and ../src). See docs/repo-artifacts.md packaging note."
|
|
2207
2543
|
);
|
|
2208
2544
|
}
|
|
2209
2545
|
return found;
|
|
2210
2546
|
}
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
const
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
try {
|
|
2220
|
-
out.push({ name, dir, manifest: JSON.parse(fs3.readFileSync(mf, "utf8")) });
|
|
2221
|
-
} catch (e) {
|
|
2222
|
-
console.error(` ! skipping ${name}: bad manifest.json (${e.message})`);
|
|
2223
|
-
}
|
|
2224
|
-
}
|
|
2225
|
-
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
2547
|
+
var resolveFrameEntry = () => path4.join(resolveRuntimeDir(), "..", "frame-entry.tsx");
|
|
2548
|
+
async function discoverDashboards(root, runner) {
|
|
2549
|
+
const result = await runner.artifacts();
|
|
2550
|
+
if (!result.ok) throw new Error(`model error: ${result.error}`);
|
|
2551
|
+
return result.artifacts.map((a) => {
|
|
2552
|
+
const tsxPath = path4.join(root, "dashboards", a.name, "Dashboard.tsx");
|
|
2553
|
+
return { ...a, tsxPath: fs3.existsSync(tsxPath) ? tsxPath : void 0 };
|
|
2554
|
+
});
|
|
2226
2555
|
}
|
|
2227
2556
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2228
2557
|
function makeBundler() {
|
|
2229
2558
|
const cache = /* @__PURE__ */ new Map();
|
|
2230
2559
|
const frameEntry = resolveFrameEntry();
|
|
2560
|
+
const runtimeDir = resolveRuntimeDir();
|
|
2561
|
+
const runtimeIndex = path4.join(runtimeDir, "index.ts");
|
|
2562
|
+
const runtimeStamp = () => fs3.statSync(frameEntry).mtimeMs + fs3.readdirSync(runtimeDir).map((f) => fs3.statSync(path4.join(runtimeDir, f)).mtimeMs).reduce((a, b) => a + b, 0);
|
|
2231
2563
|
return async function bundle(dash) {
|
|
2232
|
-
const
|
|
2233
|
-
const mtimeMs = fs3.statSync(dashboardFile).mtimeMs + fs3.statSync(frameEntry).mtimeMs;
|
|
2564
|
+
const stamp = runtimeStamp() + (dash.tsxPath ? fs3.statSync(dash.tsxPath).mtimeMs : 0);
|
|
2234
2565
|
const hit = cache.get(dash.name);
|
|
2235
|
-
if (hit && hit.
|
|
2566
|
+
if (hit && hit.stamp === stamp) return hit.js;
|
|
2236
2567
|
const result = await esbuild2.build({
|
|
2237
2568
|
entryPoints: [frameEntry],
|
|
2238
2569
|
bundle: true,
|
|
@@ -2247,9 +2578,22 @@ function makeBundler() {
|
|
|
2247
2578
|
{
|
|
2248
2579
|
name: "virtual-dashboard",
|
|
2249
2580
|
setup(b) {
|
|
2250
|
-
|
|
2581
|
+
if (dash.tsxPath) {
|
|
2582
|
+
const tsxPath = dash.tsxPath;
|
|
2583
|
+
b.onResolve({ filter: /^virtual:dashboard$/ }, () => ({ path: tsxPath }));
|
|
2584
|
+
} else {
|
|
2585
|
+
b.onResolve({ filter: /^virtual:dashboard$/ }, () => ({
|
|
2586
|
+
path: "default-dashboard",
|
|
2587
|
+
namespace: "vdefault"
|
|
2588
|
+
}));
|
|
2589
|
+
b.onLoad({ filter: /.*/, namespace: "vdefault" }, () => ({
|
|
2590
|
+
contents: `export default null;`,
|
|
2591
|
+
loader: "js"
|
|
2592
|
+
}));
|
|
2593
|
+
}
|
|
2594
|
+
b.onResolve({ filter: /^@malloyyo\/dashboard$/ }, () => ({ path: runtimeIndex }));
|
|
2251
2595
|
b.onResolve(
|
|
2252
|
-
{ filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/render$)/ },
|
|
2596
|
+
{ filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/(render|malloy-filter)$)/ },
|
|
2253
2597
|
(args) => HOST_ALIAS[args.path] ? { path: HOST_ALIAS[args.path] } : void 0
|
|
2254
2598
|
);
|
|
2255
2599
|
}
|
|
@@ -2257,7 +2601,7 @@ function makeBundler() {
|
|
|
2257
2601
|
]
|
|
2258
2602
|
});
|
|
2259
2603
|
const js = result.outputFiles[0].text;
|
|
2260
|
-
cache.set(dash.name, {
|
|
2604
|
+
cache.set(dash.name, { stamp, js });
|
|
2261
2605
|
return js;
|
|
2262
2606
|
};
|
|
2263
2607
|
}
|
|
@@ -2268,7 +2612,7 @@ function parentShell(dash, frameBase, all, initialGivens) {
|
|
|
2268
2612
|
const fb = JSON.stringify(frameBase);
|
|
2269
2613
|
const nav = all.length > 1 ? `<nav style="display:flex;gap:4px;align-items:center;padding:8px 12px;background:#f6f7f9;border-bottom:1px solid #e2e4e8;font:13px system-ui,sans-serif"><span style="color:#888;margin-right:8px">Dashboards</span>` + all.map((x) => {
|
|
2270
2614
|
const on = x.name === dash.name;
|
|
2271
|
-
return `<a href="/?d=${encodeURIComponent(x.name)}" style="padding:4px 10px;border-radius:6px;text-decoration:none;${on ? "background:#1a1a1a;color:#fff" : "color:#333"}">${esc(x.
|
|
2615
|
+
return `<a href="/?d=${encodeURIComponent(x.name)}" style="padding:4px 10px;border-radius:6px;text-decoration:none;${on ? "background:#1a1a1a;color:#fff" : "color:#333"}">${esc(x.title || x.name)}</a>`;
|
|
2272
2616
|
}).join("") + `</nav>` : "";
|
|
2273
2617
|
return html(
|
|
2274
2618
|
`<div style="display:flex;flex-direction:column;height:100vh">` + nav + `<iframe id="f" sandbox="allow-scripts allow-same-origin" src="${frameBase}/frame?d=${encodeURIComponent(dash.name)}${givensQs}" style="border:0;flex:1;width:100%"></iframe></div><script>
|
|
@@ -2288,13 +2632,13 @@ window.addEventListener('message',async(e)=>{
|
|
|
2288
2632
|
let out;
|
|
2289
2633
|
try{
|
|
2290
2634
|
const res=await fetch('/api/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
2291
|
-
body:JSON.stringify({d:${d},query:m.query,givens:m.givens})});
|
|
2635
|
+
body:JSON.stringify({d:${d},query:m.query,malloy:m.malloy,givens:m.givens})});
|
|
2292
2636
|
out=await res.json();
|
|
2293
2637
|
}catch(err){ out={ok:false,problems:[{message:String(err)}]}; }
|
|
2294
2638
|
f.contentWindow.postMessage({type:'result',id:m.id,...out},${fb});
|
|
2295
2639
|
});
|
|
2296
2640
|
</script>`,
|
|
2297
|
-
dash.
|
|
2641
|
+
dash.title
|
|
2298
2642
|
);
|
|
2299
2643
|
}
|
|
2300
2644
|
function givensFromUrl(url4) {
|
|
@@ -2302,10 +2646,17 @@ function givensFromUrl(url4) {
|
|
|
2302
2646
|
for (const [k, v] of url4.searchParams) if (k !== "d") g[k] = v;
|
|
2303
2647
|
return g;
|
|
2304
2648
|
}
|
|
2305
|
-
function frameDoc(dash, initialGivens) {
|
|
2649
|
+
function frameDoc(dash, givenSpecs, initialGivens) {
|
|
2650
|
+
const info = {
|
|
2651
|
+
name: dash.name,
|
|
2652
|
+
query: dash.query,
|
|
2653
|
+
title: dash.title,
|
|
2654
|
+
description: dash.description,
|
|
2655
|
+
givens: dash.givens
|
|
2656
|
+
};
|
|
2306
2657
|
return html(
|
|
2307
|
-
`<div id="root"></div><script>window.
|
|
2308
|
-
dash.
|
|
2658
|
+
`<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
|
|
2659
|
+
dash.title
|
|
2309
2660
|
);
|
|
2310
2661
|
}
|
|
2311
2662
|
async function readBody(req) {
|
|
@@ -2319,15 +2670,17 @@ async function serveDashboard(opts) {
|
|
|
2319
2670
|
const port = opts.port ?? 4173;
|
|
2320
2671
|
const framePort = port + 1;
|
|
2321
2672
|
const frameBase = `http://localhost:${framePort}`;
|
|
2322
|
-
let dashboards = discoverDashboards(root);
|
|
2323
|
-
if (dashboards.length === 0) {
|
|
2324
|
-
throw new Error(`No dashboards found under ${path4.join(root, "dashboards")}/`);
|
|
2325
|
-
}
|
|
2326
|
-
let byName = new Map(dashboards.map((d) => [d.name, d]));
|
|
2327
2673
|
const runner = await makeRunner(root);
|
|
2328
2674
|
if (!runner.entryExists()) {
|
|
2329
2675
|
throw new Error(`No index.malloy at ${root} \u2014 run this from a Malloy model repo.`);
|
|
2330
2676
|
}
|
|
2677
|
+
let dashboards = await discoverDashboards(root, runner);
|
|
2678
|
+
if (dashboards.length === 0) {
|
|
2679
|
+
throw new Error(
|
|
2680
|
+
`No dashboards declared \u2014 tag a top-level query with \`# artifact title="\u2026"\` in the model.`
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2683
|
+
let byName = new Map(dashboards.map((d) => [d.name, d]));
|
|
2331
2684
|
const bundle = makeBundler();
|
|
2332
2685
|
const pick = (url4) => byName.get(url4.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
|
|
2333
2686
|
const sseClients = /* @__PURE__ */ new Set();
|
|
@@ -2341,10 +2694,12 @@ async function serveDashboard(opts) {
|
|
|
2341
2694
|
if (!f.endsWith(".malloy") && !f.includes("dashboards")) return;
|
|
2342
2695
|
clearTimeout(debounce);
|
|
2343
2696
|
debounce = setTimeout(() => {
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2697
|
+
discoverDashboards(root, runner).then((next) => {
|
|
2698
|
+
dashboards = next;
|
|
2699
|
+
byName = new Map(dashboards.map((d) => [d.name, d]));
|
|
2700
|
+
console.error(` \u21BB ${f} changed \u2014 reloading`);
|
|
2701
|
+
notifyReload();
|
|
2702
|
+
}).catch((e) => console.error(` ! model error after ${f} changed: ${e.message}`));
|
|
2348
2703
|
}, 150);
|
|
2349
2704
|
});
|
|
2350
2705
|
} catch (e) {
|
|
@@ -2360,7 +2715,16 @@ async function serveDashboard(opts) {
|
|
|
2360
2715
|
try {
|
|
2361
2716
|
if (onFramePort) {
|
|
2362
2717
|
if (url4.pathname === "/frame") {
|
|
2363
|
-
|
|
2718
|
+
const dash = pick(url4);
|
|
2719
|
+
const specs = await runner.givensForQuery(dash.query);
|
|
2720
|
+
if (!specs.ok) {
|
|
2721
|
+
return send(
|
|
2722
|
+
200,
|
|
2723
|
+
"text/html; charset=utf-8",
|
|
2724
|
+
html(`<pre style="color:crimson;padding:16px">model error: ${esc(specs.error)}</pre>`, dash.title)
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
return send(200, "text/html; charset=utf-8", frameDoc(dash, specs.givens, givensFromUrl(url4)));
|
|
2364
2728
|
}
|
|
2365
2729
|
if (url4.pathname === "/bundle.js") {
|
|
2366
2730
|
return send(200, "application/javascript; charset=utf-8", await bundle(pick(url4)));
|
|
@@ -2378,13 +2742,10 @@ async function serveDashboard(opts) {
|
|
|
2378
2742
|
return send(200, "text/html; charset=utf-8", parentShell(pick(url4), frameBase, dashboards, givensFromUrl(url4)));
|
|
2379
2743
|
}
|
|
2380
2744
|
if (url4.pathname === "/api/run" && req.method === "POST") {
|
|
2381
|
-
const { d, query, givens } = JSON.parse(await readBody(req));
|
|
2745
|
+
const { d, query, malloy, givens } = JSON.parse(await readBody(req));
|
|
2382
2746
|
const dash = byName.get(d);
|
|
2383
2747
|
if (!dash) return send(404, "application/json", JSON.stringify({ ok: false, problems: [{ message: `no dashboard '${d}'` }] }));
|
|
2384
|
-
|
|
2385
|
-
return send(403, "application/json", JSON.stringify({ ok: false, problems: [{ message: `query '${query}' is not declared by ${d}` }] }));
|
|
2386
|
-
}
|
|
2387
|
-
const out = await runner.run(query, givens ?? {});
|
|
2748
|
+
const out = typeof malloy === "string" ? await runner.runText(malloy, givens ?? {}) : await runner.run(String(query ?? dash.query), givens ?? {});
|
|
2388
2749
|
return send(200, "application/json", JSON.stringify(out));
|
|
2389
2750
|
}
|
|
2390
2751
|
send(404, "text/plain", "not found");
|
|
@@ -2400,7 +2761,8 @@ async function serveDashboard(opts) {
|
|
|
2400
2761
|
malloyyo dashboard dev \u2014 model: ${root}`);
|
|
2401
2762
|
console.error(` http://localhost:${port}/ (artifact origin: ${frameBase})`);
|
|
2402
2763
|
for (const d of dashboards) {
|
|
2403
|
-
|
|
2764
|
+
const kind = d.tsxPath ? "custom" : "default UI";
|
|
2765
|
+
console.error(` \u2022 ${d.name} (${kind}) \u2192 http://localhost:${port}/?d=${d.name}`);
|
|
2404
2766
|
}
|
|
2405
2767
|
console.error(` Ctrl-C to stop.
|
|
2406
2768
|
`);
|
|
@@ -2409,7 +2771,7 @@ async function serveDashboard(opts) {
|
|
|
2409
2771
|
}
|
|
2410
2772
|
|
|
2411
2773
|
// package.json
|
|
2412
|
-
var version = "0.2.
|
|
2774
|
+
var version = "0.2.12";
|
|
2413
2775
|
|
|
2414
2776
|
// src/index.ts
|
|
2415
2777
|
function shortSha(sha) {
|
|
@@ -2434,7 +2796,7 @@ async function publish(target, dir, opts) {
|
|
|
2434
2796
|
}
|
|
2435
2797
|
}
|
|
2436
2798
|
const git = gitInfo(root);
|
|
2437
|
-
const dashboards = gatherDashboards(root);
|
|
2799
|
+
const dashboards = await gatherDashboards(root);
|
|
2438
2800
|
const body = { files, config, git, dashboards };
|
|
2439
2801
|
const provenance = git.sha ? `${git.branch}@${shortSha(git.sha)}${git.dirty ? " (dirty)" : ""}` : "(no git)";
|
|
2440
2802
|
console.log(`\u2192 ${t.url} dataset=${t.dataset}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloydata/malloyyo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "Publish Malloy models to a Malloyyo instance",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@malloydata/malloy": "0.0.420",
|
|
36
36
|
"@malloydata/malloy-connections": "0.0.420",
|
|
37
|
+
"@malloydata/malloy-filter": "0.0.420",
|
|
37
38
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
39
|
"commander": "^12.1.0"
|
|
39
40
|
},
|