@malloydata/malloyyo 0.2.7 → 0.2.9

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 +793 -277
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { resolve } from "node:path";
5
+ import { resolve as resolve2 } from "node:path";
6
6
 
7
7
  // src/config.ts
8
8
  import { readFileSync, existsSync } from "node:fs";
@@ -38,8 +38,8 @@ function resolveTarget(dir, name) {
38
38
  }
39
39
  function resolveInstance(dir, arg) {
40
40
  if (arg && /^https?:\/\//i.test(arg)) {
41
- const url3 = normalizeUrl(arg);
42
- return { name: url3, url: url3 };
41
+ const url4 = normalizeUrl(arg);
42
+ return { name: url4, url: url4 };
43
43
  }
44
44
  const targets = readTargetMap(dir);
45
45
  const entries = Object.entries(targets);
@@ -84,6 +84,29 @@ function gatherDirectory(dir) {
84
84
  const config = existsSync2(configPath) ? readFileSync2(configPath, "utf8") : void 0;
85
85
  return { files, config };
86
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
+ }
87
110
  function gitInfo(dir) {
88
111
  const git = (args) => execFileSync("git", args, {
89
112
  cwd: dir,
@@ -111,213 +134,15 @@ function gitInfo(dir) {
111
134
  }
112
135
  }
113
136
 
114
- // src/oauth.ts
115
- import http from "node:http";
116
- import crypto from "node:crypto";
117
- import { spawn } from "node:child_process";
118
-
119
- // src/store.ts
120
- import { homedir } from "node:os";
121
- import { dirname, join as join3 } from "node:path";
122
- import { mkdirSync, readFileSync as readFileSync3, writeFileSync, existsSync as existsSync3, chmodSync } from "node:fs";
123
- function credsPath() {
124
- const base = process.env.XDG_CONFIG_HOME || join3(homedir(), ".config");
125
- return join3(base, "malloyyo", "credentials.json");
126
- }
127
- function readAll() {
128
- const p = credsPath();
129
- if (!existsSync3(p)) return {};
130
- try {
131
- return JSON.parse(readFileSync3(p, "utf8"));
132
- } catch {
133
- return {};
134
- }
135
- }
136
- function loadCreds(url3) {
137
- return readAll()[url3];
138
- }
139
- function saveCreds(url3, creds) {
140
- const p = credsPath();
141
- mkdirSync(dirname(p), { recursive: true });
142
- const all = readAll();
143
- all[url3] = creds;
144
- writeFileSync(p, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
145
- try {
146
- chmodSync(p, 384);
147
- } catch {
148
- }
149
- }
150
- function clearCreds(url3) {
151
- const all = readAll();
152
- if (!(url3 in all)) return false;
153
- delete all[url3];
154
- writeFileSync(credsPath(), JSON.stringify(all, null, 2) + "\n", { mode: 384 });
155
- return true;
156
- }
157
-
158
- // src/oauth.ts
159
- var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
160
- async function discover(baseUrl) {
161
- const res = await fetch(`${baseUrl}/api/oauth/discovery/authorization-server`);
162
- if (!res.ok) throw new Error(`OAuth discovery failed at ${baseUrl}: ${res.status} ${res.statusText}`);
163
- return await res.json();
164
- }
165
- function pkce() {
166
- const verifier = crypto.randomBytes(32).toString("base64url");
167
- const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
168
- return { verifier, challenge };
169
- }
170
- async function registerClient(registrationEndpoint, redirectUri) {
171
- const res = await fetch(registrationEndpoint, {
172
- method: "POST",
173
- headers: { "content-type": "application/json" },
174
- body: JSON.stringify({
175
- client_name: "malloyyo CLI",
176
- redirect_uris: [redirectUri],
177
- token_endpoint_auth_method: "none",
178
- grant_types: ["authorization_code", "refresh_token"],
179
- response_types: ["code"],
180
- scope: "mcp"
181
- })
182
- });
183
- if (!res.ok) throw new Error(`client registration failed: ${res.status} ${await res.text()}`);
184
- return (await res.json()).client_id;
185
- }
186
- function openBrowser(url3) {
187
- const [cmd, args] = process.platform === "darwin" ? ["open", [url3]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url3]] : ["xdg-open", [url3]];
188
- try {
189
- spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
190
- } catch {
191
- }
192
- }
193
- function awaitRedirect(state) {
194
- return new Promise((resolveServer) => {
195
- let resolveCode;
196
- let rejectCode;
197
- const code = new Promise((res, rej) => {
198
- resolveCode = res;
199
- rejectCode = rej;
200
- });
201
- const timer = setTimeout(() => rejectCode(new Error("timed out waiting for browser sign-in")), LOGIN_TIMEOUT_MS);
202
- const server = http.createServer((req, res) => {
203
- const u = new URL(req.url ?? "/", "http://localhost");
204
- if (u.pathname !== "/callback") {
205
- res.writeHead(404).end();
206
- return;
207
- }
208
- const err = u.searchParams.get("error");
209
- const got = u.searchParams.get("code");
210
- const ok = !err && !!got && u.searchParams.get("state") === state;
211
- res.writeHead(ok ? 200 : 400, { "content-type": "text/html" });
212
- res.end(
213
- `<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>${ok ? "\u2713 Signed in to malloyyo" : "Sign-in failed"}</h2><p>${ok ? "You can close this tab and return to the terminal." : err ?? "state mismatch"}</p></body>`
214
- );
215
- clearTimeout(timer);
216
- if (ok) resolveCode(got);
217
- else rejectCode(new Error(err ?? "state mismatch or missing code"));
218
- });
219
- server.listen(0, "127.0.0.1", () => {
220
- const port = server.address().port;
221
- resolveServer({ port, code, close: () => server.close() });
222
- });
223
- });
224
- }
225
- async function login(baseUrl) {
226
- const ep = await discover(baseUrl);
227
- const { verifier, challenge } = pkce();
228
- const state = crypto.randomBytes(16).toString("base64url");
229
- const { port, code, close } = await awaitRedirect(state);
230
- try {
231
- const redirectUri = `http://localhost:${port}/callback`;
232
- const clientId = await registerClient(ep.registration_endpoint, redirectUri);
233
- const authUrl = new URL(ep.authorization_endpoint);
234
- authUrl.search = new URLSearchParams({
235
- response_type: "code",
236
- client_id: clientId,
237
- redirect_uri: redirectUri,
238
- code_challenge: challenge,
239
- code_challenge_method: "S256",
240
- scope: "mcp",
241
- state
242
- }).toString();
243
- console.log("Opening your browser to sign in\u2026");
244
- console.log(`If it doesn't open, visit:
245
- ${authUrl.toString()}
246
- `);
247
- openBrowser(authUrl.toString());
248
- const authCode = await code;
249
- const res = await fetch(ep.token_endpoint, {
250
- method: "POST",
251
- headers: { "content-type": "application/x-www-form-urlencoded" },
252
- body: new URLSearchParams({
253
- grant_type: "authorization_code",
254
- code: authCode,
255
- redirect_uri: redirectUri,
256
- client_id: clientId,
257
- code_verifier: verifier
258
- })
259
- });
260
- if (!res.ok) throw new Error(`token exchange failed: ${res.status} ${await res.text()}`);
261
- const grant = await res.json();
262
- const creds = {
263
- clientId,
264
- accessToken: grant.access_token,
265
- refreshToken: grant.refresh_token,
266
- expiresAt: Date.now() + (grant.expires_in ?? 86400) * 1e3
267
- };
268
- saveCreds(baseUrl, creds);
269
- return creds;
270
- } finally {
271
- close();
272
- }
273
- }
274
- async function refresh(baseUrl, creds) {
275
- const ep = await discover(baseUrl);
276
- const res = await fetch(ep.token_endpoint, {
277
- method: "POST",
278
- headers: { "content-type": "application/x-www-form-urlencoded" },
279
- body: new URLSearchParams({
280
- grant_type: "refresh_token",
281
- refresh_token: creds.refreshToken,
282
- client_id: creds.clientId
283
- })
284
- });
285
- if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
286
- const grant = await res.json();
287
- const updated = {
288
- clientId: creds.clientId,
289
- accessToken: grant.access_token,
290
- refreshToken: grant.refresh_token,
291
- expiresAt: Date.now() + (grant.expires_in ?? 86400) * 1e3
292
- };
293
- saveCreds(baseUrl, updated);
294
- return updated;
295
- }
296
- async function getAccessToken(target, opts) {
297
- if (opts.tokenFlag) return opts.tokenFlag;
298
- if (target.tokenEnv && process.env[target.tokenEnv]) return process.env[target.tokenEnv];
299
- let creds = loadCreds(target.url);
300
- if (!creds) {
301
- throw new Error(`Not authenticated for ${target.url}.
302
- Run: malloyyo login ${target.name}`);
303
- }
304
- if (creds.expiresAt - Date.now() < 6e4) {
305
- try {
306
- creds = await refresh(target.url, creds);
307
- } catch {
308
- throw new Error(`Session expired for ${target.url}.
309
- Run: malloyyo login ${target.name}`);
310
- }
311
- }
312
- return creds.accessToken;
313
- }
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";
314
141
 
315
- // src/mcp.ts
142
+ // src/host.ts
316
143
  import fs from "node:fs";
317
144
  import path2 from "node:path";
318
145
  import url2 from "node:url";
319
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
320
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
321
146
  import {
322
147
  MalloyConfig,
323
148
  Runtime,
@@ -341,7 +166,7 @@ var contentFiles = {
341
166
  "develop/getting-started.md": '---\ndescription: Getting started \u2014 build a Malloy model step by step\n---\n\n# Building a Malloy model, step by step\n\nA model is a `malloy-config.json` (the connection to the data) and an\n`index.malloy` (the published query surface), optionally with other `.malloy`\nfiles that `index.malloy` imports. You edit these with your own file tools; the\nMCP tools compile, inspect, and test what you wrote. Never read `.malloy` as\ntext \u2014 compiling a bare source is how you read a table\'s schema.\n\n## 1. Verify the connection first\n\nConfirm the connection named in `malloy-config.json` resolves \u2014 compile a\nthrowaway probe inline with `compile` (no file needed):\n\n source: _probe is CONN.sql("SELECT 1 AS one")\n\nIf it compiles, the connection is good. If not, fix the connection / config\nbefore going further \u2014 a broken connection produces an empty schema and then a\ncascade of misleading `field-not-found` errors; ignore the cascade and fix the\nconnection. Call `yo_help("develop/connection-setup")` for how to set up or repair a connection.\n\n## 2. Identify the tables the model needs\n\nIf you are unsure which tables matter, ask the fox \u2014 they own the data and know\nwhere it lives.\n\n## 3. Get a base source per table\n\nA base is "what\'s in the table and what\'s computable from it" \u2014 no joins.\nDiscover the schema by compiling a bare stub inline with `compile`:\n\n source: users_base is CONN.table("users")\n\n`compile` returns the full column list + types \u2014 that is your schema browser.\nThen write the base into its own file and iterate with `compile_file`, adding\nonly the dimensions and measures intrinsic to that one table:\n\n // users_base.malloy\n source: users_base is CONN.table("users") extend {\n measure: user_count is count()\n }\n\nIf the data lives in files rather than database tables \u2014 common when the\nconnection is DuckDB \u2014 DuckDB lets you name a file path as the table (a\nproject-relative path):\n\n source: users_base is CONN.table(\'data/users.parquet\')\n\nOnly drop to a `.sql()` block when a single file-as-table can\'t express what you\nneed \u2014 e.g. globbing or unioning several files:\n\n source: users_base is CONN.sql("SELECT * FROM read_parquet(\'data/users-*.parquet\')")\n\n(`read_parquet` there is DuckDB SQL, not Malloy \u2014 see `yo_help("develop/connection-setup")`.)\n\n## 4. Assemble index.malloy \u2014 the published surface\n\nImport the bases, join them into the consumer-facing sources, and explicitly\nexport what consumers may query:\n\n import "users_base.malloy"\n import "orders_base.malloy"\n\n source: users is users_base extend {\n join_many: orders is orders_base on id = orders.user_id\n }\n source: orders is orders_base extend { }\n\n export { users, orders }\n\n**Export discipline** \u2014 the model is a published artifact, so be deliberate about\nits public surface:\n\n- Imported names are private. Base sources stay internal scaffolding unless you\n export them.\n- Without an `export` statement, everything you define is public. Add one and the\n surface becomes explicit: only the names you list \u2014 defined or imported \u2014 are\n public.\n- Hide intermediates. A staging source you define only so other sources can build\n on it should not be exported.\n- The export list is the consumer\'s menu \u2014 exactly what the test window and real\n consumers can query, nothing more.\n\n## The loop\n\nEdit a file \u2192 `compile_file` \u2192 fix `problems[]` \u2192 `query` (pass the model file\'s\npath as `ref`; `execute: false` validates first and reports the givens the query\nneeds, supplied via `givens`). Query text is restricted \u2014 `import`, `given:`\ndeclarations, `connection.table`/`connection.sql`, raw SQL, and `##!` flags\nbelong in the model file, not the query. When `compile`/`compile_file` returns\n`formatted: false`, call `prettify` and save its output. Use project-relative\ndata paths, not absolute \u2014 they resolve against the project root and survive\npublishing the model.\n',
342
167
  "develop/working-with-models.md": "---\ndescription: Working with an existing Malloy model\n---\n\n# Working with an existing model\n\nAn existing model is an `index.malloy` (plus any `.malloy` files it imports) and\na `malloy-config.json`.\n\n## Understand the model\n\n- **Read `malloy-config.json` directly** \u2014 it is JSON, so read it as text. It\n lists the connection(s) the model queries against; `yo_help(\"develop/connection-setup\")` explains\n the format (and how to set one up or repair it).\n- **Do NOT read `.malloy` as text \u2014 compile it.** `compile_file` returns the\n structured model: each source with its fields, joins, views, and named queries,\n plus `problems[]`. That is how you describe what is in the model. (Compiling a\n bare source \u2014 no `extend` block \u2014 likewise reads a raw table's schema.)\n\n## The loop\n\nEdit a file \u2192 `compile_file` \u2192 fix `problems[]` \u2192 `query`. Pass the model file's\npath as `ref`; `execute: false` validates first and reports the givens the query\nneeds (supplied via `givens`). Query text is restricted \u2014 `import`, `given:`\ndeclarations, `connection.table`/`connection.sql`, raw SQL, and `##!` flags\nbelong in the model file, not the query. When `compile`/`compile_file` returns\n`formatted: false`, run `prettify` and save its output. Use project-relative data\npaths, not absolute.\n",
343
168
  "explore/how-to.md": '---\ndescription: Extended instructions working with this MCP server\n---\n# General Notes\nMalloy is a combined semantic layer/query language. It describes data and analysis, and it can generate and execute SQL.\n\nYou answer questions from published Malloy semantic models. A model publishes sources and queries.\n\n* Some tools return problems[] to indicate invalid Malloy. problems may have a `help_topic` field \u2014 call `yo_help(help_topic)` for detailed guidance.\n* `yo_help()` with no topic will show an index which include error explanations, examples of Malloy syntax for common patterns, and a language reference manual (Malloy syntax is still evolving).\n* Tools that inspect Malloy code return objects with schemas, among other things. An entry with a name which requires `back-tick-quoting` (reserved word, special characters), will have `must_quote: true`\n* When limiting queries, do ranking, top-N, and member selection in Malloy, not in client code. Results are byte-budgeted: oversized results are truncated (the response says so and may link the full result). Reading aggregated rows is better analysis and the only way to see everything.\n* Compose your answer from what the model publishes. When composing a query, you can make new sources, extending existing sources with measures dimensions and joins. If the model\'s surface genuinely cannot answer a question, that is useful signal about the model.\n\n# Answering A Question\nTo answer a question you need to see what sources are available which pertain to the question.\n\n`list_sources` (when available) \u2014 see the sources you can query, grouped by model, with each model\'s named queries. If you already know the source, go straight to describe_source.\n\n# Build the Query\n* New to a pattern? `yo_help("explore/query-examples")` \u2014 the handful of Malloy query shapes (views, the workhorse group_by/aggregate, filtered aggregates, `all()`, `extend:`, `select:`, `nest:`) that cover almost every question, with the SQL habits that are wrong in Malloy.\n* `describe_source(source, model_ref)` \u2014 always describe a source before querying it (`model_ref` optional when the name is unique). Returns:\n * `described_source` \u2014 the source\'s `dimensions` (columns), `measures`, and `views` (the author\'s saved queries). A dimension\'s `type` is a scalar or a nested record (`origin.city`); an array column has no `type` \u2014 it shows up as a `joins` entry at its `path`.\n * `joins` \u2014 keyed by path, the arrays and source-joins this source reaches. `fans_out` marks a path that fans out. Each entry is one of: `{ source }` (fields in `join_source_map`), `{ source_def }` (an anonymous source\'s fields, inline), or an array `{ is_array, source_def }` \u2014 a record array\'s fields are used directly (`parcels.sku`), a scalar array\'s element is `each` (`tags.each`). To write a reference, use the entry\'s `quoted_path` if it has one, else the key.\n * `join_source_map` \u2014 the named sources those `{ source }` joins resolve to, deduped.\n * In its own content block, the source\'s raw Malloy, for anything the structured output above doesn\'t cover.\n* `query(source: "...", malloy: "run: source -> { ... }", execute: false)` \u2014 validate without running; it returns the SQL. Iterate until clean. (`model_ref` optional, needed only when the source name is ambiguous.)\n* Some queries accept parameters (givens). More info if needed: yo_help("language/givens-model-level-parameters")\n\n# Run the query\n* Pass a plain-English question with EVERY query, describing what that specific query answers. Queries are recorded/shared independently. Don\'t try to group related queries.\n* query(source: "...", malloy: "run: source -> { ... }", question: "...") \u2014 run it; get the rows.\n\n# Displaying Results\n* Lead with a natural-language restatement of the query \u2014 a short heading works well.\n* A successful query comes back with an ltool_link \u2014 {text, url}, already assembled. It opens this exact query so the user can keep exploring, or share the insight. Follow the data with a markdown link, [\u2197 text](url).\n* When it helps the reader add a short note on how you got the answer: the Malloy logic (filters, grouping, aggregation, ordering, pipeline stages), and any post-processing done outside Malloy.',
344
- "explore/query-examples.md": "---\ndescription: Worked query examples \u2014 the handful of Malloy shapes that cover almost every question. Read this before writing a query from scratch.\n---\n# Query Examples\n\nAlmost every query is one of the shapes below. Examples use a `flights` source\n(dimensions like `carrier`, `distance`, `origin`, `destination`, `state`,\n`dep_delay`, `is_small_plane`; measures `flight_count`, `total_distance`).\nSubstitute the real fields from `describe_source`.\n\nTwo rules first: do ordering, limiting, and ranking **in Malloy**, not in client\ncode \u2014 and reuse what the source already publishes before writing your own.\n\n## Run a saved view\n\nThe source may already answer the question. A view is invoked by name:\n\n```malloy\nrun: flights -> by_carrier\n```\n\nRefine a saved view in place with `+ { \u2026 }` \u2014 no need to rewrite it:\n\n```malloy\nrun: flights -> by_carrier + { where: state = 'CA' }\n```\n\n## The workhorse query\n\nWhen no view fits, write a stage. The vast majority of queries are this shape \u2014\n`group_by` the dimensions, `aggregate` the **measures the source already\ndeclares**, `where` to filter, `order_by` to rank. Reuse the measures\n`describe_source` lists (here `flight_count`); don't re-derive an aggregate the\nsource already defines (`count()`):\n\n```malloy\nrun: flights -> {\n group_by: destination\n aggregate: flight_count\n where: state = 'CA'\n order_by: flight_count desc\n limit: 10\n}\n```\n\nEvery clause is `keyword:` (note the colon, including `order_by:`).\n\n### Aggregate moves\n\n**Filtered aggregate** \u2014 `measure { where: \u2026 }` filters *that one number* only,\nindependent of the stage `where:`. Two kinds of `where`: stage-level (which rows\nenter the query) vs aggregate-level (which rows a single aggregate counts).\n\n```malloy\nrun: flights -> {\n group_by: carrier\n aggregate:\n flight_count\n percent_late is flight_count { where: dep_delay > 15 } / flight_count * 100\n}\n```\n\n**Aggregates from scratch** \u2014 *only when the source has no measure for what you\nneed*, define your own with `is`:\n\n```malloy\nrun: flights -> {\n group_by: carrier\n aggregate:\n flight_count is count() -- row count\n destination_count is count(destination) -- DISTINCT destinations\n total_distance is distance.sum() -- field-first aggregation\n}\n```\n\n**Percent of total with `all()`** \u2014 `all(expr)` ignores the `group_by:` to give\nthe grand total, so a share is `part / all(part)`:\n\n```malloy\nrun: flights -> {\n group_by: carrier\n aggregate:\n flight_count\n pct_of_total is flight_count / all(flight_count)\n}\n```\n\n(`all(expr, dim)` totals within a subgroup \u2014 it takes the **alias from\n`group_by:`**, not a dotted path.)\n\n### Declare once, reuse \u2014 `extend:`\n\nWhen an expression repeats in a query, define it locally in an `extend:` block:\n\n```malloy\nrun: flights -> {\n extend: {\n measure: total_distance is distance.sum()\n dimension: state_first_letter is substr(state, 1, 1)\n }\n group_by: state_first_letter\n aggregate:\n total_distance\n small_plane_distance is total_distance { where: is_small_plane }\n}\n```\n\n**Composing aggregates.** You can't reference one aggregate from another in the\nsame stage (`aggregate: a is \u2026, b is a/2` fails \u2014 *\"'a' is not defined\"*). To\nbuild a value *from* other aggregates \u2014 a ratio, a share \u2014 define the parts as\nmeasures in `extend:` (measures **can** reference each other), then use them:\n\n```malloy\nrun: flights -> {\n extend: {\n measure:\n late_flights is flight_count { where: dep_delay > 15 }\n pct_late is late_flights / flight_count\n }\n group_by: carrier\n aggregate: late_flights, pct_late\n order_by: pct_late desc\n}\n```\n\nTo rank by a computed value, name it (in `aggregate:` or `extend:`) and\n`order_by:` that name \u2014 `order_by:` takes an output field name, never a raw\nexpression, and there is no `derive:` step.\n\n## Flat detail rows \u2014 `select:`\n\nTo fetch raw rows instead of aggregating, use `select:` (the columns to return):\n\n```malloy\nrun: flights -> {\n select: id, carrier, origin, destination, distance\n where: distance > 1000\n order_by: distance desc\n limit: 100\n}\n```\n\nA stage is **either** a reduction (`group_by:` / `aggregate:`) **or** a projection\n(`select:`) \u2014 never both in the same stage. And `select:` **cannot** be combined\nwith `nest:` in any way; nesting belongs to reductions.\n\n## Nesting \u2014 a sub-table per row\n\n`nest:` attaches a whole query to each row of the outer one. This is where\nanswers get their depth (a per-row ranked breakdown):\n\n```malloy\nrun: flights -> {\n group_by: origin\n aggregate: flight_count\n nest: by_carrier is {\n group_by: carrier\n aggregate: flight_count\n order_by: flight_count desc\n limit: 5\n }\n}\n```\n\n## SQL habits that are WRONG in Malloy\n\n| You'd write in SQL | Malloy |\n| --- | --- |\n| `COUNT(DISTINCT x)` | `count(x)` \u2014 `count(distinct x)` is a deprecated error |\n| `SUM(x)` | `x.sum()` |\n| `SUM(x) OVER ()` | `all(x)` |\n| `COUNT(*) FILTER (WHERE c)` / `CASE WHEN` | `count() { where: c }` |\n| `SELECT \u2026 GROUP BY \u2026` | `group_by:` + `aggregate:` (one stage is reduction OR `select:`, never both) |\n",
169
+ "explore/query-examples.md": "---\ndescription: Worked query examples \u2014 the handful of Malloy shapes that cover almost every question. Read this before writing a query from scratch.\n---\n# Query Examples\n\nAlmost every query is one of the shapes below. Examples use a `flights` source\n(dimensions like `carrier`, `distance`, `origin`, `destination`, `state`,\n`dep_delay`, `is_small_plane`; measures `flight_count`, `total_distance`).\nSubstitute the real fields from `describe_source`.\n\nTwo rules first: do ordering, limiting, and ranking **in Malloy**, not in client\ncode \u2014 and reuse what the source already publishes before writing your own.\n\n## Run a saved view\n\nThe source may already answer the question. A view is invoked by name:\n\n```malloy\nrun: flights -> by_carrier\n```\n\nRefine a saved view in place with `+ { \u2026 }` \u2014 no need to rewrite it:\n\n```malloy\nrun: flights -> by_carrier + { where: state = 'CA' }\n```\n\n## The workhorse query\n\nWhen no view fits, write a stage. The vast majority of queries are this shape \u2014\n`group_by` the dimensions, `aggregate` the **measures the source already\ndeclares**, `where` to filter, `order_by` to rank. Reuse the measures\n`describe_source` lists (here `flight_count`); don't re-derive an aggregate the\nsource already defines (`count()`):\n\n```malloy\nrun: flights -> {\n group_by: destination\n aggregate: flight_count\n where: state = 'CA'\n order_by: flight_count desc\n limit: 10\n}\n```\n\nEvery clause is `keyword:` (note the colon, including `order_by:`).\n\n### Aggregate moves\n\n**Filtered aggregate** \u2014 `measure { where: \u2026 }` filters *that one number* only,\nindependent of the stage `where:`. Two kinds of `where`: stage-level (which rows\nenter the query) vs aggregate-level (which rows a single aggregate counts).\n\n```malloy\nrun: flights -> {\n group_by: carrier\n aggregate:\n flight_count\n percent_late is flight_count { where: dep_delay > 15 } / flight_count * 100\n}\n```\n\n**Aggregates from scratch** \u2014 *only when the source has no measure for what you\nneed*, define your own with `is`:\n\n```malloy\nrun: flights -> {\n group_by: carrier\n aggregate:\n flight_count is count() -- row count\n destination_count is count(destination) -- DISTINCT destinations\n total_distance is distance.sum() -- field-first aggregation\n}\n```\n\n**Percent of total with `all()`** \u2014 `all(expr)` ignores the `group_by:` to give\nthe grand total, so a share is `part / all(part)`:\n\n```malloy\nrun: flights -> {\n group_by: carrier\n aggregate:\n flight_count\n pct_of_total is flight_count / all(flight_count)\n}\n```\n\n(`all(expr, dim)` totals within a subgroup \u2014 it takes the **alias from\n`group_by:`**, not a dotted path.)\n\n### Declare once, reuse \u2014 `extend:`\n\nWhen an expression repeats in a query, define it locally in an `extend:` block:\n\n```malloy\nrun: flights -> {\n extend: {\n measure: total_distance is distance.sum()\n dimension: state_first_letter is substr(state, 1, 1)\n }\n group_by: state_first_letter\n aggregate:\n total_distance\n small_plane_distance is total_distance { where: is_small_plane }\n}\n```\n\n**Composing aggregates.** You can't reference one aggregate from another in the\nsame stage (`aggregate: a is \u2026, b is a/2` fails \u2014 *\"'a' is not defined\"*). To\nbuild a value *from* other aggregates \u2014 a ratio, a share \u2014 define the parts as\nmeasures in `extend:` (measures **can** reference each other), then use them:\n\n```malloy\nrun: flights -> {\n extend: {\n measure:\n late_flights is flight_count { where: dep_delay > 15 }\n pct_late is late_flights / flight_count\n }\n group_by: carrier\n aggregate: late_flights, pct_late\n order_by: pct_late desc\n}\n```\n\nTo rank by a computed value, name it (in `aggregate:` or `extend:`) and\n`order_by:` that name \u2014 `order_by:` takes an output field name, never a raw\nexpression, and there is no `derive:` step.\n\n## Flat detail rows \u2014 `select:`\n\nTo fetch raw rows instead of aggregating, use `select:` (the columns to return):\n\n```malloy\nrun: flights -> {\n select: id, carrier, origin, destination, distance\n where: distance > 1000\n order_by: distance desc\n limit: 100\n}\n```\n\nA stage is **either** a reduction (`group_by:` / `aggregate:`) **or** a projection\n(`select:`) \u2014 never both in the same stage. And `select:` **cannot** be combined\nwith `nest:` in any way; nesting belongs to reductions.\n\n## Nesting \u2014 a sub-table per row\n\n`nest:` attaches a whole query to each row of the outer one. This is where\nanswers get their depth (a per-row ranked breakdown):\n\n```malloy\nrun: flights -> {\n group_by: origin\n aggregate: flight_count\n nest: by_carrier is {\n group_by: carrier\n aggregate: flight_count\n order_by: flight_count desc\n limit: 5\n }\n}\n```\n\n**Listing top-N detail rows in a nest \u2014 `group_by:`, not `select:`.** A nest is a\nreduction, so to nest raw rows (not an aggregate) list the columns with\n`group_by:` (`select:` is not allowed inside a nest):\n\n```malloy\nrun: flights -> {\n group_by: carrier\n aggregate: flight_count\n nest: longest_flights is {\n group_by: origin, destination, distance\n order_by: distance desc\n limit: 5\n }\n}\n```\n\n## Multi-stage \u2014 aggregate, then aggregate again (`->`)\n\nA second `->` runs another stage over the **output** of the first. Reach for it\nwhen you need to aggregate an aggregate \u2014 e.g. the **peak** of a per-period\ntotal. You can't write `flight_count.max()` (that's an aggregate of an aggregate\n\u2014 it errors); compute the per-period total in one stage, then take the max in the\nnext:\n\n```malloy\nrun: flights -> {\n group_by: carrier, dep_year\n aggregate: flights_that_year is flight_count\n} -> {\n group_by: carrier\n aggregate: peak_year is flights_that_year.max()\n}\n```\n\nIn the second stage `flights_that_year` is an ordinary column (the first stage's\noutput), so `.max()` is valid. The same shape filters or re-ranks already-\naggregated rows.\n\n## SQL habits that are WRONG in Malloy\n\n| You'd write in SQL | Malloy |\n| --- | --- |\n| `COUNT(DISTINCT x)` | `count(x)` \u2014 `count(distinct x)` is a deprecated error |\n| `SUM(x)` | `x.sum()` |\n| `SUM(x) OVER ()` | `all(x)` |\n| `COUNT(*) FILTER (WHERE c)` / `CASE WHEN` | `count() { where: c }` |\n| `SELECT \u2026 GROUP BY \u2026` | `group_by:` + `aggregate:` (one stage is reduction OR `select:`, never both) |\n",
345
170
  "explore/restricted-queries.md": "# Restricted Query Explanation\n\nThe `query` tool runs your Malloy against a **published model**. You have that\nmodel's entire published surface to work with \u2014 and you can build on it. The\nmodel is an inentionally curated subset of the data available in the\ndatabase.\n\n## You can\n\n- Use everything the model defines: its **sources, dimensions, measures, views,\n joins, and named queries**. `describe_source` shows exactly what's there.\n- **Run a named query and refine it** \u2014\n `run: top_carriers + { where: dep_year = 2024 }`.\n- **Define your own** dimensions, measures, and **your own sources and joins** \u2014\n as long as they are *derived from the model's sources*. You are not limited to\n the author's fields; compose new ones from them.\n- Reference the model's `$NAME` givens and supply values via the `givens` map on\n the `query` call (use `execute: false` to discover which a query needs).\n- Use a model field that was itself defined with raw SQL \u2014 the author vouched\n for the model's own definitions.\n\n## What is \"Restricted\"\n\nIf you see `restricted-construct-forbidden`, the query used something that\nreaches *outside* the published model: pulling in another file (`import`),\nopening a raw connection (`connection.table(...)` / `connection.sql(...)`),\nwriting raw SQL (`name!type(...)` or the `sql_*` functions), declaring new\n`given:`s, or setting `##!` compiler flags.\n\nThe fix is never to work around it \u2014 express the answer in terms of what the\nmodel publishes (define derived sources, joins, dimensions, and measures from\nthe model's sources). If something fundamental is missing, that's feedback for\nthe model's author.\n",
346
171
  "language/malloy-language-reference.md": '<!-- Copied from malloy-cli (jrtipton/malloy-cli) skills/malloy-language-reference.md on 2026-06-11.\n Deliberate temporary fork \u2014 converge when the engine is extracted to @malloydata. -->\n---\ndescription: Malloy language reference \u2014 concepts, syntax, compilation model. Load this before writing or reviewing Malloy code.\n---\n# Malloy Language Reference\n\nMalloy is a semantic data modeling and query language. It compiles to SQL and runs against existing database engines (DuckDB, BigQuery, Snowflake, PostgreSQL, MySQL, Trino, Presto). It is not a SQL wrapper or abstraction layer \u2014 it has its own type system, scoping rules, expression semantics, and compilation pipeline.\n\nMalloy is designed around how humans think about data, not how data computations are mechanically accomplished. SQL is oriented around the machine \u2014 you specify joins, group-by columns, subqueries, and window functions in terms of what the database needs to do. Malloy is oriented around the analyst \u2014 you describe relationships, name computations, and compose questions in terms of what the data means. Malloy bridges the gap between these two by compiling the human-oriented description into correct, efficient SQL.\n\nA core design principle is that **most queries are themselves designing a new semantic model.** Formulating a question about data \u2014 choosing what to group by, what to aggregate, what to nest \u2014 is inherently an act of defining a new way to look at that data. Malloy is built around this idea: the output of every query is not just a result set but a new source with its own schema, and data comprehension is an ongoing iterative process where later stages want not only the data from a previous stage but how that data came into being. This is why query output carries metadata, why queries can be used as sources, and why views and pipelines compose naturally.\n\n## Documents and Statements\n\nA Malloy file (`.malloy`) is a sequence of statements, optionally separated by semicolons. There are five statement types:\n\n- **`import`** \u2014 import sources and queries from another `.malloy` file\n- **`source:`** \u2014 define a named, reusable data source with its schema and extensions\n- **`query:`** \u2014 define a named query (source + view) for reuse\n- **`run:`** \u2014 execute a query (the "do it now" statement)\n- **`given:`** \u2014 declare model-level parameters supplied at run time (experimental, see Givens)\n\n```malloy\nimport "shared_model.malloy"\n\nsource: flights is duckdb.table(\'flights.parquet\') extend {\n measure: flight_count is count()\n}\n\nquery: carrier_summary is flights -> {\n group_by: carrier\n aggregate: flight_count\n}\n\nrun: carrier_summary\n```\n\nComments use `//` or `--` (both are line comments).\n\n## Sources\n\nA **source** is anything you can hand a SQL database and get a schema back \u2014 a table name, a SQL SELECT, or the output of another Malloy query. The columns in that schema become the source\'s initial fields (all dimensions).\n\n```malloy\nsource: flights is duckdb.table(\'flights.parquet\')\nsource: limited is duckdb.sql("""SELECT * FROM flights LIMIT 100""")\nsource: carrier_facts is carrier_summary -- a query used as a source\n```\n\nWhat makes sources central to Malloy is **extension**. The `extend` block lets you layer on dimensions, measures, views, joins, filters, primary keys, field restrictions, and renames. These extensions travel with the source \u2014 any query against it gets them for free.\n\n```malloy\nsource: flights is duckdb.table(\'flights.parquet\') extend {\n primary_key: id\n\n dimension: distance_km is distance * 1.609344\n\n measure:\n flight_count is count()\n total_distance is sum(distance)\n\n join_one: carriers with carrier\n join_one: origin_airport is airports on origin_airport.code = origin\n\n where: dep_time > @2001\n\n view: by_carrier is {\n group_by: carrier\n aggregate: flight_count, total_distance\n }\n}\n```\n\nSources can extend other sources, creating a refinement chain:\n\n```malloy\nsource: ca_flights is flights extend {\n where: origin.state = \'CA\'\n}\n```\n\nField access control uses `accept:` (allowlist) or `except:` (denylist) to restrict which inherited columns are visible. Fields can be renamed with `rename: new_name is old_name`.\n\n## Joins\n\nJoins are declared in the source, not reconstructed in every query. This is a fundamental design difference from SQL: the graph structure of your data is a property of the model.\n\n```malloy\njoin_one: carriers with carrier -- FK \u2192 PK shorthand\njoin_one: origin_airport is airports on origin_airport.code = origin -- explicit ON\njoin_many: line_items on line_items.order_id = id -- one-to-many\njoin_cross: other_table on other_table.key = key -- cross join\n```\n\n- `join_one` \u2014 the joined source has at most one row per source row (many-to-one or one-to-one)\n- `join_many` \u2014 the joined source has potentially many rows per source row\n- `join_cross` \u2014 a full cross product\n\nThe `with` shorthand requires the joined source to have a declared `primary_key`. All joins are left outer by default. There is no right join \u2014 Malloy\'s graph model doesn\'t need one.\n\n**Choosing `join_one` vs `join_many`:** Ask "for a single row in the base source, can the joined source match more than one row?" If yes \u2192 `join_many`. If no (or at most one) \u2192 `join_one`. The common mistake is reaching for `join_many` when joining a *lookup or summary table* (e.g., joining an inventory snapshot to a purchase history on a wine key). Even though the joined table may have many rows overall, if each base row resolves to *at most one* joined row, use `join_one`. Use `join_many` only when the join genuinely fans out the base rows \u2014 e.g., joining line items to orders, or notes to a wine catalog.\n\nWhen you reference a joined source\'s fields, you use dot notation: `carriers.nickname`, `origin_airport.state`. This is one of Malloy\'s most important abstractions: **the access path to nested data is identical regardless of how the nesting is physically stored.** An array of records embedded in a column, a `join_many` to a separate table, a record-typed column \u2014 all are navigated with the same dot notation. The SQL required to traverse these different physical arrangements varies wildly (unnesting arrays, LEFT JOINs, correlated subqueries, ARRAY_AGG), but Malloy hides all of that. You think about the logical shape of your data \u2014 "flights have carriers, carriers have a nickname" \u2014 and write `carriers.nickname`. The compiler figures out what SQL is needed to get there. This means you can restructure your physical schema (normalize a nested array into a separate table, or denormalize a joined table into a record column) without changing any of the Malloy that references that data.\n\n## Fields\n\nMalloy has four kinds of fields: **dimensions**, **measures**, **views**, and **calculations**.\n\n### Dimensions\n\nScalar expressions \u2014 they compute a value per row. All columns inherited from a table are dimensions. Computed dimensions reference other dimensions or columns:\n\n```malloy\ndimension: full_name is concat(first_name, \' \', last_name)\ndimension: is_long_haul is distance > 1000\n```\n\n### Measures\n\nAggregate expressions \u2014 they compute a value across a set of rows. A field is a measure when its defining expression contains an aggregate function (`count`, `sum`, `avg`, `min`, `max`):\n\n```malloy\nmeasure:\n flight_count is count()\n total_distance is sum(distance)\n avg_distance is avg(distance)\n pct_delayed is count() { where: dep_delay > 30 } / count()\n```\n\n**`count(expr)` counts distinct values.** Unlike SQL\'s `COUNT(DISTINCT expr)`, Malloy uses `count(expr)` for distinct counting. The `count(distinct expr)` form is a deprecated syntax that will produce an error. Use `count()` for total row count, `count(field)` for distinct values of that field:\n\n```malloy\naggregate:\n total_rows is count() -- all rows\n unique_carriers is count(carrier) -- distinct carriers\n```\n\nMeasures can be filtered inline with `{ where: ... }`, which is how you build things like "percent of flights delayed" without subqueries.\n\n### Views\n\nA view is a query saved into the source \u2014 a reusable transformation:\n\n```malloy\nview: by_carrier is {\n group_by: carrier\n aggregate: flight_count, total_distance\n limit: 10\n}\n```\n\nViews can reference other views from the same source as a starting point, and can be extended with `+`.\n\n### Calculations\n\nWindow functions over the grouped result. Calculations can only be defined in a query stage with `calculate:`, never in a source definition, because they depend on the output columns of the query:\n\n```malloy\nrun: flights -> {\n group_by: carrier\n aggregate: flight_count\n calculate: carrier_rank is rank()\n}\n```\n\n## Queries and Views\n\nA query pairs a source with a view (the transformation). Everything after the first `->` is the view.\n\n```malloy\nrun: flights -> {\n group_by: carrier\n aggregate: flight_count\n}\n```\n\n### Reduction vs. Projection\n\nEach stage of a view performs exactly one of:\n\n- **Reduction** \u2014 uses `group_by:` and/or `aggregate:` to reduce grain. Analogous to `SELECT ... GROUP BY` in SQL.\n- **Projection** \u2014 uses `select:` to pick fields without aggregation. Analogous to `SELECT` without `GROUP BY`.\n\nThese cannot be mixed in a single stage. A stage with `group_by:` cannot have `select:`, and vice versa.\n\n### Source-level definitions vs. query-level operations\n\nThe same `name is expression` syntax defines fields in both sources and queries:\n\n```malloy\n-- In a source (reusable):\nsource: flights is ... extend {\n measure: flight_count is count() -- defines a measure in the model\n}\n\n-- In a query (ad hoc):\nrun: flights -> {\n aggregate: flight_count is count() -- defines the same measure inline\n}\n```\n\nWhen used in a source, `measure:` and `dimension:` are **definition statements** \u2014 they add named fields to the source\'s schema. When used in a query, `group_by:`, `aggregate:`, `select:`, `nest:`, and `calculate:` are **query operations** \u2014 they specify what the query does. The field definitions are syntactically identical in both contexts, but the enclosing keyword determines the role:\n\n| Source keyword | Query keyword | What it holds |\n|---|---|---|\n| `dimension:` | `group_by:` or `select:` | scalar expressions |\n| `measure:` | `aggregate:` | aggregate expressions |\n| `view:` | `nest:` | sub-queries |\n| _(n/a)_ | `calculate:` | window functions |\n\nThis is why `measure` and `aggregate` are separate keywords. `measure:` is a *modeling* statement \u2014 "this source has a reusable aggregate computation called X." `aggregate:` is a *query* statement \u2014 "in this query, include these aggregate values in the output." A query\'s `aggregate:` can reference a previously defined measure by name, or define one inline. The distinction parallels the separation between defining a dimension in a source and using it via `group_by:` in a query.\n\n### Multi-stage Pipelines\n\nStages chain with `->`. Each stage\'s output becomes the next stage\'s source:\n\n```malloy\nrun: flights -> {\n group_by: carrier\n aggregate: flight_count is count()\n} -> {\n where: flight_count > 1000\n select: *\n}\n```\n\n### Refinement with `+`\n\nThe refinement operator `+` merges query operations together. It works both within a view and at the top level on a named query:\n\n```malloy\n-- Refining a view within a query:\nrun: flights -> by_carrier + { limit: 5 } + { nest: by_destination }\n\n-- Refining a named query at the top level:\nrun: carrier_summary + { group_by: origin } -- add origin grouping to existing query\n```\n\nWhen a dimension name appears as a bare reference, it expands to `{ group_by: name }`. A measure name expands to `{ aggregate: name }`:\n\n```malloy\nrun: flights -> carrier + flight_count + { limit: 10 }\n-- equivalent to: flights -> { group_by: carrier; aggregate: flight_count; limit: 10 }\n```\n\nFor multi-stage queries, refinement semantics get more complex \u2014 but for single-stage queries, `+` straightforwardly merges operations into the stage.\n\n### Nesting\n\n`nest:` embeds an aggregating subquery inside a reduction. Each row of the outer query gets a subtable from the nested query. Nests can nest arbitrarily deep:\n\n```malloy\nrun: flights -> {\n group_by: carrier\n aggregate: flight_count\n nest: top_routes is {\n group_by: origin, destination\n aggregate: flight_count\n limit: 3\n }\n}\n```\n\n### Other query operations\n\n- **`where:`** \u2014 filter rows (pre-aggregation). Comma-separated filters are ANDed.\n- **`having:`** \u2014 filter groups (post-aggregation), like SQL\'s HAVING.\n- **`limit:`** / **`order_by:`** \u2014 limit and sort output.\n- **`extend`** \u2014 add fields or joins to a source inline within a query expression.\n\n## Aggregate Locality (Symmetric Aggregates)\n\nThis is one of Malloy\'s most important features. In SQL, when you join tables and aggregate, you risk double-counting (the "fan trap"). Malloy solves this with **aggregate locality** \u2014 you specify *where in the join graph* an aggregation should be computed.\n\n```malloy\nrun: flights -> {\n aggregate:\n -- avg seats weighted by number of flights (locality: source, i.e. flights)\n avg_seats_per_flight is source.avg(aircraft.aircraft_models.seats)\n -- avg seats per aircraft model (locality: aircraft_models)\n avg_seats_per_model is aircraft.aircraft_models.seats.avg()\n}\n```\n\nThree syntactic forms:\n\n- `avg(expr)` \u2014 aggregate at the current source (implicit locality)\n- `joined_source.avg(expr)` \u2014 aggregate at the specified join point (explicit locality)\n- `joined_source.field.avg()` \u2014 shorthand for aggregating the field at its parent source\n\nFor `sum` and `avg` (asymmetric aggregates), when the expression crosses a join boundary, Malloy *requires* explicit locality \u2014 it won\'t silently give you a wrong answer. For `min`, `max`, and `count` (symmetric), locality doesn\'t change the result, so implicit is always fine.\n\nMalloy implements this with a technique called **symmetric aggregates** \u2014 it internally de-duplicates rows based on primary keys at the appropriate join level, so aggregations are always mathematically correct regardless of join fan-out.\n\n## Ungrouped Aggregates\n\n`all()` and `exclude()` allow computing aggregates at different grouping levels within a single query:\n\n```malloy\nrun: airports -> {\n group_by: state, faa_region\n aggregate:\n airport_count is count()\n total_airports is all(count()) -- ungrouped: total across all rows\n region_airports is all(count(), faa_region) -- grouped only by faa_region\n pct_of_total is count() / all(count())\n}\n```\n\n`all(expr)` removes all grouping. `all(expr, dim1, dim2)` keeps only the specified grouping dimensions. `exclude(expr, dim)` removes the specified dimension from grouping.\n\n**Important:** `all(expr, dim)` takes the **local alias name** as defined in the query\'s `group_by:`, not a dotted path. If you want to partition by a joined field, alias it first:\n\n```malloy\n-- WRONG: all(count(), director.primaryName) -- dot paths don\'t work here\n-- RIGHT:\nrun: movies -> {\n group_by: director is director.primaryName -- alias it\n aggregate:\n movies is count()\n director_total is all(count(), director) -- reference the alias\n pct is count() / all(count(), director)\n}\n```\n\n## Expressions\n\nMalloy expressions include arithmetic, comparison, logical operators, function calls, type casts, and several Malloy-specific forms.\n\n### Evaluation Spaces\n\nEvery expression has an evaluation space: **literal**, **constant**, **input**, or **output**. Input expressions reference source columns/dimensions. Output expressions reference the results of the current query stage (used in `calculate:`). Some functions constrain their arguments \u2014 e.g., `lag(expr)` requires an output expression, `avg(expr)` requires an input expression.\n\n### Application and Partial Comparison\n\nThe `?` operator applies a condition to a value. Partial comparisons are conditions without a left-hand side:\n\n```malloy\nwhere: state ? \'CA\' | \'NY\' -- state is \'CA\' or \'NY\'\nwhere: distance ? > 500 & < 2000 -- distance between 500 and 2000\n```\n\n`|` is alternation (OR), `&` is conjunction (AND) within partials.\n\n### Pick Expressions\n\nMalloy\'s equivalent of CASE:\n\n```malloy\ndimension: size_bucket is\n pick \'short\' when distance < 500\n pick \'medium\' when distance < 1500\n else \'long\'\n```\n\n### Filtered Aggregate Expressions\n\nAny aggregate can be filtered inline:\n\n```malloy\nmeasure: ca_flights is count() { where: origin.state = \'CA\' }\n```\n\n### Type Casting\n\n```malloy\ntotal_distance::string -- Malloy type cast\nname::"VARCHAR(32)" -- database-native type cast\n```\n\n### Time Literals and Ranges\n\n```malloy\n@2003 -- the year 2003\n@2003-Q2 -- second quarter of 2003\n@2024-01-15 10:30:00 -- timestamp literal\ndep_time ? @2003 to @2005 -- range comparison\nnow -- current timestamp\n```\n\n## Data Types\n\nMalloy\'s type system: `string`, `number`, `boolean`, `date`, `timestamp`, `timestamptz`, `json`, and `sql native` (for unsupported database types). Compound types: `type[]` for arrays, `{ name :: type, ... }` for records, nesting arbitrarily: `{ x :: number, tags :: string[] }[]`.\n\n## Annotations and Tags\n\nThese are related but distinct concepts.\n\n### Annotations\n\nAnnotations are **text strings** attached to objects during compilation. They are metadata \u2014 they never affect query execution or SQL generation. An annotation starts with `#` and continues to end of line:\n\n```malloy\n# bar_chart\nview: by_carrier is { ... }\n```\n\n- `#` annotations attach to the next object defined below them\n- `##` annotations attach to the model (the file)\n- Block annotations use `#|` ... `|#` for multi-line content (closing delimiter must match the column position of the opener)\n\nAnnotations distribute over definition lists:\n\n```malloy\n# currency\nmeasure: -- all three measures get the # currency annotation\n revenue is sum(amount)\n # percent -- this measure also gets # percent\n margin is revenue / cost\n cost is sum(amount)\n```\n\n### Tags (a use of annotations)\n\nTags are the primary *consumer* of annotation strings. They interpret annotation text using a structured property language (MOTLY). The key distinction: **annotations are the transport mechanism (raw strings attached to objects), tags are the interpretation layer (parsed key-value properties).**\n\nNot all annotations are tags. An annotation is just text. Tags are annotations that happen to be written in the tag property language and parsed by an application.\n\n### Annotation prefixes (routing)\n\nThe character(s) immediately after `#` route the annotation to different consumers:\n\n- `# ` (hash-space) \u2014 renderer tags, parsed by the Malloy VS Code extension for visualization\n- `##!` \u2014 compiler directives (e.g., `##! experimental.parameters`, `##! experimental.givens`)\n- `#"` \u2014 reserved for documentation strings\n- `#(appName)` \u2014 application-specific tags (e.g., `#(docs) hidden`)\n\n```malloy\n# bar_chart size=large -- renderer tag: tells VS Code how to render\n##! experimental.parameters -- compiler tag: enables a feature flag\n#(myApp) priority=high -- custom app tag: ignored by renderer/compiler\n```\n\n### Tag property syntax\n\n```\ntName -- boolean flag (exists = true)\ntName=value -- set property value\ntName=[a, b, c] -- array value\ntName: { p1=v1 p2=v2 } -- nested properties (replaces)\ntName { p1=v1 } -- nested properties (merges)\n-tName -- delete a property\ntName.sub.path=value -- deep path assignment\n```\n\nValues can be unquoted identifiers, quoted strings, numbers, or typed values prefixed with `@` (`@true`, `@false`, `@2024-01-15`).\n\n## Givens (Model-Level Parameters)\n\n**Status: experimental, gated by `##! experimental.givens`.** Naming is provisional.\n\nGivens are values supplied at run time that the model can reference in any expression. The motivating use case is row-level access control \u2014 a model written once with `where: x.tenant_id = $TENANT` and the tenant supplied per API call \u2014 but they also fit configuration values, session context, and any "one compiled model, many invocations with varying context" pattern.\n\nGivens are model-wide: a single namespace, one value per name per compilation. They are *complementary to* source/query parameters (`source: foo(x :: string) is ...`), not a replacement. Use a parameter when you want two differently-bound copies of the same source side-by-side in one model; use a given when you want one value visible everywhere in the compilation.\n\n### Declaration\n\nThe `given:` top-level statement introduces givens, with a name, a type, and an optional default:\n\n```malloy\ngiven:\n TENANT :: string\n MAX_ROWS :: number is 1000\n CUTOFF_DATE :: date is @2024-01-01\n```\n\nType can be any Malloy atomic type or compound type, including `filter<T>`:\n\n```malloy\ngiven:\n ROLE :: string\n ALLOWED_ROLES :: string[]\n SESSION :: { user_id :: string, tenant :: string }\n TENANT_FILTER :: filter<string>\n```\n\nDefaults are expressions over constants and other givens. Annotations attach to given declarations the same way they attach to sources or measures.\n\n### Reference: the `$` sigil\n\nInside any expression, a given is referenced with a leading `$`:\n\n```malloy\nsource: orders_for_user is orders extend {\n where: orders.tenant_id = $TENANT\n}\n\nquery: recent_orders is orders_for_user -> {\n where: order_date >= $CUTOFF_DATE\n limit: $MAX_ROWS\n}\n```\n\n`$` appears *only* at expression references. The other three sites where a given\'s name appears \u2014 declaration, import, and supply (caller side) \u2014 use the bare name, because syntactic position already disambiguates. Givens share the top-level declaration namespace with sources/queries/views, so `source: x is ...` plus `given: x :: string` is a name-conflict error.\n\n### Set membership: `expr in $arrayGiven`\n\nThe RHS of `in` is either a parenthesized list of expressions (`in (1, 2, x, y * 7)`, same as SQL) or a given with an array value (`in $ARR`). A bare array-typed expression \u2014 a dimension, a joined array field, an inline `[a, b, c]` literal \u2014 is *not* legal on the RHS; arrays only reach the RHS via the given form.\n\nWhen a given has array type, `expr in $ARR` tests `expr` against the runtime-bound array; `not in $ARR` is the negation. The left-hand side must match the array\'s element type (`string in $string[]`, `number in $number[]`, etc.); mismatches are translate-time errors. Records and nested arrays are out of scope.\n\n```malloy\ngiven:\n ALLOWED_STATES :: string[]\n URGENT_STATUSES :: string[]\n\nsource: orders extend {\n where: state in $ALLOWED_STATES\n dimension: is_urgent is order_status in $URGENT_STATUSES\n}\n```\n\nAt SQL emit, the array\'s contents land in a generated `IN (...)` clause. Empty or `null` arrays collapse to the obvious result (`IN` \u2192 `FALSE`, `NOT IN` \u2192 `TRUE`). NULL elements inside a non-empty array follow standard SQL `IN` semantics.\n\nTo derive a value from an array \u2014 typically a boolean gate \u2014 *without* the array itself reaching row-position SQL, use an inline given (below).\n\n### Inline givens\n\nAn `inline` given is evaluated at **bind time**, before SQL is emitted: its default expression runs against the resolved given values and reduces to a literal, and that literal is what reaches SQL.\n\n```malloy\ngiven:\n CAPABILITIES :: string[]\n inline CAN_READ_ORDERS :: boolean is \'read_orders\' in $CAPABILITIES\n inline CAN_MUTATE :: boolean\n is \'write_orders\' in $CAPABILITIES or \'admin\' in $CAPABILITIES\n\nsource: orders extend {\n where: $CAN_READ_ORDERS -- SQL sees: WHERE ... AND TRUE (or FALSE)\n}\n```\n\nThis is the **row-level access-control gate** pattern: the host supplies a capability list as a regular given, an inline given derives a boolean from it, and only the boolean \u2014 not the list \u2014 crosses into row-position SQL. The query planner sees a constant predicate.\n\nRules:\n\n- An inline given **must** have a default. `inline FOO :: number` with no `is` clause is a translate-time error.\n- The default may use:\n - Boolean and comparison operators: `and`, `or`, `not`, `=`, `!=`, `<`, `<=`, `>`, `>=`\n - The `in $array` test against another given\n - Literals (string, number, boolean, null, array) and references to other givens\n- The default cannot call SQL functions, reference fields, or use any operator outside that list. Disallowed operators are reported at translate time with the offending operator names.\n- Inline givens are filtered out of `Model.givens` and `PreparedQuery.givens` \u2014 they\'re computed, not supplied \u2014 so introspection-driven UIs don\'t render editors for them. A caller can still shadow one by binding it explicitly (useful in tests).\n- `inline` is a context-sensitive modifier, not a reserved keyword: fields, sources, views, dimensions, and joins can still be named `inline`.\n\n### Imports\n\nGivens behave like every other top-level named thing under import:\n\n- **Bare import** (`import "b.malloy"`) brings B\'s full export surface in, including all of B\'s givens, under their original names.\n- **Selective import** (`import { source1 } from "b.malloy"`) brings in only what\'s listed. To surface a given to your callers, list it: `import { source1, MAX_ROWS } from "b.malloy"`.\n- **Rename** uses the existing `LOCAL is REMOTE` form: `import { CAP is MAX_ROWS } from "b.malloy"`.\n\nSurfacing controls *who can supply a value*, not whether internal references work. An imported source can reference a given the importer didn\'t surface; the reference still resolves internally, and at run time the unsurfaced given relies on its declaration-site default.\n\nA common project convention is a shared `tenant_givens.malloy` (declaring `$TENANT`, `$USER_ROLE`, etc.) that every root file bare-imports on line 1, so the project\'s given contract is visible at the top of any model.\n\n### Satisfiability\n\nA query referencing `$X` is satisfiable if either `$X` is in the model\'s namespace (so a caller can supply a value) or `$X` has a default at its declaration site. Otherwise the query is unsatisfiable and errors. Latent definitions (views, dimensions, measures) that reference `$X` are fine if no query actually invokes them \u2014 satisfiability is a property of running queries.\n\n### Supplying values\n\nValues can be supplied at two layers, which compose (per-query overrides per-runtime):\n\n**Per-runtime** \u2014 bound to a `Runtime`, applied as defaults to every query through it. Two paths:\n\n1. **`givensPath` in `malloy-config.json`** points at a JSON file of `name \u2192 value`:\n ```jsonc\n { "givensPath": "./local-givens.json" }\n // or env-var indirection (resolved at config load):\n { "givensPath": { "env": "GAME_STORE_GIVENS" } }\n ```\n The values file is a flat JSON map, keys are caller-facing surface names:\n ```jsonc\n { "TENANT": "acme", "USER_ROLE": "admin", "CUTOFF_DATE": "2024-01-01" }\n ```\n\n2. **Direct on the Runtime constructor** (for per-request multi-tenant servers, tests, scripts):\n ```typescript\n const runtime = new Runtime({\n config,\n givens: { TENANT: claims.tenant_id, USER_ROLE: claims.role },\n urlReader,\n });\n ```\n Constructor values *merge over* the file at `givensPath` per-key.\n\n**Per-query** \u2014 supplied on a single `.run({ givens: ... })` call:\n```typescript\nawait query.run({ givens: { STATE_FILTER: "CA", LIMIT_OVERRIDE: 50 } })\n```\nAvailable on every compile-or-run entry point (`runtime.loadQuery(...).run(options)`, `preparedQuery.getPreparedResult(options)`, `preparedQuery.getSQL(options)`).\n\nThe resolved per-runtime values are exposed on `runtime.givens` (read-only) for diagnostics.\n\n### Finalized givens (security primitive)\n\nA multi-tenant deployment usually wants `TENANT`/`USER_ROLE`/`REGION` to be runtime-bound and **un-overridable per-query** \u2014 otherwise a downstream endpoint that accidentally accepts user-controlled query params and plumbs them into `.run({ givens: ... })` becomes a tenant-leak vulnerability.\n\n`finalizeGivens` in the config locks names at the API surface:\n\n```jsonc\n{\n "givensPath": { "env": "GAME_STORE_GIVENS" },\n "finalizeGivens": ["TENANT", "USER_ROLE", "REGION"]\n}\n```\n\nFinalize doesn\'t change *what* a given resolves to \u2014 only *who* can supply it. A `.run({ givens: { TENANT: ... } })` for a finalized name throws at API entry (named, not silently dropped). Finalized givens are filtered out of `Model.givens` and `PreparedQuery.givens` so introspection-driven UIs don\'t render editors for locked names.\n\n### JS shapes for supplied values\n\nBoth the JSON values file and per-query `givens` maps accept the same per-type shapes:\n\n| Malloy type | JS |\n|---|---|\n| `string` | string |\n| `number` | `number`, `bigint`, or string (precision escape hatch) |\n| `boolean` | boolean |\n| `date` | ISO date string `"2024-01-15"` |\n| `timestamp` (naive) | ISO string without offset \u2014 *not* a JS `Date` |\n| `timestamptz` | JS `Date` or ISO string with offset (string preferred \u2014 makes TZ choice visible) |\n| `T[]` | JS array |\n| `{ name :: T, ... }` | JS object |\n| `filter<T>` | JS string (Malloy filter expression source) |\n\nNaive timestamp givens reject `Date` because `Date` represents a UTC instant, not a wall-clock value, and `new Date("2001-01-01T00:00:00")` silently picks up the system\'s local TZ. Type mismatches throw at the boundary with a path that points at the offending location (e.g., `givens.SESSION.user_id: expected string, got number`). `null` is legal for any given type.\n\n### Introspection\n\n`Model.givens` and `PreparedQuery.givens` expose, to the host, the supplyable givens \u2014 for whole-model parameter editors and per-query "run this" forms respectively. Each entry carries name, type, default expression (or undefined if the caller must supply), location, and access to declaration-site annotations via `tagParse`/`getTaglines`.\n\n## How a Malloy Query Becomes SQL\n\nThe compilation pipeline has two phases:\n\n### Phase 1: Translation (source code \u2192 IR)\n\n```\nMalloy source \u2192 ANTLR lexer/parser \u2192 parse tree \u2192 AST builder \u2192 AST \u2192 IR generator \u2192 IR\n```\n\nThe **Intermediate Representation (IR)** is a plain, serializable data structure (JSON-compatible) that fully describes the semantic model and query. Note that IR is *not* dialect-agnostic \u2014 the same Malloy source compiled against different databases can produce different IR, because schema information, type mappings, and available functions vary by backend. It can be cached, transmitted, and reused. Key IR types:\n\n- **`SourceDef`** \u2014 a source\'s complete definition: schema, fields, joins, filters\n- **`Query`** \u2014 a source paired with a pipeline of operations\n- **`FieldDef`** \u2014 definition of any field (dimension, measure, join, calculation)\n- **`Expr`** \u2014 expression tree (arithmetic, comparisons, aggregates, function calls, field references)\n\nThe translator handles all language-level semantics: scoping, name resolution, type checking, evaluation space validation.\n\n### Phase 2: Compilation (IR \u2192 SQL)\n\n```\nIR \u2192 query compiler \u2192 expression compiler \u2192 dialect-specific SQL generator \u2192 SQL + metadata\n```\n\nThe compiler walks the IR query pipeline, translating each stage into SQL constructs (CTEs, subqueries, GROUP BY, window functions). A **Dialect** layer handles database-specific SQL generation.\n\nThe compiler also produces **metadata** alongside the SQL \u2014 structural information needed to interpret the result set (column types, nesting structure, annotation data). This metadata is what allows Malloy renderers to reconstruct nested/hierarchical results from the flat SQL result set and apply visualization tags.\n\n### Key architectural consequences\n\n- Because the IR is serializable, it can be cached and reused across compilations (though IR is database-specific \u2014 the same source compiled against different backends may produce different IR).\n- Because joins are declared in the source (not the query), the compiler knows the full join graph and can compute symmetric aggregates correctly.\n- Because nested queries are first-class, the compiler generates the appropriate SQL (correlated subqueries or ARRAY_AGG patterns depending on dialect) automatically.\n- Because measures are typed as aggregates in the IR, the compiler can validate that they only appear in aggregate context and enforce locality rules.\n\n## Where to Go Deeper\n\nThis document is a conceptual reference \u2014 enough to reason about the language and its design, but not exhaustive. Here\'s where to find more detail.\n\n### Language Documentation\n\nThe full docs live at [https://docs.malloydata.dev](https://docs.malloydata.dev). Key pages by topic:\n\n| Topic | URL |\n|---|---|\n| Sources, extensions, joins, primary keys | [documentation/language/source](https://docs.malloydata.dev/documentation/language/source) |\n| Queries, views, reduction vs projection | [documentation/language/query](https://docs.malloydata.dev/documentation/language/query), [views](https://docs.malloydata.dev/documentation/language/views) |\n| Fields: dimensions, measures, views, calculations | [documentation/language/fields](https://docs.malloydata.dev/documentation/language/fields) |\n| Aggregate functions and aggregate locality | [documentation/language/aggregates](https://docs.malloydata.dev/documentation/language/aggregates) |\n| Ungrouped aggregates (`all`, `exclude`) | [documentation/language/ungrouped-aggregates](https://docs.malloydata.dev/documentation/language/ungrouped-aggregates) |\n| Nested views / aggregating subqueries | [documentation/language/nesting](https://docs.malloydata.dev/documentation/language/nesting) |\n| Joins | [documentation/language/join](https://docs.malloydata.dev/documentation/language/join) |\n| Expressions, operators, pick, apply | [documentation/language/expressions](https://docs.malloydata.dev/documentation/language/expressions) |\n| Evaluation spaces (literal, constant, input, output) | [documentation/language/eval_space](https://docs.malloydata.dev/documentation/language/eval_space) |\n| Filters and filter placement | [documentation/language/filters](https://docs.malloydata.dev/documentation/language/filters) |\n| Annotations and tags | [documentation/language/tags](https://docs.malloydata.dev/documentation/language/tags) |\n| Calculations and window functions | [documentation/language/calculations_windows](https://docs.malloydata.dev/documentation/language/calculations_windows) |\n| Data types | [documentation/language/datatypes](https://docs.malloydata.dev/documentation/language/datatypes) |\n| Time operations, ranges, timezones | [documentation/language/timestamp-operations](https://docs.malloydata.dev/documentation/language/timestamp-operations), [time-ranges](https://docs.malloydata.dev/documentation/language/time-ranges), [timezones](https://docs.malloydata.dev/documentation/language/timezones) |\n| Imports | [documentation/language/imports](https://docs.malloydata.dev/documentation/language/imports) |\n| Top-level statements and model structure | [documentation/language/statement](https://docs.malloydata.dev/documentation/language/statement) |\n| Functions reference | [documentation/language/functions](https://docs.malloydata.dev/documentation/language/functions) |\n\n### Examples and Patterns\n\nThe docs site includes worked examples of common analytical patterns at [documentation/patterns](https://docs.malloydata.dev/documentation/patterns/): percent-of-total, year-over-year, cohort analysis, sessionization, moving averages, nested subtotals, and more.\n\nEnd-to-end guides are at [documentation/user_guides](https://docs.malloydata.dev/documentation/user_guides/), including [Malloy by Example](https://docs.malloydata.dev/documentation/user_guides/malloy_by_example) (a comprehensive walkthrough) and a three-part series for SQL users ([part 1](https://docs.malloydata.dev/documentation/user_guides/sql_experts1), [part 2](https://docs.malloydata.dev/documentation/user_guides/sql_experts2), [part 3](https://docs.malloydata.dev/documentation/user_guides/sql_experts3)).\n\n### Source Code\n\nThe Malloy implementation lives at [github.com/malloydata/malloy](https://github.com/malloydata/malloy). Key entry points:\n\n| What | Where |\n|---|---|\n| ANTLR grammar (lexer + parser) | `packages/malloy/src/lang/grammar/` |\n| AST node hierarchy | `packages/malloy/src/lang/ast/` |\n| Parse tree \u2192 AST builder | `packages/malloy/src/lang/malloy-to-ast.ts` |\n| IR type definitions | `packages/malloy/src/model/malloy_types.ts` |\n| IR \u2192 SQL compiler | `packages/malloy/src/model/` |\n| Dialect-specific SQL generation | `packages/malloy/src/dialect/` |\n| Tag/annotation parsing (MOTLY) | `packages/malloy-tag/` |\n| Renderer | `packages/malloy-render/` |\n| Architecture overview | `CONTEXT.md` (root and in each package) |\n',
347
172
  "language/pick.md": "---\ndescription: pick expressions \u2014 Malloy's CASE/if-then-else\n---\n\n`pick` is Malloy's equivalent of SQL `CASE WHEN`. Each branch is its own\n`pick` keyword; the `else` clause catches the remainder.\n\nThere are two forms of pick. In the first the `when` expression is any\nboolean expression.\n\n```malloy\n pick 'Female' when upper(first_name) in ('JENNIFER', 'ELIZABETH', 'AMY', 'JESSICA')\n pick 'Male' when upper(first_name) in ('JAMES', 'JOHN', 'ROBERT', 'MICHAEL')\n else 'Unknown'\n```\n\n## Example usage in a query\n\n```malloy\nrun: payments -> {\n group_by: tier is\n pick 'high' when total_amount > 10000\n pick 'medium' when total_amount > 1000\n else 'low'\n aggregate: payment_count is count()\n}\n```\n\n## Common mistakes\n\n- **Every branch needs its own `pick` keyword** \u2014 there is no `when \u2026 then`:\n ```malloy\n -- WRONG:\n pick 'a' when x = 1 'b' when x = 2 else 'c'\n\n -- RIGHT:\n pick 'a' when x = 1\n pick 'b' when x = 2\n else 'c'\n ```\n\n- **`else` is required** when the branches don't cover all cases \u2014 omitting it\n returns `null` for unmatched rows.\n",
@@ -471,6 +296,7 @@ function getHelpTopic(query) {
471
296
  }
472
297
  var ERROR_TOPIC_MAP = {
473
298
  "syntax-error": "explore/query-examples",
299
+ "aggregate-of-aggregate": "explore/query-examples",
474
300
  "field-not-found": "language/fields",
475
301
  "aggregate-in-calculate": "language/expressions",
476
302
  "not-an-aggregate": "language/fields",
@@ -752,30 +578,30 @@ function walkFields(fields, structDefFields, depth, ctx, anon) {
752
578
  const ef = f;
753
579
  const cls = classifyJoinTarget(ef, ctx.knownSources);
754
580
  const inlineMode = ctx.opts.expand === "inline";
755
- const join4 = { name: f.name, relationship: joinRel(ef) };
756
- applyDocs(join4, f.annotations);
757
- if (cls.kind === "ref") join4.source_ref = cls.name;
581
+ const join5 = { name: f.name, relationship: joinRel(ef) };
582
+ applyDocs(join5, f.annotations);
583
+ if (cls.kind === "ref") join5.source_ref = cls.name;
758
584
  else if (cls.kind === "anon" && !inlineMode) {
759
- join4.anon_src_index = allocAnon(ef, cls.refId, depth + 1, ctx, anon);
585
+ join5.anon_src_index = allocAnon(ef, cls.refId, depth + 1, ctx, anon);
760
586
  }
761
- if (needsQuote(f.name)) join4.must_quote = true;
762
- if (annotations.length > 0) join4.annotations = annotations;
763
- if (loc) join4.location = loc;
587
+ if (needsQuote(f.name)) join5.must_quote = true;
588
+ if (annotations.length > 0) join5.annotations = annotations;
589
+ if (loc) join5.location = loc;
764
590
  const synthetic = isScalarArray(ef) || isRepeatedRecord(ef) || isAnonymousRecord(ef);
765
- if (isScalarArray(ef)) join4.column_shape = "scalar_array";
766
- else if (isRepeatedRecord(ef)) join4.column_shape = "record_array";
767
- else if (isAnonymousRecord(ef)) join4.column_shape = "record";
591
+ if (isScalarArray(ef)) join5.column_shape = "scalar_array";
592
+ else if (isRepeatedRecord(ef)) join5.column_shape = "record_array";
593
+ else if (isAnonymousRecord(ef)) join5.column_shape = "record";
768
594
  if (!synthetic && mLoc) {
769
595
  const body = sliceSource(ctx.readSource(mLoc.url), mLoc);
770
- if (body) join4.body = body;
596
+ if (body) join5.body = body;
771
597
  }
772
598
  const shouldInline = inlineMode || cls.kind === "own";
773
599
  if (shouldInline && depth < MAX_JOIN_DEPTH) {
774
600
  const childStructFields = ef.structDef.fields ?? [];
775
601
  const sub = walkFields(ef.allFields, childStructFields, depth + 1, ctx, anon);
776
- join4.fields = stripScalarArrayValue(ef, sub);
602
+ join5.fields = stripScalarArrayValue(ef, sub);
777
603
  }
778
- groups.joins.push(join4);
604
+ groups.joins.push(join5);
779
605
  continue;
780
606
  }
781
607
  if (f.isQueryField()) {
@@ -1179,6 +1005,67 @@ async function executeMaterialized(query, opts, loadProblems, decorate = (p) =>
1179
1005
  return { ok: false, problems: [...loadProblems, errorProblem(e, uri)] };
1180
1006
  }
1181
1007
  }
1008
+ async function run(runtime, entry, opts = {}) {
1009
+ let materializer;
1010
+ let modelQueries;
1011
+ let loadProblems;
1012
+ try {
1013
+ materializer = runtime.loadModel(entry);
1014
+ const model = await materializer.getModel();
1015
+ modelQueries = { named: [...model.queries().named], unnamed: model.queries().unnamed };
1016
+ loadProblems = mapProblems(model.problems);
1017
+ } catch (e) {
1018
+ if (e instanceof MalloyError2) {
1019
+ return { ok: false, problems: mapProblems(e.problems) };
1020
+ }
1021
+ return { ok: false, problems: [errorProblem(e, entry.href)] };
1022
+ }
1023
+ let query;
1024
+ if (opts.name !== void 0) {
1025
+ if (!modelQueries.named.includes(opts.name)) {
1026
+ return {
1027
+ ok: false,
1028
+ problems: [
1029
+ codeProblem(
1030
+ "selector-not-found",
1031
+ `No query named '${opts.name}'. Available: ` + JSON.stringify({ queries: modelQueries.named, runs: modelQueries.unnamed }),
1032
+ entry.href
1033
+ )
1034
+ ]
1035
+ };
1036
+ }
1037
+ query = materializer.loadQueryByName(opts.name);
1038
+ } else if (typeof opts.index === "number") {
1039
+ if (opts.index < 0 || opts.index >= modelQueries.unnamed) {
1040
+ return {
1041
+ ok: false,
1042
+ problems: [
1043
+ codeProblem(
1044
+ "selector-out-of-range",
1045
+ `Index ${opts.index} out of range; the model has ${modelQueries.unnamed} run: statement(s).`,
1046
+ entry.href
1047
+ )
1048
+ ]
1049
+ };
1050
+ }
1051
+ query = materializer.loadQueryByIndex(opts.index);
1052
+ } else {
1053
+ if (modelQueries.unnamed === 0) {
1054
+ return {
1055
+ ok: false,
1056
+ problems: [
1057
+ codeProblem(
1058
+ "no-run",
1059
+ "The source has no run: statement. Specify a named query via `name`, or add a run: to the source.",
1060
+ entry.href
1061
+ )
1062
+ ]
1063
+ };
1064
+ }
1065
+ query = materializer.loadFinalQuery();
1066
+ }
1067
+ return executeMaterialized(query, opts, loadProblems, (p) => p, entry.href);
1068
+ }
1182
1069
  async function queryGivens(q) {
1183
1070
  try {
1184
1071
  const pq = await q.getPreparedQuery();
@@ -1692,36 +1579,383 @@ function exploreSurface(host, opts = {}) {
1692
1579
  };
1693
1580
  }
1694
1581
 
1695
- // ../mcp-engine/dist/mcp-sdk.js
1696
- import {
1697
- CallToolRequestSchema,
1698
- ListToolsRequestSchema
1699
- } from "@modelcontextprotocol/sdk/types.js";
1700
- var HOST_ONLY2 = "host_only";
1701
- function toContent(result) {
1702
- const { malloy_text, [HOST_ONLY2]: _hostOnly, ...rest } = result;
1703
- const content = [
1704
- { type: "text", text: JSON.stringify(rest, null, 2) }
1705
- ];
1706
- if (typeof malloy_text === "string" && malloy_text.length > 0) {
1707
- content.push({ type: "text", text: malloy_text });
1708
- }
1709
- return { content, structuredContent: { ...rest } };
1582
+ // src/host.ts
1583
+ var ENTRY = "index.malloy";
1584
+ function fsReader() {
1585
+ return {
1586
+ readURL: async (u) => {
1587
+ if (u.protocol !== "file:") {
1588
+ throw new Error(`unsupported URL scheme for import: ${u.href}`);
1589
+ }
1590
+ return fs.promises.readFile(u, "utf8");
1591
+ }
1592
+ };
1710
1593
  }
1711
- function lowLevel(server) {
1712
- return "server" in server ? server.server : server;
1594
+ async function loadConfig(rootUrl, reader) {
1595
+ const discovered = await discoverConfig(rootUrl, rootUrl, reader).catch(() => null);
1596
+ return discovered ?? new MalloyConfig({ includeDefaultConnections: true }, {
1597
+ rootDirectory: rootUrl.toString()
1598
+ });
1713
1599
  }
1714
- function attachSurface(server, surface, opts = {}) {
1715
- const s = lowLevel(server);
1716
- const byName = new Map(surface.tools.map((t) => [t.name, t]));
1717
- s.setRequestHandler(ListToolsRequestSchema, async () => ({
1718
- tools: surface.tools.map((t) => ({
1719
- name: t.name,
1720
- title: t.title,
1721
- description: t.description,
1722
- inputSchema: t.inputSchema
1723
- }))
1724
- }));
1600
+ async function makeRunner(root) {
1601
+ await import("@malloydata/malloy-connections");
1602
+ const abs = path2.resolve(root);
1603
+ const rootUrl = url2.pathToFileURL(abs + path2.sep);
1604
+ async function lease(fn) {
1605
+ const reader = fsReader();
1606
+ const config = await loadConfig(rootUrl, reader);
1607
+ const { reader: prepared, entry } = prepareSource(reader, { url: path2.join(abs, ENTRY) });
1608
+ const runtime = new Runtime({ config, urlReader: prepared });
1609
+ try {
1610
+ return await fn(runtime, entry);
1611
+ } finally {
1612
+ await config.shutdown("idle").catch(() => {
1613
+ });
1614
+ }
1615
+ }
1616
+ return {
1617
+ root: abs,
1618
+ entryExists: () => fs.existsSync(path2.join(abs, ENTRY)),
1619
+ run(queryName, givens) {
1620
+ return lease(
1621
+ (runtime, entry) => run(runtime, entry, { name: queryName, givens, stableResult: true, rowLimit: 5e3 })
1622
+ );
1623
+ },
1624
+ validate(queryName, givens) {
1625
+ return lease(async (runtime, entry) => {
1626
+ try {
1627
+ const mm = runtime.loadModel(entry);
1628
+ const model = await mm.getModel();
1629
+ const named = [...model.queries().named];
1630
+ if (!named.includes(queryName)) {
1631
+ return { ok: false, error: `no query named '${queryName}' (model has: ${named.join(", ") || "none"})` };
1632
+ }
1633
+ const q = mm.loadQueryByName(queryName);
1634
+ const has = givens && Object.keys(givens).length > 0;
1635
+ await q.getSQL(has ? { givens } : void 0);
1636
+ return { ok: true };
1637
+ } catch (e) {
1638
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
1639
+ }
1640
+ });
1641
+ }
1642
+ };
1643
+ }
1644
+
1645
+ // src/lint.ts
1646
+ async function lintDashboards(root) {
1647
+ const abs = resolve(root);
1648
+ const names = listDashboardDirs(abs);
1649
+ const dashboards = [];
1650
+ if (names.length === 0) return { ok: true, dashboards };
1651
+ const runner = await makeRunner(abs);
1652
+ if (!runner.entryExists()) {
1653
+ return { ok: false, dashboards: [{ name: "(model)", errors: [`no index.malloy at ${abs}`] }] };
1654
+ }
1655
+ for (const name of names) {
1656
+ 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") {
1680
+ errors.push(`given "${g.name}": "type" must be "string" or "number"`);
1681
+ }
1682
+ if (g.default !== void 0) givenValues[g.name] = g.default;
1683
+ }
1684
+ }
1685
+ }
1686
+ const tsxPath = join3(dir, "Dashboard.tsx");
1687
+ if (!existsSync3(tsxPath)) {
1688
+ errors.push(`missing Dashboard.tsx`);
1689
+ } else {
1690
+ try {
1691
+ await esbuild.transform(readFileSync3(tsxPath, "utf8"), { loader: "tsx", jsx: "automatic" });
1692
+ } catch (e) {
1693
+ const msg = e.errors?.map((x) => x.text).join("; ") ?? String(e);
1694
+ errors.push(`Dashboard.tsx: ${msg}`);
1695
+ }
1696
+ }
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 });
1702
+ }
1703
+ return { ok: dashboards.every((d) => d.errors.length === 0), dashboards };
1704
+ }
1705
+ function printLintReport(report) {
1706
+ for (const d of report.dashboards) {
1707
+ if (d.errors.length === 0) {
1708
+ console.log(` \u2713 ${d.name}`);
1709
+ } else {
1710
+ console.log(` \u2717 ${d.name}`);
1711
+ for (const e of d.errors) console.log(` ${e}`);
1712
+ }
1713
+ }
1714
+ }
1715
+
1716
+ // src/oauth.ts
1717
+ import http from "node:http";
1718
+ import crypto from "node:crypto";
1719
+ import { spawn } from "node:child_process";
1720
+
1721
+ // src/store.ts
1722
+ import { homedir } from "node:os";
1723
+ import { dirname, join as join4 } from "node:path";
1724
+ import { mkdirSync, readFileSync as readFileSync4, writeFileSync, existsSync as existsSync4, chmodSync } from "node:fs";
1725
+ function credsPath() {
1726
+ const base = process.env.XDG_CONFIG_HOME || join4(homedir(), ".config");
1727
+ return join4(base, "malloyyo", "credentials.json");
1728
+ }
1729
+ function readAll() {
1730
+ const p = credsPath();
1731
+ if (!existsSync4(p)) return {};
1732
+ try {
1733
+ return JSON.parse(readFileSync4(p, "utf8"));
1734
+ } catch {
1735
+ return {};
1736
+ }
1737
+ }
1738
+ function loadCreds(url4) {
1739
+ return readAll()[url4];
1740
+ }
1741
+ function saveCreds(url4, creds) {
1742
+ const p = credsPath();
1743
+ mkdirSync(dirname(p), { recursive: true });
1744
+ const all = readAll();
1745
+ all[url4] = creds;
1746
+ writeFileSync(p, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
1747
+ try {
1748
+ chmodSync(p, 384);
1749
+ } catch {
1750
+ }
1751
+ }
1752
+ function clearCreds(url4) {
1753
+ const all = readAll();
1754
+ if (!(url4 in all)) return false;
1755
+ delete all[url4];
1756
+ writeFileSync(credsPath(), JSON.stringify(all, null, 2) + "\n", { mode: 384 });
1757
+ return true;
1758
+ }
1759
+
1760
+ // src/oauth.ts
1761
+ var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
1762
+ async function discover(baseUrl) {
1763
+ const res = await fetch(`${baseUrl}/api/oauth/discovery/authorization-server`);
1764
+ if (!res.ok) throw new Error(`OAuth discovery failed at ${baseUrl}: ${res.status} ${res.statusText}`);
1765
+ return await res.json();
1766
+ }
1767
+ function pkce() {
1768
+ const verifier = crypto.randomBytes(32).toString("base64url");
1769
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
1770
+ return { verifier, challenge };
1771
+ }
1772
+ async function registerClient(registrationEndpoint, redirectUri) {
1773
+ const res = await fetch(registrationEndpoint, {
1774
+ method: "POST",
1775
+ headers: { "content-type": "application/json" },
1776
+ body: JSON.stringify({
1777
+ client_name: "malloyyo CLI",
1778
+ redirect_uris: [redirectUri],
1779
+ token_endpoint_auth_method: "none",
1780
+ grant_types: ["authorization_code", "refresh_token"],
1781
+ response_types: ["code"],
1782
+ scope: "mcp"
1783
+ })
1784
+ });
1785
+ if (!res.ok) throw new Error(`client registration failed: ${res.status} ${await res.text()}`);
1786
+ return (await res.json()).client_id;
1787
+ }
1788
+ function openBrowser(url4) {
1789
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url4]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url4]] : ["xdg-open", [url4]];
1790
+ try {
1791
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
1792
+ } catch {
1793
+ }
1794
+ }
1795
+ function awaitRedirect(state) {
1796
+ return new Promise((resolveServer) => {
1797
+ let resolveCode;
1798
+ let rejectCode;
1799
+ const code = new Promise((res, rej) => {
1800
+ resolveCode = res;
1801
+ rejectCode = rej;
1802
+ });
1803
+ const timer = setTimeout(() => rejectCode(new Error("timed out waiting for browser sign-in")), LOGIN_TIMEOUT_MS);
1804
+ const server = http.createServer((req, res) => {
1805
+ const u = new URL(req.url ?? "/", "http://localhost");
1806
+ if (u.pathname !== "/callback") {
1807
+ res.writeHead(404).end();
1808
+ return;
1809
+ }
1810
+ const err = u.searchParams.get("error");
1811
+ const got = u.searchParams.get("code");
1812
+ const ok = !err && !!got && u.searchParams.get("state") === state;
1813
+ res.writeHead(ok ? 200 : 400, { "content-type": "text/html" });
1814
+ res.end(
1815
+ `<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>${ok ? "\u2713 Signed in to malloyyo" : "Sign-in failed"}</h2><p>${ok ? "You can close this tab and return to the terminal." : err ?? "state mismatch"}</p></body>`
1816
+ );
1817
+ clearTimeout(timer);
1818
+ if (ok) resolveCode(got);
1819
+ else rejectCode(new Error(err ?? "state mismatch or missing code"));
1820
+ });
1821
+ server.listen(0, "127.0.0.1", () => {
1822
+ const port = server.address().port;
1823
+ resolveServer({ port, code, close: () => server.close() });
1824
+ });
1825
+ });
1826
+ }
1827
+ async function login(baseUrl) {
1828
+ const ep = await discover(baseUrl);
1829
+ const { verifier, challenge } = pkce();
1830
+ const state = crypto.randomBytes(16).toString("base64url");
1831
+ const { port, code, close } = await awaitRedirect(state);
1832
+ try {
1833
+ const redirectUri = `http://localhost:${port}/callback`;
1834
+ const clientId = await registerClient(ep.registration_endpoint, redirectUri);
1835
+ const authUrl = new URL(ep.authorization_endpoint);
1836
+ authUrl.search = new URLSearchParams({
1837
+ response_type: "code",
1838
+ client_id: clientId,
1839
+ redirect_uri: redirectUri,
1840
+ code_challenge: challenge,
1841
+ code_challenge_method: "S256",
1842
+ scope: "mcp",
1843
+ state
1844
+ }).toString();
1845
+ console.log("Opening your browser to sign in\u2026");
1846
+ console.log(`If it doesn't open, visit:
1847
+ ${authUrl.toString()}
1848
+ `);
1849
+ openBrowser(authUrl.toString());
1850
+ const authCode = await code;
1851
+ const res = await fetch(ep.token_endpoint, {
1852
+ method: "POST",
1853
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1854
+ body: new URLSearchParams({
1855
+ grant_type: "authorization_code",
1856
+ code: authCode,
1857
+ redirect_uri: redirectUri,
1858
+ client_id: clientId,
1859
+ code_verifier: verifier
1860
+ })
1861
+ });
1862
+ if (!res.ok) throw new Error(`token exchange failed: ${res.status} ${await res.text()}`);
1863
+ const grant = await res.json();
1864
+ const creds = {
1865
+ clientId,
1866
+ accessToken: grant.access_token,
1867
+ refreshToken: grant.refresh_token,
1868
+ expiresAt: Date.now() + (grant.expires_in ?? 86400) * 1e3
1869
+ };
1870
+ saveCreds(baseUrl, creds);
1871
+ return creds;
1872
+ } finally {
1873
+ close();
1874
+ }
1875
+ }
1876
+ async function refresh(baseUrl, creds) {
1877
+ const ep = await discover(baseUrl);
1878
+ const res = await fetch(ep.token_endpoint, {
1879
+ method: "POST",
1880
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1881
+ body: new URLSearchParams({
1882
+ grant_type: "refresh_token",
1883
+ refresh_token: creds.refreshToken,
1884
+ client_id: creds.clientId
1885
+ })
1886
+ });
1887
+ if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
1888
+ const grant = await res.json();
1889
+ const updated = {
1890
+ clientId: creds.clientId,
1891
+ accessToken: grant.access_token,
1892
+ refreshToken: grant.refresh_token,
1893
+ expiresAt: Date.now() + (grant.expires_in ?? 86400) * 1e3
1894
+ };
1895
+ saveCreds(baseUrl, updated);
1896
+ return updated;
1897
+ }
1898
+ async function getAccessToken(target, opts) {
1899
+ if (opts.tokenFlag) return opts.tokenFlag;
1900
+ if (target.tokenEnv && process.env[target.tokenEnv]) return process.env[target.tokenEnv];
1901
+ let creds = loadCreds(target.url);
1902
+ if (!creds) {
1903
+ throw new Error(`Not authenticated for ${target.url}.
1904
+ Run: malloyyo login ${target.name}`);
1905
+ }
1906
+ if (creds.expiresAt - Date.now() < 6e4) {
1907
+ try {
1908
+ creds = await refresh(target.url, creds);
1909
+ } catch {
1910
+ throw new Error(`Session expired for ${target.url}.
1911
+ Run: malloyyo login ${target.name}`);
1912
+ }
1913
+ }
1914
+ return creds.accessToken;
1915
+ }
1916
+
1917
+ // src/mcp.ts
1918
+ import fs2 from "node:fs";
1919
+ import path3 from "node:path";
1920
+ import url3 from "node:url";
1921
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1922
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1923
+ import {
1924
+ MalloyConfig as MalloyConfig2,
1925
+ Runtime as Runtime2,
1926
+ discoverConfig as discoverConfig2
1927
+ } from "@malloydata/malloy";
1928
+
1929
+ // ../mcp-engine/dist/mcp-sdk.js
1930
+ import {
1931
+ CallToolRequestSchema,
1932
+ ListToolsRequestSchema
1933
+ } from "@modelcontextprotocol/sdk/types.js";
1934
+ var HOST_ONLY2 = "host_only";
1935
+ function toContent(result) {
1936
+ const { malloy_text, [HOST_ONLY2]: _hostOnly, ...rest } = result;
1937
+ const content = [
1938
+ { type: "text", text: JSON.stringify(rest, null, 2) }
1939
+ ];
1940
+ if (typeof malloy_text === "string" && malloy_text.length > 0) {
1941
+ content.push({ type: "text", text: malloy_text });
1942
+ }
1943
+ return { content, structuredContent: { ...rest } };
1944
+ }
1945
+ function lowLevel(server) {
1946
+ return "server" in server ? server.server : server;
1947
+ }
1948
+ function attachSurface(server, surface, opts = {}) {
1949
+ const s = lowLevel(server);
1950
+ const byName = new Map(surface.tools.map((t) => [t.name, t]));
1951
+ s.setRequestHandler(ListToolsRequestSchema, async () => ({
1952
+ tools: surface.tools.map((t) => ({
1953
+ name: t.name,
1954
+ title: t.title,
1955
+ description: t.description,
1956
+ inputSchema: t.inputSchema
1957
+ }))
1958
+ }));
1725
1959
  s.setRequestHandler(CallToolRequestSchema, async (req) => {
1726
1960
  const tool = byName.get(req.params.name);
1727
1961
  if (!tool) {
@@ -1759,18 +1993,78 @@ function attachSurface(server, surface, opts = {}) {
1759
1993
  }
1760
1994
  }
1761
1995
 
1996
+ // src/dashboard-guidance.ts
1997
+ var DASHBOARD_GUIDANCE = `
1998
+
1999
+ # Authoring dashboards
2000
+
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\`.
2004
+
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\`.
2013
+
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
+ ]
2026
+ }
2027
+ \`\`\`
2028
+ Given \`name\`s must match the model's given names exactly; \`type\` must match.
2029
+
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:
2033
+ \`\`\`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.
2040
+ }
2041
+ \`\`\`
2042
+
2043
+ ## 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.
2050
+
2051
+ ## Preview
2052
+ From the model repo: \`malloyyo dashboard dev\` \u2192 open the printed URL. Editing
2053
+ \`Dashboard.tsx\` and reloading rebuilds it.
2054
+ `;
2055
+
1762
2056
  // src/mcp.ts
1763
- var ENTRY = "index.malloy";
2057
+ var ENTRY2 = "index.malloy";
1764
2058
  function defaultConfig(rootUrl) {
1765
- return new MalloyConfig({ includeDefaultConnections: true }, {
2059
+ return new MalloyConfig2({ includeDefaultConnections: true }, {
1766
2060
  rootDirectory: rootUrl.toString()
1767
2061
  });
1768
2062
  }
1769
- async function loadConfig(root, reader) {
1770
- const rootUrl = url2.pathToFileURL(root + path2.sep);
2063
+ async function loadConfig2(root, reader) {
2064
+ const rootUrl = url3.pathToFileURL(root + path3.sep);
1771
2065
  let discovered;
1772
2066
  try {
1773
- discovered = await discoverConfig(rootUrl, rootUrl, reader);
2067
+ discovered = await discoverConfig2(rootUrl, rootUrl, reader);
1774
2068
  } catch (e) {
1775
2069
  return {
1776
2070
  config: defaultConfig(rootUrl),
@@ -1783,19 +2077,19 @@ async function loadConfig(root, reader) {
1783
2077
  }
1784
2078
  return { config: defaultConfig(rootUrl), problems: [] };
1785
2079
  }
1786
- function fsReader() {
2080
+ function fsReader2() {
1787
2081
  return {
1788
2082
  readURL: async (u) => {
1789
2083
  if (u.protocol !== "file:") {
1790
2084
  throw new Error(`unsupported URL scheme for import: ${u.href}`);
1791
2085
  }
1792
- return fs.promises.readFile(u, "utf8");
2086
+ return fs2.promises.readFile(u, "utf8");
1793
2087
  }
1794
2088
  };
1795
2089
  }
1796
2090
  function resolveUnderRoot(root, p) {
1797
- const abs = p.includes("://") ? path2.resolve(decodeURIComponent(new URL(p).pathname)) : path2.resolve(root, p);
1798
- if (abs !== root && !abs.startsWith(root + path2.sep)) {
2091
+ const abs = p.includes("://") ? path3.resolve(decodeURIComponent(new URL(p).pathname)) : path3.resolve(root, p);
2092
+ if (abs !== root && !abs.startsWith(root + path3.sep)) {
1799
2093
  throw new Error(`path is outside the project root: ${p}`);
1800
2094
  }
1801
2095
  return abs;
@@ -1804,7 +2098,7 @@ function makeConfigSource(root) {
1804
2098
  let cached;
1805
2099
  const signature = () => ["malloy-config.json", "malloy-config-local.json"].map((name) => {
1806
2100
  try {
1807
- const st = fs.statSync(path2.join(root, name));
2101
+ const st = fs2.statSync(path3.join(root, name));
1808
2102
  return `${name}:${st.mtimeMs}:${st.size}`;
1809
2103
  } catch {
1810
2104
  return `${name}:absent`;
@@ -1813,7 +2107,7 @@ function makeConfigSource(root) {
1813
2107
  return async () => {
1814
2108
  const sig = signature();
1815
2109
  if (cached?.sig !== sig) {
1816
- cached = { sig, loaded: await loadConfig(root, fsReader()) };
2110
+ cached = { sig, loaded: await loadConfig2(root, fsReader2()) };
1817
2111
  }
1818
2112
  return cached.loaded;
1819
2113
  };
@@ -1824,10 +2118,10 @@ function makeWithRuntime(root, currentConfig) {
1824
2118
  return gateConfigProblems(problems, async () => {
1825
2119
  const resolved = "url" in input ? { url: resolveUnderRoot(root, input.url) } : {
1826
2120
  source: input.source,
1827
- baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root + path2.sep
2121
+ baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root + path3.sep
1828
2122
  };
1829
- const { reader, entry, readSource } = prepareSource(fsReader(), resolved);
1830
- const runtime = new Runtime({ config, urlReader: reader });
2123
+ const { reader, entry, readSource } = prepareSource(fsReader2(), resolved);
2124
+ const runtime = new Runtime2({ config, urlReader: reader });
1831
2125
  try {
1832
2126
  return await fn({ runtime, entry, readSource });
1833
2127
  } finally {
@@ -1838,17 +2132,17 @@ function makeWithRuntime(root, currentConfig) {
1838
2132
  }
1839
2133
  function makeExploreHost(root, currentConfig) {
1840
2134
  const withRuntime = makeWithRuntime(root, currentConfig);
1841
- const published = (ref) => ref === ENTRY && fs.existsSync(path2.join(root, ENTRY));
2135
+ const published = (ref) => ref === ENTRY2 && fs2.existsSync(path3.join(root, ENTRY2));
1842
2136
  return {
1843
2137
  withModel: (ref, fn) => {
1844
2138
  if (!published(ref)) throw new Error(`no published model '${ref}'`);
1845
- return withRuntime({ url: ENTRY }, fn);
2139
+ return withRuntime({ url: ENTRY2 }, fn);
1846
2140
  },
1847
2141
  list: async () => {
1848
- if (!published(ENTRY)) return { entries: [] };
1849
- const entry = await withRuntime({ url: ENTRY }, async (m) => {
2142
+ if (!published(ENTRY2)) return { entries: [] };
2143
+ const entry = await withRuntime({ url: ENTRY2 }, async (m) => {
1850
2144
  const compiled = await compile(m.runtime, m.entry, { exportedOnly: true });
1851
- return compiled.ok && compiled.model ? modelCatalogEntry(ENTRY, compiled.model) : { model_ref: ENTRY };
2145
+ return compiled.ok && compiled.model ? modelCatalogEntry(ENTRY2, compiled.model) : { model_ref: ENTRY2 };
1852
2146
  });
1853
2147
  return { entries: [entry] };
1854
2148
  }
@@ -1856,14 +2150,14 @@ function makeExploreHost(root, currentConfig) {
1856
2150
  }
1857
2151
  async function serveMcp(opts) {
1858
2152
  await import("@malloydata/malloy-connections");
1859
- const root = path2.resolve(opts.root ?? process.cwd());
2153
+ const root = path3.resolve(opts.root ?? process.cwd());
1860
2154
  const currentConfig = makeConfigSource(root);
1861
2155
  const surface = exploreSurface(makeExploreHost(root, currentConfig));
1862
2156
  const instanceName = process.env.INSTANCE_NAME || "Malloyyo";
1863
2157
  const server = new McpServer(
1864
2158
  { name: "malloyyo-explore", version: opts.version },
1865
2159
  {
1866
- instructions: renderInstructions(surface.instructions, instanceName),
2160
+ instructions: renderInstructions(surface.instructions, instanceName) + DASHBOARD_GUIDANCE,
1867
2161
  capabilities: { tools: {}, prompts: {}, resources: {} }
1868
2162
  }
1869
2163
  );
@@ -1876,23 +2170,230 @@ async function serveMcp(opts) {
1876
2170
  });
1877
2171
  }
1878
2172
 
2173
+ // src/dashboard.ts
2174
+ import http2 from "node:http";
2175
+ import fs3 from "node:fs";
2176
+ import path4 from "node:path";
2177
+ import { fileURLToPath } from "node:url";
2178
+ import { createRequire } from "node:module";
2179
+ import * as esbuild2 from "esbuild";
2180
+ var require2 = createRequire(import.meta.url);
2181
+ var HOST_LIBS = [
2182
+ "react",
2183
+ "react-dom",
2184
+ "react-dom/client",
2185
+ "react/jsx-runtime",
2186
+ "react/jsx-dev-runtime",
2187
+ "@malloydata/render"
2188
+ ];
2189
+ var HOST_ALIAS = {};
2190
+ for (const spec of HOST_LIBS) {
2191
+ try {
2192
+ HOST_ALIAS[spec] = require2.resolve(spec);
2193
+ } catch {
2194
+ }
2195
+ }
2196
+ function resolveFrameEntry() {
2197
+ const candidates = [
2198
+ new URL("./frame-entry.tsx", import.meta.url),
2199
+ // dev: src/dashboard.ts
2200
+ new URL("../src/frame-entry.tsx", import.meta.url)
2201
+ // built: dist/index.js
2202
+ ].map((u) => fileURLToPath(u));
2203
+ const found = candidates.find((c) => fs3.existsSync(c));
2204
+ if (!found) {
2205
+ 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."
2207
+ );
2208
+ }
2209
+ return found;
2210
+ }
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));
2226
+ }
2227
+ var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2228
+ function makeBundler() {
2229
+ const cache = /* @__PURE__ */ new Map();
2230
+ const frameEntry = resolveFrameEntry();
2231
+ 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;
2234
+ const hit = cache.get(dash.name);
2235
+ if (hit && hit.mtimeMs === mtimeMs) return hit.js;
2236
+ const result = await esbuild2.build({
2237
+ entryPoints: [frameEntry],
2238
+ bundle: true,
2239
+ format: "iife",
2240
+ platform: "browser",
2241
+ jsx: "automatic",
2242
+ write: false,
2243
+ logLevel: "silent",
2244
+ loader: { ".css": "empty" },
2245
+ define: { "process.env.NODE_ENV": '"production"' },
2246
+ plugins: [
2247
+ {
2248
+ name: "virtual-dashboard",
2249
+ setup(b) {
2250
+ b.onResolve({ filter: /^virtual:dashboard$/ }, () => ({ path: dashboardFile }));
2251
+ b.onResolve(
2252
+ { filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/render$)/ },
2253
+ (args) => HOST_ALIAS[args.path] ? { path: HOST_ALIAS[args.path] } : void 0
2254
+ );
2255
+ }
2256
+ }
2257
+ ]
2258
+ });
2259
+ const js = result.outputFiles[0].text;
2260
+ cache.set(dash.name, { mtimeMs, js });
2261
+ return js;
2262
+ };
2263
+ }
2264
+ var html = (body, title) => `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0">${body}</body></html>`;
2265
+ function parentShell(dash, frameBase, all) {
2266
+ const d = JSON.stringify(dash.name);
2267
+ const fb = JSON.stringify(frameBase);
2268
+ 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) => {
2269
+ const on = x.name === dash.name;
2270
+ 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>`;
2271
+ }).join("") + `</nav>` : "";
2272
+ return html(
2273
+ `<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)}" style="border:0;flex:1;width:100%"></iframe></div><script>
2274
+ const f=document.getElementById('f');
2275
+ window.addEventListener('message',async(e)=>{
2276
+ if(e.source!==f.contentWindow||e.origin!==${fb})return;
2277
+ const m=e.data; if(!m||m.type!=='run')return;
2278
+ let out;
2279
+ try{
2280
+ const res=await fetch('/api/run',{method:'POST',headers:{'content-type':'application/json'},
2281
+ body:JSON.stringify({d:${d},query:m.query,givens:m.givens})});
2282
+ out=await res.json();
2283
+ }catch(err){ out={ok:false,problems:[{message:String(err)}]}; }
2284
+ f.contentWindow.postMessage({type:'result',id:m.id,...out},${fb});
2285
+ });
2286
+ </script>`,
2287
+ dash.manifest.title
2288
+ );
2289
+ }
2290
+ function frameDoc(dash) {
2291
+ return html(
2292
+ `<div id="root"></div><script>window.__MANIFEST__=${JSON.stringify(dash.manifest)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
2293
+ dash.manifest.title
2294
+ );
2295
+ }
2296
+ async function readBody(req) {
2297
+ const chunks = [];
2298
+ for await (const c of req) chunks.push(c);
2299
+ return Buffer.concat(chunks).toString("utf8");
2300
+ }
2301
+ async function serveDashboard(opts) {
2302
+ await import("@malloydata/malloy-connections");
2303
+ const root = path4.resolve(opts.root ?? process.cwd());
2304
+ const port = opts.port ?? 4173;
2305
+ const framePort = port + 1;
2306
+ const frameBase = `http://localhost:${framePort}`;
2307
+ const dashboards = discoverDashboards(root);
2308
+ if (dashboards.length === 0) {
2309
+ throw new Error(`No dashboards found under ${path4.join(root, "dashboards")}/`);
2310
+ }
2311
+ const byName = new Map(dashboards.map((d) => [d.name, d]));
2312
+ const runner = await makeRunner(root);
2313
+ if (!runner.entryExists()) {
2314
+ throw new Error(`No index.malloy at ${root} \u2014 run this from a Malloy model repo.`);
2315
+ }
2316
+ const bundle = makeBundler();
2317
+ const pick = (url4) => byName.get(url4.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
2318
+ const handler = async (req, res) => {
2319
+ const onFramePort = (req.socket.localPort ?? port) === framePort;
2320
+ const url4 = new URL(req.url ?? "/", `http://localhost:${onFramePort ? framePort : port}`);
2321
+ const send = (code, type, body, extra = {}) => {
2322
+ res.writeHead(code, { "content-type": type, ...extra });
2323
+ res.end(body);
2324
+ };
2325
+ try {
2326
+ if (onFramePort) {
2327
+ if (url4.pathname === "/frame") {
2328
+ return send(200, "text/html; charset=utf-8", frameDoc(pick(url4)));
2329
+ }
2330
+ if (url4.pathname === "/bundle.js") {
2331
+ return send(200, "application/javascript; charset=utf-8", await bundle(pick(url4)));
2332
+ }
2333
+ return send(404, "text/plain", "not found");
2334
+ }
2335
+ if (url4.pathname === "/") {
2336
+ return send(200, "text/html; charset=utf-8", parentShell(pick(url4), frameBase, dashboards));
2337
+ }
2338
+ if (url4.pathname === "/api/run" && req.method === "POST") {
2339
+ const { d, query, givens } = JSON.parse(await readBody(req));
2340
+ const dash = byName.get(d);
2341
+ if (!dash) return send(404, "application/json", JSON.stringify({ ok: false, problems: [{ message: `no dashboard '${d}'` }] }));
2342
+ if (query !== dash.manifest.query) {
2343
+ return send(403, "application/json", JSON.stringify({ ok: false, problems: [{ message: `query '${query}' is not declared by ${d}` }] }));
2344
+ }
2345
+ const out = await runner.run(query, givens ?? {});
2346
+ return send(200, "application/json", JSON.stringify(out));
2347
+ }
2348
+ send(404, "text/plain", "not found");
2349
+ } catch (e) {
2350
+ send(500, "application/json", JSON.stringify({ ok: false, problems: [{ message: e.message }] }));
2351
+ }
2352
+ };
2353
+ const shellServer = http2.createServer(handler);
2354
+ const frameServer = http2.createServer(handler);
2355
+ await new Promise((r) => shellServer.listen(port, r));
2356
+ await new Promise((r) => frameServer.listen(framePort, r));
2357
+ console.error(`
2358
+ malloyyo dashboard dev \u2014 model: ${root}`);
2359
+ console.error(` http://localhost:${port}/ (artifact origin: ${frameBase})`);
2360
+ for (const d of dashboards) {
2361
+ console.error(` \u2022 ${d.name} \u2192 http://localhost:${port}/?d=${d.name}`);
2362
+ }
2363
+ console.error(` Ctrl-C to stop.
2364
+ `);
2365
+ await new Promise(() => {
2366
+ });
2367
+ }
2368
+
1879
2369
  // package.json
1880
- var version = "0.2.7";
2370
+ var version = "0.2.9";
1881
2371
 
1882
2372
  // src/index.ts
1883
2373
  function shortSha(sha) {
1884
2374
  return sha ? sha.slice(0, 7) : "";
1885
2375
  }
1886
2376
  async function publish(target, dir, opts) {
1887
- const root = resolve(dir);
2377
+ const root = resolve2(dir);
1888
2378
  const t = resolveTarget(root, target);
1889
2379
  const bearer = await getAccessToken(t, { tokenFlag: opts.token });
1890
2380
  const { files, config } = gatherDirectory(root);
1891
2381
  if (files.length === 0) {
1892
2382
  throw new Error(`No .malloy files found under ${root}`);
1893
2383
  }
2384
+ if (!opts.skipLint) {
2385
+ const report = await lintDashboards(root);
2386
+ if (report.dashboards.length > 0) {
2387
+ console.log("dashboards:");
2388
+ printLintReport(report);
2389
+ }
2390
+ if (!report.ok) {
2391
+ throw new Error("dashboard lint failed \u2014 fix the above, or pass --skip-lint");
2392
+ }
2393
+ }
1894
2394
  const git = gitInfo(root);
1895
- const body = { files, config, git };
2395
+ const dashboards = gatherDashboards(root);
2396
+ const body = { files, config, git, dashboards };
1896
2397
  const provenance = git.sha ? `${git.branch}@${shortSha(git.sha)}${git.dirty ? " (dirty)" : ""}` : "(no git)";
1897
2398
  console.log(`\u2192 ${t.url} dataset=${t.dataset}`);
1898
2399
  console.log(` ${files.length} file(s) ${provenance}`);
@@ -1909,10 +2410,12 @@ async function publish(target, dir, opts) {
1909
2410
  if (!res.ok || !out.ok) {
1910
2411
  throw new Error(`publish failed: ${out.error ?? `${res.status} ${res.statusText}`}`);
1911
2412
  }
1912
- console.log(`\u2713 published version ${out.version} \u2014 ${out.sources?.length ?? 0} source(s)`);
2413
+ console.log(
2414
+ `\u2713 published version ${out.version} \u2014 ${out.sources?.length ?? 0} source(s)` + (dashboards.length ? `, ${dashboards.length} dashboard(s)` : "")
2415
+ );
1913
2416
  }
1914
2417
  async function status(target, opts) {
1915
- const t = resolveTarget(resolve("."), target);
2418
+ const t = resolveTarget(resolve2("."), target);
1916
2419
  const bearer = await getAccessToken(t, { tokenFlag: opts.token });
1917
2420
  const res = await fetch(`${t.url}/api/datasets/${t.dataset}/model/status`, {
1918
2421
  headers: { authorization: `Bearer ${bearer}` }
@@ -1927,25 +2430,38 @@ async function status(target, opts) {
1927
2430
  console.log(` ${s.compileError ? `\u2717 ${s.compileError}` : `\u2713 compiled ${s.compiledAt ?? ""}`}`);
1928
2431
  }
1929
2432
  async function loginCmd(target) {
1930
- const inst = resolveInstance(resolve("."), target);
2433
+ const inst = resolveInstance(resolve2("."), target);
1931
2434
  await login(inst.url);
1932
2435
  console.log(`\u2713 logged in to ${inst.name} (${inst.url})`);
1933
2436
  }
1934
2437
  async function logoutCmd(target) {
1935
- const inst = resolveInstance(resolve("."), target);
2438
+ const inst = resolveInstance(resolve2("."), target);
1936
2439
  console.log(clearCreds(inst.url) ? `\u2713 logged out of ${inst.url}` : `not logged in to ${inst.url}`);
1937
2440
  }
1938
2441
  var program = new Command();
1939
2442
  program.name("malloyyo").description("Publish Malloy models to a Malloyyo instance").version(version);
1940
2443
  program.command("login").argument("[target]", "target name or instance URL (optional if the config has one target)").description("sign in to an instance in your browser (stores a token)").action(loginCmd);
1941
2444
  program.command("logout").argument("[target]", "target name or instance URL (optional if the config has one target)").description("forget the stored token for an instance").action(logoutCmd);
1942
- program.command("publish").argument("<target>", "named target from the `malloyyo` config block").argument("[dir]", "directory to publish", ".").option("--token <token>", "bearer token (overrides login/env)").option("--dry-run", "gather and report what would be sent, but don't POST").description('push the Malloy model in <dir> (default ".") to <target>').action(publish);
2445
+ program.command("publish").argument("<target>", "named target from the `malloyyo` config block").argument("[dir]", "directory to publish", ".").option("--token <token>", "bearer token (overrides login/env)").option("--dry-run", "gather and report what would be sent, but don't POST").option("--skip-lint", "skip the pre-publish dashboard lint").description('push the Malloy model in <dir> (default ".") to <target>').action(publish);
2446
+ program.command("lint").argument("[dir]", "directory to lint", ".").description("validate ./dashboards against the model (manifest, query, givens, Dashboard.tsx)").action(async (dir) => {
2447
+ const report = await lintDashboards(resolve2(dir));
2448
+ if (report.dashboards.length === 0) {
2449
+ console.log("no dashboards to lint");
2450
+ return;
2451
+ }
2452
+ printLintReport(report);
2453
+ if (!report.ok) process.exit(1);
2454
+ });
1943
2455
  program.command("status").argument("<target>", "named target from the `malloyyo` config block").option("--token <token>", "bearer token (overrides login/env)").description("show what's live on <target>: version, commit, compile state").action(status);
1944
2456
  program.command("mcp").option("-C, --root <dir>", "project root (default: current directory)").description(
1945
2457
  "run a local stdio MCP server (the explore / test-window surface) over the Malloy model in the current directory"
1946
2458
  ).action(async (opts) => {
1947
2459
  await serveMcp({ root: opts.root, version });
1948
2460
  });
2461
+ program.command("dashboard").argument("<action>", "action to run (currently: dev)").option("-C, --root <dir>", "project root (default: current directory)").option("-p, --port <port>", "port to serve on", "4173").description("preview dashboard artifacts in ./dashboards against the local Malloy model").action(async (action, opts) => {
2462
+ if (action !== "dev") throw new Error(`unknown dashboard action '${action}' (expected: dev)`);
2463
+ await serveDashboard({ root: opts.root, port: Number(opts.port) });
2464
+ });
1949
2465
  program.parseAsync().catch((err) => {
1950
2466
  console.error(err instanceof Error ? err.message : String(err));
1951
2467
  process.exit(1);