@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.
Files changed (2) hide show
  1. package/dist/index.js +568 -206
  2. 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
- for (const name of names) {
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
- const dir = join3(abs, "dashboards", name);
1658
- let manifest = null;
1659
- try {
1660
- manifest = JSON.parse(readFileSync3(join3(dir, "manifest.json"), "utf8"));
1661
- } catch (e) {
1662
- errors.push(`manifest.json: invalid JSON (${e.message})`);
1663
- }
1664
- let query;
1665
- const givenValues = {};
1666
- if (manifest) {
1667
- if (typeof manifest.title !== "string") errors.push(`manifest: "title" must be a string`);
1668
- if (typeof manifest.query !== "string") errors.push(`manifest: "query" must be a string`);
1669
- else query = manifest.query;
1670
- const givens = manifest.givens;
1671
- if (!Array.isArray(givens)) {
1672
- errors.push(`manifest: "givens" must be an array`);
1673
- } else {
1674
- for (const g of givens) {
1675
- if (typeof g?.name !== "string") {
1676
- errors.push(`manifest: every given needs a string "name"`);
1677
- continue;
1678
- }
1679
- if (g.type !== "string" && g.type !== "number" && g.type !== "boolean") {
1680
- errors.push(`given "${g.name}": "type" must be "string", "number", or "boolean"`);
1681
- }
1682
- if (g.default !== void 0) givenValues[g.name] = g.default;
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(dir, "Dashboard.tsx");
1687
- if (!existsSync3(tsxPath)) {
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
- if (query) {
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
- You can build a **dashboard** for this model: a small React view that renders one
2002
- or more of the model's queries, with filter controls. Store it in the repo under
2003
- \`./dashboards/<name>/\`. Preview it with \`malloyyo dashboard dev\`.
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
- ## Before you write anything
2006
- 1. Call \`describe_source\` to see the model's **named queries** and its
2007
- **givens** (the declared filter inputs, e.g. STATE, DECADE, with their types).
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
- ## Files to create
2015
- \`./dashboards/<name>/manifest.json\`
2016
- \`\`\`json
2017
- {
2018
- "title": "Human title",
2019
- "query": "<a named query from the model>",
2020
- "givens": [
2021
- { "name": "STATE", "label": "State", "type": "string", "control": "select",
2022
- "options": ["CA","NY","TX"], "default": "CA" },
2023
- { "name": "DECADE", "label": "Decade", "type": "number", "control": "select",
2024
- "options": [1980,1990], "default": 1980 }
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
- \`./dashboards/<name>/Dashboard.tsx\` \u2014 a default-exported React component. It
2031
- receives everything as props from the host runtime; it must NOT import data
2032
- libraries, fetch, or hold credentials:
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
- export default function Dashboard({ manifest, givens, setGiven, Panel }) {
2035
- // givens : current filter values, e.g. { STATE: "CA", DECADE: 1980 }
2036
- // setGiven : (name, value) => void \u2014 change a filter, the Panel re-runs
2037
- // Panel : <Panel givens={givens} /> runs manifest.query with those givens
2038
- // and renders the result with Malloy's renderer
2039
- // Lay out the controls + Panel however you like \u2014 this is your React.
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
- - Only React is available to the dashboard (plus the injected \`Panel\`). No other
2045
- imports, no network, no arbitrary Malloy \u2014 the runtime sandboxes it.
2046
- - Interactivity is done by changing **givens** (which drive the query's filters),
2047
- not by rewriting queries.
2048
- - The dashboard runs against the SAME model you're exploring, so what you preview
2049
- is what the model actually returns.
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
- From the model repo: \`malloyyo dashboard dev\` \u2192 open the printed URL. Editing
2053
- \`Dashboard.tsx\` and reloading rebuilds it.
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 resolveFrameEntry() {
2532
+ function resolveRuntimeDir() {
2197
2533
  const candidates = [
2198
- new URL("./frame-entry.tsx", import.meta.url),
2534
+ new URL("./frame-runtime/", import.meta.url),
2199
2535
  // dev: src/dashboard.ts
2200
- new URL("../src/frame-entry.tsx", import.meta.url)
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-entry.tsx not found \u2014 `dashboard dev` currently needs the CLI source checkout (looked in ./ and ../src). See docs/repo-artifacts.md packaging note."
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
- function discoverDashboards(root) {
2212
- const base = path4.join(root, "dashboards");
2213
- if (!fs3.existsSync(base)) return [];
2214
- const out = [];
2215
- for (const name of fs3.readdirSync(base)) {
2216
- const dir = path4.join(base, name);
2217
- const mf = path4.join(dir, "manifest.json");
2218
- if (!fs3.statSync(dir).isDirectory() || !fs3.existsSync(mf)) continue;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 dashboardFile = path4.join(dash.dir, "Dashboard.tsx");
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.mtimeMs === mtimeMs) return hit.js;
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
- b.onResolve({ filter: /^virtual:dashboard$/ }, () => ({ path: dashboardFile }));
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, { mtimeMs, js });
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.manifest.title || x.name)}</a>`;
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.manifest.title
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.__MANIFEST__=${JSON.stringify(dash.manifest)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
2308
- dash.manifest.title
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
- dashboards = discoverDashboards(root);
2345
- byName = new Map(dashboards.map((d) => [d.name, d]));
2346
- console.error(` \u21BB ${f} changed \u2014 reloading`);
2347
- notifyReload();
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
- return send(200, "text/html; charset=utf-8", frameDoc(pick(url4), givensFromUrl(url4)));
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
- if (query !== dash.manifest.query) {
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
- console.error(` \u2022 ${d.name} \u2192 http://localhost:${port}/?d=${d.name}`);
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.11";
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.11",
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
  },