@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.
Files changed (2) hide show
  1. package/dist/index.js +587 -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,247 @@ 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
2004
2172
 
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\`.
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
- ## 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
- ]
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
- \`./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
+
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
- 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.
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
- - 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.
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
- From the model repo: \`malloyyo dashboard dev\` \u2192 open the printed URL. Editing
2053
- \`Dashboard.tsx\` and reloading rebuilds it.
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 resolveFrameEntry() {
2551
+ function resolveRuntimeDir() {
2197
2552
  const candidates = [
2198
- new URL("./frame-entry.tsx", import.meta.url),
2553
+ new URL("./frame-runtime/", import.meta.url),
2199
2554
  // dev: src/dashboard.ts
2200
- new URL("../src/frame-entry.tsx", import.meta.url)
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-entry.tsx not found \u2014 `dashboard dev` currently needs the CLI source checkout (looked in ./ and ../src). See docs/repo-artifacts.md packaging note."
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
- 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));
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 dashboardFile = path4.join(dash.dir, "Dashboard.tsx");
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.mtimeMs === mtimeMs) return hit.js;
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
- b.onResolve({ filter: /^virtual:dashboard$/ }, () => ({ path: dashboardFile }));
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, { mtimeMs, js });
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.manifest.title || x.name)}</a>`;
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.manifest.title
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.__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
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
- dashboards = discoverDashboards(root);
2345
- byName = new Map(dashboards.map((d) => [d.name, d]));
2346
- console.error(` \u21BB ${f} changed \u2014 reloading`);
2347
- notifyReload();
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
- return send(200, "text/html; charset=utf-8", frameDoc(pick(url4), givensFromUrl(url4)));
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
- 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 ?? {});
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
- console.error(` \u2022 ${d.name} \u2192 http://localhost:${port}/?d=${d.name}`);
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.11";
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.11",
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
  },