@malloydata/malloyyo 0.2.11 → 0.2.13
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 +587 -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,247 @@ 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
|
|
2004
2172
|
|
|
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\`.
|
|
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:
|
|
2013
2176
|
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
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
|
+
\`\`\`
|
|
2197
|
+
|
|
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.
|
|
2201
|
+
|
|
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
|
+
|
|
2286
|
+
**RELATED (faceted) filters** \u2014 query-form only: a suggest query may
|
|
2287
|
+
reference the OTHER givens, and the runtime runs it with the dashboard's
|
|
2288
|
+
current values (the suggested given itself is excluded, so the list never
|
|
2289
|
+
collapses to the current pick). Brand suggestions narrow when Category is
|
|
2290
|
+
set:
|
|
2291
|
+
|
|
2292
|
+
\`\`\`malloy
|
|
2293
|
+
query: brand_suggest is inventory_items -> product_brand + {
|
|
2294
|
+
where:
|
|
2295
|
+
product_category ~ $CATEGORY, // NOT product_brand ~ $BRAND
|
|
2296
|
+
product_department ~ $DEPARTMENT
|
|
2297
|
+
limit: 500
|
|
2298
|
+
}
|
|
2299
|
+
\`\`\`
|
|
2300
|
+
|
|
2301
|
+
Declare one \`*_suggest\` per filter, each referencing the others; \`f''\`
|
|
2302
|
+
defaults mean unset filters don't constrain. \`source=\` suggests can't do
|
|
2303
|
+
this (no place for a \`where:\`) \u2014 another reason to prefer \`query=\`.
|
|
2304
|
+
- \`control=select\` \u2014 a fixed dropdown instead of a typeahead search box
|
|
2305
|
+
- \`range_min\` / \`range_max\` \u2014 bounds; makes a filter<number> given a
|
|
2306
|
+
dual-thumb range slider
|
|
2307
|
+
- anything else passes through in \`spec.tags\` for custom components
|
|
2308
|
+
|
|
2309
|
+
Control picked from the declaration automatically: numeric range tags \u2192
|
|
2310
|
+
dual-thumb slider; \`filter<timestamp|timestamptz|date>\` \u2192 the TimeRange
|
|
2311
|
+
widget (relative presets: Today / Last 7 days / Last 30 days / \u2026 plus a
|
|
2312
|
+
"Custom range\u2026" from/to date picker); suggest + control=select \u2192 dropdown;
|
|
2313
|
+
boolean \u2192 checkbox; anything else \u2192 committing search box with typeahead.
|
|
2314
|
+
The suggest-driven options are DATA VALUES only \u2014 options that aren't column
|
|
2315
|
+
values (custom time presets, threshold buckets) need a custom component
|
|
2316
|
+
(below) with explicit \`{value, text}\` options where value is a filter
|
|
2317
|
+
expression built with \`filters.*\`.
|
|
2318
|
+
|
|
2319
|
+
## Custom components (optional): ./dashboards/<slug>/Dashboard.tsx
|
|
2320
|
+
|
|
2321
|
+
When the default UI isn't enough, add ONE file. It composes the runtime's
|
|
2322
|
+
widgets/hooks with your own React \u2014 you own layout, copy, and theming; the
|
|
2323
|
+
model still owns every query and filter:
|
|
2324
|
+
|
|
2033
2325
|
\`\`\`tsx
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2326
|
+
import React from "react";
|
|
2327
|
+
import { Controls, Given, Search, Select, TimeRange, Panel, filters, useGiven } from "@malloyyo/dashboard";
|
|
2328
|
+
|
|
2329
|
+
export default function Dashboard({ dashboard, givens }) {
|
|
2330
|
+
return (
|
|
2331
|
+
<div style={{ maxWidth: 860, margin: "0 auto", padding: 24 }}>
|
|
2332
|
+
<h1>{dashboard.title}</h1>
|
|
2333
|
+
<Controls>
|
|
2334
|
+
<Given name="STATE" /> {/* picks the control from the declaration */}
|
|
2335
|
+
<Search given="NAME" /> {/* committing input + typeahead + validation */}
|
|
2336
|
+
<TimeRange given="PERIOD" presets={[
|
|
2337
|
+
{ value: "", text: "All time" },
|
|
2338
|
+
{ value: filters.lastN(1, "day"), text: "Last day" },
|
|
2339
|
+
{ value: filters.lastN(1, "week"), text: "Last week" },
|
|
2340
|
+
{ value: filters.lastN(1, "month"), text: "Last month" },
|
|
2341
|
+
]} /> {/* "Custom range\u2026" is always appended */}
|
|
2342
|
+
<Select given="MIN_SAMPLE"
|
|
2343
|
+
options={[10, 200, 1000].map(n => ({ value: filters.greaterThan(n), text: \`> \${n}\` }))} />
|
|
2344
|
+
</Controls>
|
|
2345
|
+
<Panel givens={givens} /> {/* the tagged query, Malloy renderer */}
|
|
2346
|
+
<Panel malloy="baby_names -> births_by_decade" givens={givens} /> {/* restricted text */}
|
|
2347
|
+
</div>
|
|
2348
|
+
);
|
|
2040
2349
|
}
|
|
2041
2350
|
\`\`\`
|
|
2042
2351
|
|
|
2352
|
+
From \`@malloyyo/dashboard\` (also handed to the component as props):
|
|
2353
|
+
- **Widgets** (headless-ish; restyle via className/style or CSS vars
|
|
2354
|
+
\`--dash-fg/-muted/-border/-accent/-control-bg/-controls-bg\`):
|
|
2355
|
+
\`<Controls/>\` (all givens, or compose children), \`<Given name/>\`,
|
|
2356
|
+
\`<Select given [options]/>\`, \`<Search given/>\`, \`<Range given [min max]/>\`,
|
|
2357
|
+
\`<TimeRange given [presets]/>\` (temporal presets + custom range),
|
|
2358
|
+
\`<Checkbox given/>\` (bound to a boolean given)
|
|
2359
|
+
- **Hooks**: \`useGiven(name)\` \u2192 {value, set, spec};
|
|
2360
|
+
\`useOptions(name, typed?)\` \u2192 {options, loading} (typeahead);
|
|
2361
|
+
\`useQuery({query|malloy, givens})\` \u2192 {rows, loading, error} \u2014 plain rows
|
|
2362
|
+
for your own visuals
|
|
2363
|
+
- **Helpers**: \`filters.oneOf/contains/between/atLeast/\u2026\` build
|
|
2364
|
+
filter-expression strings with correct escaping; temporal:
|
|
2365
|
+
\`filters.lastN(7, "day")\` \u2192 \`'7 days'\`, \`filters.dateRange("2026-01-01",
|
|
2366
|
+
"2026-07-01")\`, \`filters.afterDate/beforeDate\`; read back with
|
|
2367
|
+
\`filters.values/numberRange/threshold/inLast/temporalRange\`;
|
|
2368
|
+
\`filters.isValid(type, src)\` checks typed input.
|
|
2369
|
+
Never hand-concatenate a filter string.
|
|
2370
|
+
**Escaping rule for custom controls:** a filter given's value is an
|
|
2371
|
+
EXPRESSION, so committing a raw column value is wrong the moment it contains
|
|
2372
|
+
a comma/percent/dash ('Tesla, Inc.' parses as two alternatives and matches
|
|
2373
|
+
nothing). Commit \`filters.oneOf(value)\` (exact) or
|
|
2374
|
+
\`filters.contains(term)\` (substring), and unwrap for display with
|
|
2375
|
+
\`filters.values(src)\`. The stock \`<Select/>\` does this automatically;
|
|
2376
|
+
\`<Search/>\` deliberately commits raw text (its input IS a filter
|
|
2377
|
+
expression).
|
|
2378
|
+
- \`<Panel/>\` and \`runData(text, givens)\` \u2014 named queries are the primary
|
|
2379
|
+
form; arbitrary Malloy runs as a RESTRICTED query (no import / given: /
|
|
2380
|
+
connection.* / raw SQL / ##! flags \u2014 the model's published surface only).
|
|
2381
|
+
|
|
2043
2382
|
## Rules
|
|
2044
|
-
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
-
|
|
2049
|
-
|
|
2383
|
+
- Declare data in the model: givens are \`filter<T>\`, options come from
|
|
2384
|
+
\`# suggest {\u2026}\` declarations, dashboards are \`# artifact\` tags. If a query or given you
|
|
2385
|
+
need is missing, add it to the \`.malloy\` file first (check with
|
|
2386
|
+
\`describe_source\`).
|
|
2387
|
+
- Surface everything through the entry model (see the top section).
|
|
2388
|
+
- Only React + \`@malloyyo/dashboard\` are importable. No other imports, no
|
|
2389
|
+
network \u2014 the runtime sandboxes the component.
|
|
2390
|
+
- Interactivity = setting given values (filter-expression strings), not
|
|
2391
|
+
rewriting query text per interaction.
|
|
2050
2392
|
|
|
2051
|
-
## Preview
|
|
2052
|
-
|
|
2053
|
-
\`Dashboard.tsx\`
|
|
2393
|
+
## Preview & validate
|
|
2394
|
+
\`malloyyo dashboard dev\` \u2192 open the printed URL. Edits to \`.malloy\` (tags,
|
|
2395
|
+
givens, queries) and \`Dashboard.tsx\` hot-reload. \`malloyyo lint\` validates
|
|
2396
|
+
the tagged queries, given \`suggest\` declarations, and any Dashboard.tsx \u2014
|
|
2397
|
+
but only for dashboards REACHABLE FROM THE ENTRY: "no dashboards to lint"
|
|
2398
|
+
usually means the \`# artifact\` queries aren't exported through
|
|
2399
|
+
\`index.malloy\`, not that they don't exist.
|
|
2400
|
+
|
|
2401
|
+
Validation loop that works well: the local \`malloyyo mcp\` server hot-reloads
|
|
2402
|
+
working-directory edits \u2014 \`query(execute:false)\` to compile-check,
|
|
2403
|
+
\`execute:true\` to run. A top-level \`# artifact\` query runs as
|
|
2404
|
+
\`run: <name>\` (not \`source -> <name>\`), and is only visible once exported
|
|
2405
|
+
through the entry. Don't validate local edits against a hosted/claude.ai
|
|
2406
|
+
connector \u2014 that serves the PUBLISHED model, which is stale until
|
|
2407
|
+
\`malloyyo publish\`.
|
|
2054
2408
|
`;
|
|
2055
2409
|
|
|
2056
2410
|
// src/mcp.ts
|
|
@@ -2184,7 +2538,8 @@ var HOST_LIBS = [
|
|
|
2184
2538
|
"react-dom/client",
|
|
2185
2539
|
"react/jsx-runtime",
|
|
2186
2540
|
"react/jsx-dev-runtime",
|
|
2187
|
-
"@malloydata/render"
|
|
2541
|
+
"@malloydata/render",
|
|
2542
|
+
"@malloydata/malloy-filter"
|
|
2188
2543
|
];
|
|
2189
2544
|
var HOST_ALIAS = {};
|
|
2190
2545
|
for (const spec of HOST_LIBS) {
|
|
@@ -2193,46 +2548,41 @@ for (const spec of HOST_LIBS) {
|
|
|
2193
2548
|
} catch {
|
|
2194
2549
|
}
|
|
2195
2550
|
}
|
|
2196
|
-
function
|
|
2551
|
+
function resolveRuntimeDir() {
|
|
2197
2552
|
const candidates = [
|
|
2198
|
-
new URL("./frame-
|
|
2553
|
+
new URL("./frame-runtime/", import.meta.url),
|
|
2199
2554
|
// dev: src/dashboard.ts
|
|
2200
|
-
new URL("../src/frame-
|
|
2555
|
+
new URL("../src/frame-runtime/", import.meta.url)
|
|
2201
2556
|
// built: dist/index.js
|
|
2202
2557
|
].map((u) => fileURLToPath(u));
|
|
2203
2558
|
const found = candidates.find((c) => fs3.existsSync(c));
|
|
2204
2559
|
if (!found) {
|
|
2205
2560
|
throw new Error(
|
|
2206
|
-
"frame-
|
|
2561
|
+
"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
2562
|
);
|
|
2208
2563
|
}
|
|
2209
2564
|
return found;
|
|
2210
2565
|
}
|
|
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));
|
|
2566
|
+
var resolveFrameEntry = () => path4.join(resolveRuntimeDir(), "..", "frame-entry.tsx");
|
|
2567
|
+
async function discoverDashboards(root, runner) {
|
|
2568
|
+
const result = await runner.artifacts();
|
|
2569
|
+
if (!result.ok) throw new Error(`model error: ${result.error}`);
|
|
2570
|
+
return result.artifacts.map((a) => {
|
|
2571
|
+
const tsxPath = path4.join(root, "dashboards", a.name, "Dashboard.tsx");
|
|
2572
|
+
return { ...a, tsxPath: fs3.existsSync(tsxPath) ? tsxPath : void 0 };
|
|
2573
|
+
});
|
|
2226
2574
|
}
|
|
2227
2575
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2228
2576
|
function makeBundler() {
|
|
2229
2577
|
const cache = /* @__PURE__ */ new Map();
|
|
2230
2578
|
const frameEntry = resolveFrameEntry();
|
|
2579
|
+
const runtimeDir = resolveRuntimeDir();
|
|
2580
|
+
const runtimeIndex = path4.join(runtimeDir, "index.ts");
|
|
2581
|
+
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
2582
|
return async function bundle(dash) {
|
|
2232
|
-
const
|
|
2233
|
-
const mtimeMs = fs3.statSync(dashboardFile).mtimeMs + fs3.statSync(frameEntry).mtimeMs;
|
|
2583
|
+
const stamp = runtimeStamp() + (dash.tsxPath ? fs3.statSync(dash.tsxPath).mtimeMs : 0);
|
|
2234
2584
|
const hit = cache.get(dash.name);
|
|
2235
|
-
if (hit && hit.
|
|
2585
|
+
if (hit && hit.stamp === stamp) return hit.js;
|
|
2236
2586
|
const result = await esbuild2.build({
|
|
2237
2587
|
entryPoints: [frameEntry],
|
|
2238
2588
|
bundle: true,
|
|
@@ -2247,9 +2597,22 @@ function makeBundler() {
|
|
|
2247
2597
|
{
|
|
2248
2598
|
name: "virtual-dashboard",
|
|
2249
2599
|
setup(b) {
|
|
2250
|
-
|
|
2600
|
+
if (dash.tsxPath) {
|
|
2601
|
+
const tsxPath = dash.tsxPath;
|
|
2602
|
+
b.onResolve({ filter: /^virtual:dashboard$/ }, () => ({ path: tsxPath }));
|
|
2603
|
+
} else {
|
|
2604
|
+
b.onResolve({ filter: /^virtual:dashboard$/ }, () => ({
|
|
2605
|
+
path: "default-dashboard",
|
|
2606
|
+
namespace: "vdefault"
|
|
2607
|
+
}));
|
|
2608
|
+
b.onLoad({ filter: /.*/, namespace: "vdefault" }, () => ({
|
|
2609
|
+
contents: `export default null;`,
|
|
2610
|
+
loader: "js"
|
|
2611
|
+
}));
|
|
2612
|
+
}
|
|
2613
|
+
b.onResolve({ filter: /^@malloyyo\/dashboard$/ }, () => ({ path: runtimeIndex }));
|
|
2251
2614
|
b.onResolve(
|
|
2252
|
-
{ filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/render$)/ },
|
|
2615
|
+
{ filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/(render|malloy-filter)$)/ },
|
|
2253
2616
|
(args) => HOST_ALIAS[args.path] ? { path: HOST_ALIAS[args.path] } : void 0
|
|
2254
2617
|
);
|
|
2255
2618
|
}
|
|
@@ -2257,7 +2620,7 @@ function makeBundler() {
|
|
|
2257
2620
|
]
|
|
2258
2621
|
});
|
|
2259
2622
|
const js = result.outputFiles[0].text;
|
|
2260
|
-
cache.set(dash.name, {
|
|
2623
|
+
cache.set(dash.name, { stamp, js });
|
|
2261
2624
|
return js;
|
|
2262
2625
|
};
|
|
2263
2626
|
}
|
|
@@ -2268,7 +2631,7 @@ function parentShell(dash, frameBase, all, initialGivens) {
|
|
|
2268
2631
|
const fb = JSON.stringify(frameBase);
|
|
2269
2632
|
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
2633
|
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.
|
|
2634
|
+
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
2635
|
}).join("") + `</nav>` : "";
|
|
2273
2636
|
return html(
|
|
2274
2637
|
`<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 +2651,13 @@ window.addEventListener('message',async(e)=>{
|
|
|
2288
2651
|
let out;
|
|
2289
2652
|
try{
|
|
2290
2653
|
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})});
|
|
2654
|
+
body:JSON.stringify({d:${d},query:m.query,malloy:m.malloy,givens:m.givens})});
|
|
2292
2655
|
out=await res.json();
|
|
2293
2656
|
}catch(err){ out={ok:false,problems:[{message:String(err)}]}; }
|
|
2294
2657
|
f.contentWindow.postMessage({type:'result',id:m.id,...out},${fb});
|
|
2295
2658
|
});
|
|
2296
2659
|
</script>`,
|
|
2297
|
-
dash.
|
|
2660
|
+
dash.title
|
|
2298
2661
|
);
|
|
2299
2662
|
}
|
|
2300
2663
|
function givensFromUrl(url4) {
|
|
@@ -2302,10 +2665,17 @@ function givensFromUrl(url4) {
|
|
|
2302
2665
|
for (const [k, v] of url4.searchParams) if (k !== "d") g[k] = v;
|
|
2303
2666
|
return g;
|
|
2304
2667
|
}
|
|
2305
|
-
function frameDoc(dash, initialGivens) {
|
|
2668
|
+
function frameDoc(dash, givenSpecs, initialGivens) {
|
|
2669
|
+
const info = {
|
|
2670
|
+
name: dash.name,
|
|
2671
|
+
query: dash.query,
|
|
2672
|
+
title: dash.title,
|
|
2673
|
+
description: dash.description,
|
|
2674
|
+
givens: dash.givens
|
|
2675
|
+
};
|
|
2306
2676
|
return html(
|
|
2307
|
-
`<div id="root"></div><script>window.
|
|
2308
|
-
dash.
|
|
2677
|
+
`<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>`,
|
|
2678
|
+
dash.title
|
|
2309
2679
|
);
|
|
2310
2680
|
}
|
|
2311
2681
|
async function readBody(req) {
|
|
@@ -2319,15 +2689,17 @@ async function serveDashboard(opts) {
|
|
|
2319
2689
|
const port = opts.port ?? 4173;
|
|
2320
2690
|
const framePort = port + 1;
|
|
2321
2691
|
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
2692
|
const runner = await makeRunner(root);
|
|
2328
2693
|
if (!runner.entryExists()) {
|
|
2329
2694
|
throw new Error(`No index.malloy at ${root} \u2014 run this from a Malloy model repo.`);
|
|
2330
2695
|
}
|
|
2696
|
+
let dashboards = await discoverDashboards(root, runner);
|
|
2697
|
+
if (dashboards.length === 0) {
|
|
2698
|
+
throw new Error(
|
|
2699
|
+
`No dashboards declared \u2014 tag a top-level query with \`# artifact title="\u2026"\` in the model.`
|
|
2700
|
+
);
|
|
2701
|
+
}
|
|
2702
|
+
let byName = new Map(dashboards.map((d) => [d.name, d]));
|
|
2331
2703
|
const bundle = makeBundler();
|
|
2332
2704
|
const pick = (url4) => byName.get(url4.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
|
|
2333
2705
|
const sseClients = /* @__PURE__ */ new Set();
|
|
@@ -2341,10 +2713,12 @@ async function serveDashboard(opts) {
|
|
|
2341
2713
|
if (!f.endsWith(".malloy") && !f.includes("dashboards")) return;
|
|
2342
2714
|
clearTimeout(debounce);
|
|
2343
2715
|
debounce = setTimeout(() => {
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2716
|
+
discoverDashboards(root, runner).then((next) => {
|
|
2717
|
+
dashboards = next;
|
|
2718
|
+
byName = new Map(dashboards.map((d) => [d.name, d]));
|
|
2719
|
+
console.error(` \u21BB ${f} changed \u2014 reloading`);
|
|
2720
|
+
notifyReload();
|
|
2721
|
+
}).catch((e) => console.error(` ! model error after ${f} changed: ${e.message}`));
|
|
2348
2722
|
}, 150);
|
|
2349
2723
|
});
|
|
2350
2724
|
} catch (e) {
|
|
@@ -2360,7 +2734,16 @@ async function serveDashboard(opts) {
|
|
|
2360
2734
|
try {
|
|
2361
2735
|
if (onFramePort) {
|
|
2362
2736
|
if (url4.pathname === "/frame") {
|
|
2363
|
-
|
|
2737
|
+
const dash = pick(url4);
|
|
2738
|
+
const specs = await runner.givensForQuery(dash.query);
|
|
2739
|
+
if (!specs.ok) {
|
|
2740
|
+
return send(
|
|
2741
|
+
200,
|
|
2742
|
+
"text/html; charset=utf-8",
|
|
2743
|
+
html(`<pre style="color:crimson;padding:16px">model error: ${esc(specs.error)}</pre>`, dash.title)
|
|
2744
|
+
);
|
|
2745
|
+
}
|
|
2746
|
+
return send(200, "text/html; charset=utf-8", frameDoc(dash, specs.givens, givensFromUrl(url4)));
|
|
2364
2747
|
}
|
|
2365
2748
|
if (url4.pathname === "/bundle.js") {
|
|
2366
2749
|
return send(200, "application/javascript; charset=utf-8", await bundle(pick(url4)));
|
|
@@ -2378,13 +2761,10 @@ async function serveDashboard(opts) {
|
|
|
2378
2761
|
return send(200, "text/html; charset=utf-8", parentShell(pick(url4), frameBase, dashboards, givensFromUrl(url4)));
|
|
2379
2762
|
}
|
|
2380
2763
|
if (url4.pathname === "/api/run" && req.method === "POST") {
|
|
2381
|
-
const { d, query, givens } = JSON.parse(await readBody(req));
|
|
2764
|
+
const { d, query, malloy, givens } = JSON.parse(await readBody(req));
|
|
2382
2765
|
const dash = byName.get(d);
|
|
2383
2766
|
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 ?? {});
|
|
2767
|
+
const out = typeof malloy === "string" ? await runner.runText(malloy, givens ?? {}) : await runner.run(String(query ?? dash.query), givens ?? {});
|
|
2388
2768
|
return send(200, "application/json", JSON.stringify(out));
|
|
2389
2769
|
}
|
|
2390
2770
|
send(404, "text/plain", "not found");
|
|
@@ -2400,7 +2780,8 @@ async function serveDashboard(opts) {
|
|
|
2400
2780
|
malloyyo dashboard dev \u2014 model: ${root}`);
|
|
2401
2781
|
console.error(` http://localhost:${port}/ (artifact origin: ${frameBase})`);
|
|
2402
2782
|
for (const d of dashboards) {
|
|
2403
|
-
|
|
2783
|
+
const kind = d.tsxPath ? "custom" : "default UI";
|
|
2784
|
+
console.error(` \u2022 ${d.name} (${kind}) \u2192 http://localhost:${port}/?d=${d.name}`);
|
|
2404
2785
|
}
|
|
2405
2786
|
console.error(` Ctrl-C to stop.
|
|
2406
2787
|
`);
|
|
@@ -2409,7 +2790,7 @@ async function serveDashboard(opts) {
|
|
|
2409
2790
|
}
|
|
2410
2791
|
|
|
2411
2792
|
// package.json
|
|
2412
|
-
var version = "0.2.
|
|
2793
|
+
var version = "0.2.13";
|
|
2413
2794
|
|
|
2414
2795
|
// src/index.ts
|
|
2415
2796
|
function shortSha(sha) {
|
|
@@ -2434,7 +2815,7 @@ async function publish(target, dir, opts) {
|
|
|
2434
2815
|
}
|
|
2435
2816
|
}
|
|
2436
2817
|
const git = gitInfo(root);
|
|
2437
|
-
const dashboards = gatherDashboards(root);
|
|
2818
|
+
const dashboards = await gatherDashboards(root);
|
|
2438
2819
|
const body = { files, config, git, dashboards };
|
|
2439
2820
|
const provenance = git.sha ? `${git.branch}@${shortSha(git.sha)}${git.dirty ? " (dirty)" : ""}` : "(no git)";
|
|
2440
2821
|
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.13",
|
|
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
|
},
|