@malloydata/malloyyo 0.2.14 → 0.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.js +725 -381
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -86,13 +86,18 @@ import path from "node:path";
|
|
|
86
86
|
import url from "node:url";
|
|
87
87
|
var HOST_ONLY = "host_only";
|
|
88
88
|
var contentFiles = {
|
|
89
|
+
"dashboards/authoring.md": '---\ndescription: How to author a dashboard \u2014 a self-contained file in the dashboards/ directory that defines its own query; the entry point for dashboard how-to\n---\n\n# Authoring dashboards\n\nA dashboard is a **self-contained `.malloy` file in the `dashboards/`\ndirectory**. The file IS the dashboard: it imports the model parts it needs,\ndefines its query (with the filtering it applies), and tags it. No manifest, and\nfor the basic case no JavaScript. Preview with `malloyyo dashboard dev`; check\nwith `malloyyo lint`. Requires `@malloydata/malloy` 0.0.423+.\n\nRelated: `yo_help dashboards/givens-and-controls` (filter controls),\n`dashboards/grid-layout` (columns/colspan/break), `dashboards/custom-components`\n(a flat `<name>.jsx`), `dashboards/vega-charts` (`<VegaChart>`).\n\n> **Need a chart the `# bar_chart`/`# line_chart`/`# shape_map` tags can\'t do?**\n> Use the `<VegaChart>` COMPONENT (a Vega-Lite spec over query rows) \u2014 NOT a `#`\n> tag; there is no `# vega_lite`. See `yo_help dashboards/vega-charts`.\n\n## The layout\n\n```\nmodel/\n ecommerce.malloy # sources, reusable views/measures, # drill tags\n givens.malloy # given: declarations (the filter controls)\n index.malloy # imports/exports sources \u2014 the MCP/data surface ONLY\n dashboards/\n overview.malloy # one dashboard; the FILENAME is its name/slug\n overview.jsx # optional custom component for overview\n```\n\n**The filename is the dashboard\'s name** \u2014 its URL slug, its `# drill` target,\nand the basename of its optional component. Discovery globs\n`dashboards/*.malloy` and compiles EACH as its own entry \u2014 so dashboards are NOT\ndeclared in, or exported through, `index.malloy` (`index.malloy` is just the\n`query`/`describe_source` data surface).\n\n## Preferred: put the query IN the dashboard file\n\nDefine the query right in `dashboards/<name>.malloy` and tag it `# artifact`, so\nthe given mapping (the `where: \u2026 ~ $GIVEN`) is visible next to the dashboard:\n\n```malloy\n// dashboards/overview.malloy\n##! experimental.givens\nimport "../ecommerce.malloy" // BARE import: source + givens in scope\n\n#" Business health at a glance \u2014 sales, margin, orders.\n# artifact { title="Business Overview" } dashboard {columns=6}\nquery: overview is order_items -> {\n where: // the given mapping, HERE\n inventory_items.product_brand ~ $BRAND, // multi-filter where: is\n inventory_items.product_category ~ $CATEGORY, // COMMA separated\n created_at ~ $PERIOD\n # colspan=2\n aggregate: total_sales, total_gross_margin, order_count\n # colspan=3\n nest:\n # line_chart\n sales_trend is by_month\n top_brands\n # shape_map\n sales_by_state\n}\n```\n\nThat\'s a complete dashboard: the runtime auto-renders a title (the tag\'s\n`title`, else the `#"` doc), a control for every given the query references, and\nthe result panel. `# artifact` DECLARES the dashboard; `# dashboard {columns=6}`\nis the renderer tag that draws it as KPI tiles + a card grid \u2014 partners, on the\nsame line. Grid rules: `yo_help dashboards/grid-layout`.\n\n**You know you\'re doing it right when the `where: foo ~ $FOO` is in the DASHBOARD\nfile, not the model.** Keep the model\'s sources/views reusable and given-free;\neach dashboard decides its own filtering.\n\n**The bare import is required for controls.** A control renders only when the\ngiven\'s DECLARATION is in the dashboard file\'s scope \u2014 a bare\n`import "../ecommerce.malloy"` (or `import "../givens.malloy"`) brings them all;\nthe runtime shows a control for exactly the givens the query references. A\nselective `import { order_items } from \u2026` brings the filter but NOT the control.\n\nKeep the FILENAME as the name \u2014 don\'t set `name=`, so the URL, the\n`# drill { to=\u2026 }`, and the component basename all agree (one source of truth).\n\n## Other forms\n\n- **A view of a source you extend in the file** \u2014 tag the dashboard `view:` with\n `# artifact` (runs as `<source> -> <view>`). Good when the dashboard needs\n helper views defined alongside it.\n- **Compose existing views**: a model-level `## artifact { tiles=["a -> b", "c -> d"]\n dashboard_columns=6 }` (`##`, ONE line) names several views, run separately and\n combined into one `# dashboard`. Use for multi-tile / cross-source; prefer the\n inline query whenever a dashboard has its own filtering.\n- A `dashboards/*.malloy` with NO `# artifact`/`## artifact` is a shared INCLUDE\n (skipped by discovery) \u2014 put helper sources/views there for several dashboards\n to import.\n\n## Givens (filter controls)\n\nDeclare givens as `filter<T>` in the MODEL (`givens.malloy` or the source file) \u2014\nthey\'re shared and used by the MCP surface too; each dashboard APPLIES them in\nits `where:`. Full control reference: `yo_help dashboards/givens-and-controls`.\nPer-dashboard starting values go in the tag:\n\n```malloy\n# artifact { title="Ford recalls" givens { MANUFACTURER=f\'Ford Motor Company\' } }\n```\n\n## Drill from a dimension\n\n`# drill` on a source `dimension:` (in the model) makes its cells clickable \u2014\nopening another dashboard (seeding the value) or filtering in place:\n\n```malloy\ndimension:\n # drill { to=[category_explorer, self] }\n category is inventory_items.product_category\n```\n\n`to` is a list; each is a **dashboard slug** (a `dashboards/<slug>.malloy`\nfilename) \u2192 opens it, seeding the value into the given named like the dimension\nUPPER-cased (`category` \u2192 `CATEGORY`), or **`self`** \u2192 filter the current\ndashboard in place. Add `given=` when the target given differs. `lint` VERIFIES\nevery `to=` slug resolves to a real dashboard file (a typo/renamed dashboard\nfails loudly, not at click time).\n\n> **malloy#2979 (fixed in 0.0.423):** a `# drill` on a bare `group_by: name` was\n> dropped when nested through `+ {\u2026}`. Put it on the source `dimension:`, or use\n> `group_by: name is concat(name,\'\')`.\n\n## Custom component (optional)\n\nFor bespoke layout/charts, add a flat sibling `dashboards/<name>.jsx` (or\n`.tsx`). Only React + `@malloyyo/dashboard` importable (sandboxed). A bare\n`<Panel/>` renders the whole dashboard; a `<Panel query="\u2026"/>` /\n`<VegaChart query="\u2026"/>` runs a query DEFINED in this dashboard file (by name) or\na `source -> view`. `lint` checks each `query="\u2026"` still resolves. See `yo_help\ndashboards/custom-components`.\n\n## Rules\n- Each dashboard is one `dashboards/<name>.malloy`; the filename is the slug.\n Prefer the inline `query: \u2026 # artifact` form \u2014 the `where: ~ $GIVEN` lives in\n the dashboard file.\n- Bare-import the model (and/or `givens.malloy`) so the controls render.\n- Givens are `filter<T>` declared in the model; options come from `# suggest {\u2026}`;\n interactivity = setting given values, not rewriting query text.\n- `index.malloy` is the data surface, NOT where dashboards live.\n- If a query/given you need is missing, add it (check with `describe_source`).\n\n## Preview & validate\n`malloyyo dashboard dev` \u2192 open the URL; `.malloy`/`.jsx` edits hot-reload.\n`malloyyo lint` checks each dashboard file on its own: it compiles as its entry;\neach tile/query and `# suggest` compiles; `dashboard_columns` is a positive int;\nthe component compiles and its `query="\u2026"` resolve; no duplicate names, no\norphaned component; every `# drill { to=\u2026 }` resolves. Tight loop: the local\n`malloyyo mcp --develop` server hot-reloads edits \u2014 `query(execute:false)` to\ncompile-check, `execute:true` to run. Don\'t validate against a hosted/claude.ai\nconnector \u2014 it serves the PUBLISHED model (stale until `malloyyo publish`).\n',
|
|
90
|
+
"dashboards/custom-components.md": '---\ndescription: Custom dashboard UI \u2014 a flat dashboards/<name>.jsx|tsx sibling composing @malloyyo/dashboard widgets/hooks/helpers with your own React\n---\n\n# Custom dashboard components (`dashboards/<name>.jsx`)\n\nThe default UI (auto-rendered controls + panel) covers most dashboards. When it\nisn\'t enough, add ONE file \u2014 a **flat sibling** `dashboards/<name>.jsx` (or\n`.tsx`) next to the dashboard\'s `dashboards/<name>.malloy` (same basename) \u2014 that\ncomposes the runtime\'s widgets/hooks with your own React. You own layout, copy,\nand theming; the `.malloy` file still owns every query and filter. See also\n`yo_help dashboards/authoring` and `dashboards/vega-charts`.\n\n```tsx\nimport React from "react";\nimport { Controls, Given, Search, Select, TimeRange, Panel, filters, useGiven } from "@malloyyo/dashboard";\n\nexport default function Dashboard({ dashboard, givens }) {\n return (\n <div style={{ maxWidth: 860, margin: "0 auto", padding: 24 }}>\n <h1>{dashboard.title}</h1>\n <Controls>\n <Given name="STATE" /> {/* picks the control from the declaration */}\n <Search given="NAME" /> {/* committing input + typeahead + validation */}\n <TimeRange given="PERIOD" presets={[\n { value: "", text: "All time" },\n { value: filters.lastN(1, "day"), text: "Last day" },\n { value: filters.lastN(1, "week"), text: "Last week" },\n { value: filters.lastN(1, "month"), text: "Last month" },\n ]} /> {/* "Custom range\u2026" is always appended */}\n <Select given="MIN_SAMPLE"\n options={[10, 200, 1000].map(n => ({ value: filters.greaterThan(n), text: `> ${n}` }))} />\n </Controls>\n <Panel givens={givens} /> {/* the dashboard itself (its tiles/query) */}\n <Panel query="baby_names -> births_by_decade" givens={givens} /> {/* a specific query */}\n </div>\n );\n}\n```\n\nFrom `@malloyyo/dashboard` (also handed to the component as props):\n- **Widgets** (headless-ish; restyle via className/style or the `--dash-*` CSS\n vars \u2014 see Theming below): `<Controls/>` (all givens, or compose children;\n grows Apply/Reset under `autorun=false`), `<Given name/>`,\n `<Select given [options]/>`, `<Search given/>` (committing input + typeahead +\n inline \u2715 clear), `<MultiSelect given [options]/>` (chip multi-select for a\n `filter<string>` \u2014 commits an exact-match list via `filters.oneOf`),\n `<Range given [min max]/>`, `<TimeRange given [presets]/>` (temporal presets +\n custom range), `<Checkbox given/>` (bound to a boolean given),\n `<VegaChart spec query|malloy|data givens/>` (a Vega-Lite chart over query\n rows \u2014 see `yo_help dashboards/vega-charts`)\n- **Hooks**: `useGiven(name)` \u2192 {value, set, spec};\n `useOptions(name, typed?)` \u2192 {options, loading} (typeahead);\n `useQuery({query|malloy, givens})` \u2192 {rows, loading, error} \u2014 plain rows\n for your own visuals\n- **Helpers**: `filters.oneOf/contains/between/atLeast/\u2026` build\n filter-expression strings with correct escaping; temporal:\n `filters.lastN(7, "day")` \u2192 `\'7 days\'`, `filters.dateRange("2026-01-01",\n "2026-07-01")`, `filters.afterDate/beforeDate`; read back with\n `filters.values/numberRange/threshold/inLast/temporalRange`;\n `filters.isValid(type, src)` checks typed input.\n Never hand-concatenate a filter string.\n **Escaping rule for custom controls:** a filter given\'s value is an\n EXPRESSION, so committing a raw column value is wrong the moment it contains\n a comma/percent/dash (\'Tesla, Inc.\' parses as two alternatives and matches\n nothing). Commit `filters.oneOf(value)` (exact) or\n `filters.contains(term)` (substring), and unwrap for display with\n `filters.values(src)`. The stock `<Select/>` does this automatically;\n `<Search/>` deliberately commits raw text (its input IS a filter\n expression).\n- `<Panel/>` runs against the DASHBOARD\'s own file: a bare `<Panel/>` renders\n the whole dashboard (its tiles); `<Panel query="\u2026"/>` runs a query defined in\n the dashboard file (by name) or a `source -> view`; `<Panel malloy="\u2026"/>` and\n `runData(text, givens)` run arbitrary Malloy as a RESTRICTED query (no import /\n given: / connection.* / raw SQL / ##! flags \u2014 the model\'s governed surface\n only). `lint` checks each hard-coded `query="\u2026"` still resolves.\n\n## Theming\n\nEvery widget is styled by the runtime\'s **default Malloyyo theme** (system\nfont, neutral grays, blue accent, auto light/dark following the viewer\'s OS) \u2014\na bare component looks styled with zero effort, so DON\'T hand-hardcode\n`fontFamily`/colors. The theme is CSS custom properties; override any subset by\nsetting them on a wrapper element (more specific than the runtime\'s `:root`):\n\n```tsx\n<div style={{ "--dash-accent": "#e11d48", "--dash-controls-bg": "#faf5ff" }}>\n <Controls /> \u2026\n</div>\n```\n\nVars: `--dash-font`, `--dash-bg`, `--dash-fg`, `--dash-muted`, `--dash-border`,\n`--dash-accent`, `--dash-accent-fg`, `--dash-control-bg`, `--dash-controls-bg`,\n`--dash-chip-bg`, `--dash-chip-fg`, `--dash-panel-bg`, `--dash-radius`,\n`--dash-danger`. `DefaultDashboard` also takes a `theme={{ accent, controlsBg }}`\nprop (camelCase keys \u2192 `--dash-*`). The results `<Panel>` keeps a light surface\nin both light/dark (the Malloy renderer has no dark theme) \u2014 override\n`--dash-panel-bg` if your renderer output is dark-safe.\n\nFor charts beyond the Malloy renderer\'s `#` tags, use `<VegaChart>` \u2014\n`yo_help dashboards/vega-charts`.\n',
|
|
91
|
+
"dashboards/givens-and-controls.md": "---\ndescription: Dashboard filter controls \u2014 declare filter<T> givens with # label / suggest / control tags; faceted (related) suggestions\n---\n\n# Dashboard givens & controls\n\nA dashboard's filters are `filter<T>` **givens** declared in the model; the\n`#` tags on each declaration drive its control. This is part of authoring a\ndashboard \u2014 see also `yo_help dashboards/authoring`.\n\n**Declare the filters as `filter<T>` givens** \u2014 never raw strings/numbers.\nA `filter<string>` value accepts one value ('NY'), alternatives ('NY, CA'),\nwildcards ('Ann%'), negation ('-NY'); a `filter<number>` accepts ranges\n('[1910 to 1930]') and comparisons ('> 200'); a `filter<timestamp>` /\n`filter<date>` accepts relative windows ('7 days' = the last 7 days, 'today',\n'last month') and literal ranges ('2026-01-01 to 2026-07-01' \u2014 NO `@` in\nfilter literals). Apply with `~`; `f''` = empty = no filter (the natural\n\"All\"/\"all time\" \u2014 just `col ~ $X`, no `$X = '' or \u2026` dance):\n\n```malloy\n##! experimental { givens }\ngiven:\n # label=\"State\" control=select suggest { source=baby_names dimension=state }\n STATE :: filter<string> is f'NY'\n # label=\"Brand\" suggest { query=brand_suggest dimension=product_brand }\n BRAND :: filter<string> is f''\n # label=\"Names\" control=multiselect suggest { query=name_suggest dimension=name }\n NAMES :: filter<string> is f''\n # label=\"Years\" range_min=1910 range_max=2025\n YEAR_RANGE :: filter<number> is f'[1910 to 1930]'\n # label=\"Time period\"\n PERIOD :: filter<timestamp> is f''\n # label=\"Include rare names\"\n INCLUDE_RARE :: boolean is false\n```\n\nTags on the declaration drive the control (tag syntax is `key=\"value\"` \u2014\nequals, not colon):\n- `label` \u2014 control caption (defaults to the given's name)\n- `suggest { \u2026 }` \u2014 where the control's options come from. NO Malloy code in\n strings \u2014 just names:\n - `suggest { query=brand_suggest dimension=product_brand }` \u2014 the FIRST\n COLUMN of a named query (declare the query in the model \u2014 governed and\n reviewable). PREFER THIS FORM. The query must be in scope where the dashboard\n runs (bring it in with the dashboard file's bare `import`).\n - `suggest { source=baby_names dimension=state }` \u2014 the DISTINCT VALUES of\n a dimension on a source (the source must be in the dashboard's scope)\n A `dimension` (in either form) is what enables SERVER-SIDE TYPEAHEAD: the\n runtime refines the base query with what the user has typed\n (`\u2026 + { where: lower(field) ~ f'll%'; limit: 50 }`, case-insensitive,\n escaped). Without a dimension the fetched list is filtered client-side.\n Runs as a restricted query; lint checks the declaration compiles.\n\n **RELATED (faceted) filters** \u2014 query-form only: a suggest query may\n reference the OTHER givens, and the runtime runs it with the dashboard's\n current values (the suggested given itself is excluded, so the list never\n collapses to the current pick). Brand suggestions narrow when Category is\n set:\n\n ```malloy\n query: brand_suggest is inventory_items -> product_brand + {\n where:\n product_category ~ $CATEGORY, // NOT product_brand ~ $BRAND\n product_department ~ $DEPARTMENT\n limit: 500\n }\n ```\n\n Declare one `*_suggest` per filter, each referencing the others; `f''`\n defaults mean unset filters don't constrain. `source=` suggests can't do\n this (no place for a `where:`) \u2014 another reason to prefer `query=`.\n- `control=select` \u2014 a fixed dropdown instead of a typeahead search box\n- `control=multiselect` \u2014 a tokenized multi-select for a `filter<string>`:\n each pick is a removable chip, the committed value is an exact-match list\n (`Emma, Olivia, Sophia`). Ideal for \"pick several\" filters (names, brands).\n Suggestions come from the given's `suggest {\u2026}` (server-side typeahead when\n it names a dimension). Empty (start from `f''`) = no filter (all).\n- `range_min` / `range_max` \u2014 bounds; makes a filter<number> given a\n dual-thumb range slider\n- anything else passes through in `spec.tags` for custom components\n\nControl picked from the declaration automatically: numeric range tags \u2192\ndual-thumb slider; `filter<timestamp|timestamptz|date>` \u2192 the TimeRange\nwidget (relative presets: Today / Last 7 days / Last 30 days / \u2026 plus a\n\"Custom range\u2026\" from/to date picker); `control=multiselect` \u2192 chip\nmulti-select; suggest + control=select \u2192 dropdown; boolean \u2192 checkbox;\nanything else \u2192 committing search box with typeahead (an inline \u2715 clears it;\na \"Press \u21B5 to apply\" hint shows while the typed draft differs from what's\nrunning \u2014 free text can't safely re-run per keystroke).\nThe suggest-driven options are DATA VALUES only \u2014 options that aren't column\nvalues (custom time presets, threshold buckets) need a custom component\n(`yo_help dashboards/custom-components`) with explicit `{value, text}` options\nwhere value is a filter expression built with `filters.*`.\n\n## When the query re-runs: live (default) vs. Apply\n\nBy default a dashboard is **live** \u2014 every control change re-runs the query\nimmediately (the committing search box is the exception: free text commits on\nEnter/blur, since a half-typed filter is invalid). To batch changes behind an\n**Apply** button instead, set `autorun=false` on the `# artifact` tag:\n\n```malloy\n# artifact { name=\"births-by-name\" title=\"Births by name\" autorun=false }\n```\n\n`autorun=false` makes `<Controls>` grow an Apply/Reset pair \u2014 controls edit a\ndraft and nothing re-runs until Apply. Reach for it when the query is expensive\nor several filters are usually changed together; leave it off (live) otherwise.\n",
|
|
92
|
+
"dashboards/grid-layout.md": "---\ndescription: Dashboard grid layout \u2014 # dashboard {columns=N} with # colspan and # break to place KPI tiles and charts\n---\n\n# Dashboard grid layout (`# dashboard {columns=N}`)\n\nBy default a `# dashboard` result flows its KPI tiles and cards and wraps.\nAdd `{columns=N}` to place them on a fixed **N-column grid** instead \u2014 use\n`columns=6`, which divides evenly into 2- and 3-wide cards.\n\n**Key mechanic:** a tag placed ABOVE `aggregate:` or `nest:` applies to EVERY\nitem declared in that block. So you set card widths once per block, not per\nfield.\n\n```malloy\n# artifact { title=\"Customer Insights\" } dashboard {columns=6}\nview: customer_insights is {\n where: created_at ~ $PERIOD\n # colspan=2\n aggregate: total_sales, user_count, order_count, average_order_value\n # colspan=3\n nest:\n # break\n # bar_chart\n users_by_spend_tier\n sales_by_traffic_source\n # shape_map\n sales_by_state\n # colspan=6\n recent_orders // wide detail table \u2192 full width\n}\n```\n\n## The conventions\n\n- **`# colspan=2` above `aggregate:`** \u2014 each KPI / measure tile spans 2 of 6\n columns \u2192 3 tiles per row.\n- **`# colspan=3` above `nest:`** \u2014 each graph or small table spans 3 \u2192 2 per\n row. Per-item render tags (`# line_chart`, `# bar_chart`, `# shape_map`) still\n go on the individual nested items.\n- **`# colspan=6`** \u2014 a single wide / many-column table gets its own full-width\n line. Tag that one item; a per-item `# colspan` overrides the block default.\n- **`# break` on the FIRST nest item** \u2014 starts the graphs on a fresh row, so\n KPI tiles and charts never share one. The renderer splits fields into a new\n grid at each `# break`. Just always add it: it's a no-op when the tiles\n already fill complete rows, and the fix when they don't (e.g. 4 measures\n leave a lone tile a colspan-3 chart would otherwise pack in beside).\n\n`# colspan` only does anything in columns mode \u2014 without `{columns=N}` the\nlayout is free-flow wrap and colspan is ignored. Clamp colspans to `1..N`.\n\nSee also `yo_help dashboards/vega-charts` for custom charts, and the fuller\nauthoring guide surfaced by the local `malloyyo mcp` server.\n",
|
|
93
|
+
"dashboards/vega-charts.md": '---\ndescription: Custom dashboard charts with Vega-Lite \u2014 the <VegaChart> component, for charts the # renderer tags can\'t do\n---\n\n# Custom charts with Vega-Lite (`<VegaChart>`)\n\nWhen Malloy\'s renderer tags (`# bar_chart`, `# line_chart`, `# shape_map`, \u2026)\ndon\'t cover the chart you want, a dashboard can draw a **Vega-Lite** spec with\nthe `<VegaChart>` component. The chart engine ships in the dashboard runtime, so\nyou author only a JSON spec + a Malloy query \u2014 no library to load.\n\n**It is a COMPONENT, not a `#` tag.** There is no `# vega_lite` or\n`# scatter_chart` tag. `<VegaChart>` lives in a custom component \u2014 a flat sibling\n`dashboards/<name>.jsx` (or `.tsx`) next to the dashboard\'s\n`dashboards/<name>.malloy` \u2014 a different layer from the `#` renderer tags. (The\ndashboard\'s query is declared in the `.malloy` file; the component only\ncustomizes presentation. Preview with `malloyyo dashboard dev`, validate with\n`malloyyo lint`.)\n\n## The recipe\n\n```tsx\nimport { VegaChart } from "@malloyyo/dashboard";\n\n// Encodings point at the query\'s OUTPUT COLUMN NAMES (here: name, births).\nconst spec = {\n mark: { type: "bar", tooltip: true },\n encoding: {\n y: { field: "name", type: "nominal", sort: "-x" },\n x: { field: "births", type: "quantitative" },\n },\n};\n\nexport default function Dashboard({ givens }) {\n return <VegaChart spec={spec} query="births_by_name" givens={givens} />;\n}\n```\n\nThree ways to feed it data:\n- `<VegaChart spec={spec} query="births_by_name" givens={givens}/>` \u2014 a query\n defined in the dashboard\'s `.malloy` file (by name), or a `source -> view`\n- `<VegaChart spec={spec} malloy="source -> view" givens={givens}/>` \u2014 restricted\n Malloy text (same governance as the explore surface: no import / given: /\n connection.* / raw SQL / ##! flags)\n- `<VegaChart spec={spec} data={rows}/>` \u2014 rows you already have from `useQuery`\n\n## Gotchas (the ones that actually bite)\n\n- **Shape the data in Malloy; return FLAT rows.** Do ranking, share/percent\n (`all(x, dim)`), and label lookups (a `pick` for month names) in the QUERY.\n The spec just encodes columns \u2014 it is not the place to reshape data.\n- **Match column names character-for-character.** Run the query once with\n `query(execute:true)` and read the exact output column names; the spec\'s\n `field` values must match them exactly.\n- **The spec\'s `data` is ignored / any `url` is stripped.** The frame has no\n network \u2014 remote data URLs, transform lookups, and remote `image` marks are\n removed. Adapting a Vega-Lite gallery example = delete its\n `"data": {"url": \u2026}` and repoint the encodings; the query rows are inlined for\n you as the dataset.\n- **Nests come back as arrays.** Flatten to plottable rows in the query, or bind\n a nest to its own chart: `<VegaChart data={row.my_nest}/>`.\n- **Interactivity = setting given values**, never rewriting query text per\n interaction. Client-side chart interactions (tooltip, zoom, brush) work;\n anything that calls a server does not.\n- **Reads well:** for normalized/share data use a diverging color scale with\n `domainMid` (e.g. `1/12` for month-share), and sort a discrete axis by a\n companion numeric field (`month_name` sorted by `month_num`) rather than\n alphabetically.\n\n## Validate\n\n`malloyyo lint` checks the query, the givens, AND the component (it compiles,\nand each `query="\u2026"` it references resolves) \u2014 your only pre-browser check. Then\n`malloyyo dashboard dev` to see it render.\n',
|
|
89
94
|
"develop/connection-setup.md": '---\ndescription: Setting up a data connection (malloy-config.json)\n---\n\n# Setting up a data connection\n\nA model reaches its data through a **connection** declared in\n`malloy-config.json` at the **root of the model** (next to `index.malloy`). This\nis Malloy\'s standard connection config \u2014 the full reference, with every\nconnector\'s properties, is at\n<https://docs.malloydata.dev/documentation/setup/config>. The essentials:\n\n## The file\n\n```json\n{\n "connections": {\n "mydb": { "is": "duckdb" }\n }\n}\n```\n\n- A connection has a **name** (the key) and a type (`is`). Sources refer to it by\n that name \u2014 `source: x is mydb.table("orders")` or `mydb.sql("SELECT \u2026")` \u2014 so\n the name in the config must match the name in the model.\n- Supported types (`is`): `duckdb` (incl. MotherDuck), `bigquery`, `postgres`,\n `mysql`, `snowflake`, `databricks`, `trino`, `presto`. Each has its own\n properties \u2014 see the full docs.\n\n## Default connections (and when they apply)\n\nSetting `"includeDefaultConnections": true` makes one connection available for\n**each registered database type, named by the type** \u2014 a `duckdb` connection\nnamed `duckdb`, a `postgres` named `postgres`, and so on. Each uses that\nconnector\'s default settings, which for several backends means picking up\ncredentials from the environment (e.g. BigQuery\'s application-default\ncredentials \u2014 see the per-connector setup docs). Connections you name explicitly\nin `connections` always win; the defaults only fill in types you didn\'t list.\n\n```json\n{\n "includeDefaultConnections": true,\n "connections": { "warehouse": { "is": "postgres", "host": "\u2026" } }\n}\n```\n\nmalloyyo has one rule worth knowing:\n\n- **No `malloy-config.json` at all \u2192 defaults are ON.** `duckdb` just works with\n zero setup.\n- **Write a `malloy-config.json` and they turn OFF** unless you add\n `"includeDefaultConnections": true`. A config that only defines, say, a\n `postgres` connection will report *No connection named "duckdb"* if a source\n still references `duckdb`.\n\nThis is deliberate \u2014 your local connections then resolve exactly the way the\npublished server\'s will, rather than silently leaning on a default that would not\nexist in production. (It differs from `malloy-cli`, which forces the defaults on\nunconditionally.)\n\nGive a connection a **custom name** \u2014 not the bare type default \u2014 whenever you\nhave more than one connection of the same type, or need non-default parameters.\n\n## DuckDB and local files (the common case)\n\nDuckDB can either open a **DuckDB database file** or read **local data files**\n(CSV, Parquet, \u2026) directly.\n\n**A pre-loaded database file** \u2014 point `databasePath` at a `.duckdb` file and\nreference its tables by name (an absolute path is safest):\n\n```json\n{ "connections": { "warehouse": { "is": "duckdb", "databasePath": "/data/warehouse.duckdb" } } }\n```\n\n**Local files, by path** \u2014 read a CSV or Parquet file straight into a source.\nThese paths are **project-relative** \u2014 resolved against the model root, so they\nsurvive publishing:\n\n source: my_csv is duckdb.table(\'data/my_file.csv\')\n source: my_parquet is duckdb.table(\'data/my_file.parquet\')\n\n`.table()` names a single file. When you need something it can\'t express \u2014 a\nglob, a union, any SQL \u2014 wrap it in `.sql()` (its paths are project-relative too):\n\n source: payments is duckdb.sql(\n "SELECT * FROM read_parquet(\'data/payments-*.parquet\')"\n )\n\nThe default `duckdb` connection is **in-memory** \u2014 nothing persists between runs;\nyour data lives in the files (or the `databasePath` database) you read.\n\n**MotherDuck:** a DuckDB connection against an `md:` database; set the\n`MOTHERDUCK_TOKEN` environment variable.\n\n## Secrets \u2014 keep them out of the file\n\nAny property value may be written as `{ "env": "VAR_NAME" }`. It resolves from\n`process.env.VAR_NAME` when the connection opens, so passwords and tokens never\nget committed:\n\n```json\n{\n "connections": {\n "analytics": {\n "is": "postgres",\n "host": "db.internal",\n "databaseName": "analytics",\n "username": "reader",\n "password": { "env": "PG_PASSWORD" }\n }\n }\n}\n```\n\n(The non-secret property names here are illustrative \u2014 each connector\'s exact\nproperties are in the full docs. The `{ "env": \u2026 }` form is the part that\nmatters: it works for any value.)\n\n## malloyyo specifics\n\n- **One file, one place.** Only the `malloy-config.json` at the model root is\n read \u2014 there is no walk-up to parent directories.\n- **Local override:** a `malloy-config-local.json` (do **not** commit it)\n **replaces** `malloy-config.json` entirely when present \u2014 your private variant\n for local credentials or a different database.\n- **The same file ships to production.** Publishing uploads this exact\n `malloy-config.json`, so it must resolve the same way locally and on the\n server \u2014 put anything environment-specific behind `{ "env": \u2026 }` rather than\n hard-coding it. That is what makes the local test window faithful to\n production.\n- Edits are picked up **without a restart** \u2014 the server re-reads the file when\n it changes.\n\n## When a connection will not resolve\n\nFix the connection **first**: a broken connection yields an empty schema and then\na cascade of misleading `field-not-found` errors \u2014 ignore those and fix the\nconnection. A fast check is to compile a probe and see if it alone compiles:\n\n source: _probe is mydb.sql("SELECT 1 AS one")\n',
|
|
90
95
|
"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',
|
|
91
96
|
"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",
|
|
92
97
|
"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.',
|
|
93
|
-
"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",
|
|
98
|
+
"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## Make a cell a clickable deep link \u2014 `# link`\n\nWhen the answer is \"here's the row, go look at it in the source system\", tag a\n`group_by:`/`select:` field with `# link` so its cell renders as a hyperlink\n(in the shareable ltool view and in dashboards). Three forms:\n\n```malloy\nrun: flights -> {\n # link -- the value IS a full URL\n group_by: page is concat('https://wikipedia.org/wiki/', origin)\n}\n```\n\n```malloy\nrun: flights -> {\n # link { url_template='https://www.flightsfrom.com/$$' } -- $$ = this cell's value\n group_by: origin\n}\n```\n\nLink to a value *other* than the one displayed with `field=`, and hide the raw\nid with `# hidden` so only the label shows:\n\n```malloy\nrun: flights -> {\n # link { url_template='https://crm.example.com/person/$$' field=person_id }\n group_by: person_name\n # hidden\n group_by: person_id\n}\n```\n\n`$$` is substituted anywhere in the template (`.../$$-SJC` works). Sibling\n`# image { url_template=\u2026 width= height= alt= }` renders the cell as an inline\nimage instead. Deep links open in a new browser tab.\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",
|
|
94
99
|
"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",
|
|
95
|
-
"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',
|
|
100
|
+
"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 (`# bar_chart`, `# line_chart`, `# shape_map`; `# link { url_template="https://\u2026/$$" }` turns a field\'s cell into a clickable deep link \u2014 `$$` is the cell value, `field=other` links on a different (often `# hidden`) field; `# image { url_template=\u2026 }` renders it as an image)\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',
|
|
96
101
|
"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",
|
|
97
102
|
"writing-malloy-with-mcp.md": "---\ndescription: How to write Malloy over an MCP surface \u2014 the compiler-in-the-loop discipline, common errors, and givens. Tool-agnostic; read once.\n---\n# Writing Malloy over MCP\n\nMalloy is a semantic language: a source already carries measures, dimensions,\nviews, and joins, and the compiler typechecks every query against them. The\nsingle most useful habit is **let the compiler be ground truth** \u2014 don't guess\nsyntax or field names. Read the source's shape first, validate before you run,\nand read the `problems[]` the surface returns.\n\nThe exact tools differ by surface (an explore surface exposes describe + query;\nan authoring surface adds compile/prettify), but the loop is the same:\n\n1. **Read the shape.** Describe the source you're querying \u2014 its measures,\n dimensions, views, and joins. The model usually already defines the\n aggregation you want; reuse it instead of re-deriving it.\n2. **Validate, then run.** Compile/validate the query first (no execution) to\n confirm it typechecks and to see the generated SQL or the givens it needs;\n fix any `problems[]`, then execute to get rows.\n3. **Recover from problems[].** Every failure \u2014 parse, field-not-found,\n aggregate-locality, runtime \u2014 comes back as a uniform `problems[]` with a\n `code` and (when known) a `help_topic`. Pull that topic with `yo_help`.\n\n## Common errors and how to read them\n\n- **Unknown field** \u2014 describe the source and check its dimensions / measures /\n views / joins for what actually exists. A join rendered by `source_ref` is\n described under that name in the same response.\n- **Aggregate locality** \u2014 `sum(joined.x)` across a join needs explicit\n locality: `source.sum(joined.x)` or `joined.x.sum()`.\n- **Mixed reduction / projection** \u2014 one query stage is either\n `group_by:`/`aggregate:` OR `select:`, never both.\n- **Calculation in a source** \u2014 `calculate:` (window functions) lives in\n queries, not in source definitions.\n\n## Givens (`$NAME` parameters)\n\nSome models declare given parameters (`$TENANT`, `$MAX_ROWS`, \u2026). Validate a\nquery with execution off to learn which givens it references (with their types\nand whether a default exists), then supply values keyed by surface name (no\n`$`). A given with a default is optional; one without must be supplied. For the\nper-type value shapes (dates as ISO strings, records as objects, `filter<T>` as\na Malloy filter string, \u2026) pull `yo_help(\"givens\")` \u2014 don't guess; the compiler\nvalidates and points at the offending field.\n\n## Before writing non-trivial Malloy\n\nBrowse the `language/*` topics first (start with `yo_help(\"language/overview\")`).\nThe language has real scoping and typing rules the compiler enforces \u2014 reading\nthe reference beats guessing.\n"
|
|
98
103
|
};
|
|
@@ -1152,6 +1157,23 @@ function docText(t) {
|
|
|
1152
1157
|
return void 0;
|
|
1153
1158
|
}
|
|
1154
1159
|
}
|
|
1160
|
+
function resolveTile(tile, source) {
|
|
1161
|
+
const t = tile.trim();
|
|
1162
|
+
if (source && !t.includes("->")) return `${source} -> ${t}`;
|
|
1163
|
+
return t;
|
|
1164
|
+
}
|
|
1165
|
+
function readGivens(tag) {
|
|
1166
|
+
const givensTag = tag.tag("artifact", "givens");
|
|
1167
|
+
if (!givensTag) return void 0;
|
|
1168
|
+
const givens = {};
|
|
1169
|
+
for (const [key, t] of Object.entries(givensTag.dict ?? {})) {
|
|
1170
|
+
const eq = t.eq;
|
|
1171
|
+
if (typeof eq === "string" || typeof eq === "number" || typeof eq === "boolean") {
|
|
1172
|
+
givens[key] = eq;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return Object.keys(givens).length ? givens : void 0;
|
|
1176
|
+
}
|
|
1155
1177
|
function readArtifactTag(ident, q) {
|
|
1156
1178
|
let tag;
|
|
1157
1179
|
try {
|
|
@@ -1164,33 +1186,51 @@ function readArtifactTag(ident, q) {
|
|
|
1164
1186
|
const description = docText(q);
|
|
1165
1187
|
const name = nested?.text("name") ?? tag.text("name") ?? ident.defaultName;
|
|
1166
1188
|
const title = nested?.text("title") ?? tag.text("title") ?? description?.split("\n")[0] ?? ident.defaultName;
|
|
1189
|
+
const rawTiles = nested?.textArray("tiles") ?? tag.textArray("tiles");
|
|
1190
|
+
if (rawTiles && rawTiles.length) {
|
|
1191
|
+
const info2 = {
|
|
1192
|
+
name,
|
|
1193
|
+
query: "",
|
|
1194
|
+
title,
|
|
1195
|
+
tiles: rawTiles.map((t) => resolveTile(t, ident.source))
|
|
1196
|
+
};
|
|
1197
|
+
if (ident.source) info2.source = ident.source;
|
|
1198
|
+
if (description) info2.description = description;
|
|
1199
|
+
const cols = nested?.numeric("dashboard_columns") ?? tag.numeric("dashboard_columns");
|
|
1200
|
+
if (typeof cols === "number") info2.dashboard_columns = cols;
|
|
1201
|
+
const autorunText2 = nested?.text("autorun") ?? tag.text("autorun");
|
|
1202
|
+
if (autorunText2 === "false") info2.autorun = false;
|
|
1203
|
+
const givens2 = readGivens(tag);
|
|
1204
|
+
if (givens2) info2.givens = givens2;
|
|
1205
|
+
return info2;
|
|
1206
|
+
}
|
|
1167
1207
|
const info = { name, query: ident.runExpr, title };
|
|
1168
1208
|
if (ident.source) info.source = ident.source;
|
|
1169
1209
|
if (ident.view) info.view = ident.view;
|
|
1170
1210
|
if (description) info.description = description;
|
|
1171
|
-
const
|
|
1172
|
-
if (
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
const eq = t.eq;
|
|
1176
|
-
if (typeof eq === "string" || typeof eq === "number" || typeof eq === "boolean") {
|
|
1177
|
-
givens[key] = eq;
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
if (Object.keys(givens).length) info.givens = givens;
|
|
1181
|
-
}
|
|
1211
|
+
const autorunText = nested?.text("autorun") ?? tag.text("autorun");
|
|
1212
|
+
if (autorunText === "false") info.autorun = false;
|
|
1213
|
+
const givens = readGivens(tag);
|
|
1214
|
+
if (givens) info.givens = givens;
|
|
1182
1215
|
return info;
|
|
1183
1216
|
}
|
|
1184
1217
|
async function artifactQueries(runtime, entry) {
|
|
1185
1218
|
try {
|
|
1186
1219
|
const model = await runtime.loadModel(entry).getModel();
|
|
1187
1220
|
const artifacts = [];
|
|
1221
|
+
const modelComposite = readArtifactTag({ runExpr: "", defaultName: "dashboard" }, model);
|
|
1222
|
+
if (modelComposite?.tiles) artifacts.push(modelComposite);
|
|
1188
1223
|
for (const queryName of model.queries().named) {
|
|
1189
1224
|
const pq = model.getPreparedQueryByName(queryName);
|
|
1190
1225
|
const info = readArtifactTag({ runExpr: queryName, defaultName: queryName }, pq);
|
|
1191
1226
|
if (info) artifacts.push(info);
|
|
1192
1227
|
}
|
|
1193
1228
|
for (const src of model.explores) {
|
|
1229
|
+
const srcComposite = readArtifactTag(
|
|
1230
|
+
{ runExpr: "", defaultName: src.name, source: src.name },
|
|
1231
|
+
src
|
|
1232
|
+
);
|
|
1233
|
+
if (srcComposite?.tiles) artifacts.push(srcComposite);
|
|
1194
1234
|
for (const field of src.allFields) {
|
|
1195
1235
|
if (!field.isQueryField()) continue;
|
|
1196
1236
|
const view = field;
|
|
@@ -1211,6 +1251,111 @@ async function artifactQueries(runtime, entry) {
|
|
|
1211
1251
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1212
1252
|
}
|
|
1213
1253
|
}
|
|
1254
|
+
async function collectDrillTargets(runtime, entry) {
|
|
1255
|
+
try {
|
|
1256
|
+
const model = await runtime.loadModel(entry).getModel();
|
|
1257
|
+
const targets = /* @__PURE__ */ new Set();
|
|
1258
|
+
for (const src of model.explores) {
|
|
1259
|
+
for (const field of src.allFields) {
|
|
1260
|
+
const tagged = field;
|
|
1261
|
+
let tag;
|
|
1262
|
+
try {
|
|
1263
|
+
tag = tagged.annotations.parseAsTag().tag;
|
|
1264
|
+
} catch {
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
const drill = tag.tag("drill");
|
|
1268
|
+
if (!drill) continue;
|
|
1269
|
+
const to = drill.textArray("to") ?? (drill.text("to") ? [drill.text("to")] : []);
|
|
1270
|
+
for (const t of to) if (t && t !== "self") targets.add(t);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return { ok: true, targets: [...targets] };
|
|
1274
|
+
} catch (e) {
|
|
1275
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
async function modelArtifact(runtime, entry, defaultName) {
|
|
1279
|
+
try {
|
|
1280
|
+
const model = await runtime.loadModel(entry).getModel();
|
|
1281
|
+
const composite = readArtifactTag({ runExpr: "", defaultName }, model);
|
|
1282
|
+
if (composite?.tiles) return { ok: true, artifact: composite };
|
|
1283
|
+
for (const queryName of model.queries().named) {
|
|
1284
|
+
const pq = model.getPreparedQueryByName(queryName);
|
|
1285
|
+
const info = readArtifactTag({ runExpr: queryName, defaultName }, pq);
|
|
1286
|
+
if (info && !info.tiles) return { ok: true, artifact: { ...info, tiles: [info.query], query: "" } };
|
|
1287
|
+
}
|
|
1288
|
+
for (const src of model.explores) {
|
|
1289
|
+
for (const field of src.allFields) {
|
|
1290
|
+
if (!field.isQueryField()) continue;
|
|
1291
|
+
const view = field;
|
|
1292
|
+
const info = readArtifactTag(
|
|
1293
|
+
{ runExpr: `${src.name} -> ${view.name}`, defaultName, source: src.name, view: view.name },
|
|
1294
|
+
view
|
|
1295
|
+
);
|
|
1296
|
+
if (info && !info.tiles) return { ok: true, artifact: { ...info, tiles: [info.query], query: "" } };
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
return { ok: true, artifact: void 0 };
|
|
1300
|
+
} catch (e) {
|
|
1301
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
function liftRenderTags(annotations) {
|
|
1305
|
+
if (!annotations) return [];
|
|
1306
|
+
return annotations.filter((a) => {
|
|
1307
|
+
const v = a.value;
|
|
1308
|
+
return v.startsWith("# ") && !v.startsWith("# artifact");
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
function tileAsNestField(name, res) {
|
|
1312
|
+
const fields = (res.schema?.fields ?? []).map((f) => ({
|
|
1313
|
+
name: f.name,
|
|
1314
|
+
type: f.type,
|
|
1315
|
+
annotations: f.annotations
|
|
1316
|
+
}));
|
|
1317
|
+
return {
|
|
1318
|
+
kind: "dimension",
|
|
1319
|
+
name,
|
|
1320
|
+
type: {
|
|
1321
|
+
kind: "array_type",
|
|
1322
|
+
element_type: { kind: "record_type", fields }
|
|
1323
|
+
},
|
|
1324
|
+
annotations: liftRenderTags(res.annotations)
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
function uniqueName(name, taken) {
|
|
1328
|
+
if (!taken.has(name)) {
|
|
1329
|
+
taken.add(name);
|
|
1330
|
+
return name;
|
|
1331
|
+
}
|
|
1332
|
+
let i = 2;
|
|
1333
|
+
while (taken.has(`${name}_${i}`)) i++;
|
|
1334
|
+
const out = `${name}_${i}`;
|
|
1335
|
+
taken.add(out);
|
|
1336
|
+
return out;
|
|
1337
|
+
}
|
|
1338
|
+
function combineTiles(tiles, opts = {}) {
|
|
1339
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1340
|
+
const named = tiles.map((t) => ({ name: uniqueName(t.name, taken), result: t.result }));
|
|
1341
|
+
const columns = typeof opts.columns === "number" && Number.isFinite(opts.columns) ? ` {columns=${Math.trunc(opts.columns)}}` : "";
|
|
1342
|
+
return {
|
|
1343
|
+
// Provenance carried from the first tile; the combined result never runs
|
|
1344
|
+
// SQL itself, so connection_name is cosmetic but the renderer reads it.
|
|
1345
|
+
connection_name: tiles[0]?.result.connection_name ?? "composite",
|
|
1346
|
+
model_annotations: tiles[0]?.result.model_annotations,
|
|
1347
|
+
annotations: [{ value: `# dashboard${columns}
|
|
1348
|
+
` }],
|
|
1349
|
+
schema: { fields: named.map((t) => tileAsNestField(t.name, t.result)) },
|
|
1350
|
+
// One dashboard row whose columns are the tiles' data cells (verbatim).
|
|
1351
|
+
data: {
|
|
1352
|
+
kind: "array_cell",
|
|
1353
|
+
array_value: [
|
|
1354
|
+
{ kind: "record_cell", record_value: named.map((t) => t.result.data) }
|
|
1355
|
+
]
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1214
1359
|
var INSTANCE_PLACEHOLDER = "{{INSTANCE_NAME}}";
|
|
1215
1360
|
function renderInstructions(text, instanceName) {
|
|
1216
1361
|
return text.replaceAll(INSTANCE_PLACEHOLDER, instanceName);
|
|
@@ -1361,6 +1506,17 @@ function refModelProblem(ref, e) {
|
|
|
1361
1506
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1362
1507
|
return codeProblem("model-not-found", `Cannot use model '${ref}': ${msg}`);
|
|
1363
1508
|
}
|
|
1509
|
+
function refNudge(ref, inspect) {
|
|
1510
|
+
const also = inspect.also ? ` and ${inspect.also}=<the source you queried>` : "";
|
|
1511
|
+
return (p) => {
|
|
1512
|
+
if (p.code !== "field-not-found") return p;
|
|
1513
|
+
return {
|
|
1514
|
+
...p,
|
|
1515
|
+
message: `${p.message} \u2014 call ${inspect.tool} with ${inspect.param}="${ref}"${also} to see what fields, measures, views, and joins exist.`,
|
|
1516
|
+
help_topic: p.help_topic ?? "language/fields"
|
|
1517
|
+
};
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1364
1520
|
async function executeQuery(m, args, fix, result) {
|
|
1365
1521
|
const malloy = argString(args, "malloy");
|
|
1366
1522
|
const execute = argOptBool(args, "execute") ?? true;
|
|
@@ -1374,6 +1530,50 @@ async function executeQuery(m, args, fix, result) {
|
|
|
1374
1530
|
const budgeted = await applyResultBudget(full, result, { toolName: "query", args });
|
|
1375
1531
|
return { ...budgeted, problems: budgeted.problems.map(fix) };
|
|
1376
1532
|
}
|
|
1533
|
+
function queryTool(host, opts = {}) {
|
|
1534
|
+
const inspect = opts.inspect ?? { tool: "describe_source", param: "model_ref", also: "source" };
|
|
1535
|
+
return {
|
|
1536
|
+
name: "query",
|
|
1537
|
+
title: prompts.shared.tools.query.title,
|
|
1538
|
+
description: prompts.shared.tools.query.description,
|
|
1539
|
+
inputSchema: {
|
|
1540
|
+
type: "object",
|
|
1541
|
+
properties: {
|
|
1542
|
+
model_ref: {
|
|
1543
|
+
type: "string",
|
|
1544
|
+
description: "Model ref \u2014 a published model name, or (on a local develop server) the path of the root model file."
|
|
1545
|
+
},
|
|
1546
|
+
malloy: { type: "string", description: "Malloy query text, e.g. `run: orders -> { ... }`." },
|
|
1547
|
+
question: {
|
|
1548
|
+
type: "string",
|
|
1549
|
+
description: "Plain-English description of what this query answers; hosts may record or share it."
|
|
1550
|
+
},
|
|
1551
|
+
givens: {
|
|
1552
|
+
type: "object",
|
|
1553
|
+
description: "Values for $NAME givens, keyed by name (no $). Validate with execute:false to learn which givens the query needs."
|
|
1554
|
+
},
|
|
1555
|
+
execute: { type: "boolean", description: "Default true. false \u2192 compile/validate only (no execution)." },
|
|
1556
|
+
max_rows: { type: "integer", minimum: 1, maximum: 1e4, description: `Row cap (default ${DEFAULT_ROW_LIMIT}).` }
|
|
1557
|
+
},
|
|
1558
|
+
required: ["model_ref", "malloy"],
|
|
1559
|
+
additionalProperties: false
|
|
1560
|
+
},
|
|
1561
|
+
handler: async (args) => {
|
|
1562
|
+
const ref = argString(args, "model_ref");
|
|
1563
|
+
if (!ref.trim()) {
|
|
1564
|
+
return { ok: false, problems: [codeProblem("model-ref-required", prompts.shared.errors["no-model-ref"])] };
|
|
1565
|
+
}
|
|
1566
|
+
try {
|
|
1567
|
+
return await host.withModel(
|
|
1568
|
+
ref,
|
|
1569
|
+
(m) => executeQuery(m, args, refNudge(ref, inspect), opts.result)
|
|
1570
|
+
);
|
|
1571
|
+
} catch (e) {
|
|
1572
|
+
return { ok: false, problems: [refModelProblem(ref, e)] };
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1377
1577
|
async function resolveModel(host, source, modelRef) {
|
|
1378
1578
|
if (modelRef) return { model_ref: modelRef };
|
|
1379
1579
|
if (!host.list) {
|
|
@@ -1654,9 +1854,126 @@ function exploreSurface(host, opts = {}) {
|
|
|
1654
1854
|
skills: sharedSkills()
|
|
1655
1855
|
};
|
|
1656
1856
|
}
|
|
1857
|
+
var pathSchema = {
|
|
1858
|
+
type: "string",
|
|
1859
|
+
description: "Path to a .malloy file, relative to the server root. (Absolute paths and file:// URIs are also accepted.)"
|
|
1860
|
+
};
|
|
1861
|
+
var sourceSchema = { type: "string", description: "Malloy source code." };
|
|
1862
|
+
var basePathSchema = {
|
|
1863
|
+
type: "string",
|
|
1864
|
+
description: "Optional path for resolving relative imports in the inline source \u2014 typically the file the snippet will live next to. If omitted, imports must be absolute."
|
|
1865
|
+
};
|
|
1866
|
+
var expandSchema = {
|
|
1867
|
+
type: "string",
|
|
1868
|
+
enum: ["ref", "inline"],
|
|
1869
|
+
description: "Join rendering: 'ref' (default) references joined sources by name via source_ref; 'inline' recursively inlines the joined schema."
|
|
1870
|
+
};
|
|
1871
|
+
var emitRunSqlSchema = {
|
|
1872
|
+
type: "boolean",
|
|
1873
|
+
description: "Compile each run: statement to SQL and include it as model.runs[].sql. Default false (large; use query to execute)."
|
|
1874
|
+
};
|
|
1875
|
+
function developSurface(host, opts = {}) {
|
|
1876
|
+
async function lease(input, fn) {
|
|
1877
|
+
try {
|
|
1878
|
+
return await host.withRuntime(input, fn);
|
|
1879
|
+
} catch (e) {
|
|
1880
|
+
return { ok: false, problems: [errorProblem(e)] };
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
const modelHost = {
|
|
1884
|
+
withModel: (ref, fn) => host.withRuntime({ url: ref }, fn)
|
|
1885
|
+
};
|
|
1886
|
+
const tools = [
|
|
1887
|
+
{
|
|
1888
|
+
name: "compile_file",
|
|
1889
|
+
title: prompts.develop.tools.compile_file.title,
|
|
1890
|
+
description: prompts.develop.tools.compile_file.description,
|
|
1891
|
+
inputSchema: {
|
|
1892
|
+
type: "object",
|
|
1893
|
+
properties: { path: pathSchema, expand: expandSchema, emit_run_sql: emitRunSqlSchema },
|
|
1894
|
+
required: ["path"],
|
|
1895
|
+
additionalProperties: false
|
|
1896
|
+
},
|
|
1897
|
+
handler: async (args) => lease(
|
|
1898
|
+
{ url: argString(args, "path") },
|
|
1899
|
+
(m) => compile(m.runtime, m.entry, {
|
|
1900
|
+
readSource: m.readSource,
|
|
1901
|
+
expand: argOptString(args, "expand"),
|
|
1902
|
+
emitRunSql: argOptBool(args, "emit_run_sql")
|
|
1903
|
+
})
|
|
1904
|
+
)
|
|
1905
|
+
},
|
|
1906
|
+
{
|
|
1907
|
+
name: "compile",
|
|
1908
|
+
title: prompts.develop.tools.compile.title,
|
|
1909
|
+
description: prompts.develop.tools.compile.description,
|
|
1910
|
+
inputSchema: {
|
|
1911
|
+
type: "object",
|
|
1912
|
+
properties: {
|
|
1913
|
+
source: sourceSchema,
|
|
1914
|
+
base_path: basePathSchema,
|
|
1915
|
+
expand: expandSchema,
|
|
1916
|
+
emit_run_sql: emitRunSqlSchema
|
|
1917
|
+
},
|
|
1918
|
+
required: ["source"],
|
|
1919
|
+
additionalProperties: false
|
|
1920
|
+
},
|
|
1921
|
+
handler: async (args) => lease(
|
|
1922
|
+
{ source: argString(args, "source"), baseUrl: argOptString(args, "base_path") },
|
|
1923
|
+
(m) => compile(m.runtime, m.entry, {
|
|
1924
|
+
readSource: m.readSource,
|
|
1925
|
+
expand: argOptString(args, "expand"),
|
|
1926
|
+
emitRunSql: argOptBool(args, "emit_run_sql")
|
|
1927
|
+
})
|
|
1928
|
+
)
|
|
1929
|
+
},
|
|
1930
|
+
queryTool(modelHost, {
|
|
1931
|
+
result: opts.result,
|
|
1932
|
+
inspect: { tool: "compile_file", param: "path" }
|
|
1933
|
+
}),
|
|
1934
|
+
{
|
|
1935
|
+
name: "prettify",
|
|
1936
|
+
title: prompts.develop.tools.prettify.title,
|
|
1937
|
+
description: prompts.develop.tools.prettify.description,
|
|
1938
|
+
inputSchema: {
|
|
1939
|
+
type: "object",
|
|
1940
|
+
properties: { source: sourceSchema },
|
|
1941
|
+
required: ["source"],
|
|
1942
|
+
additionalProperties: false
|
|
1943
|
+
},
|
|
1944
|
+
handler: async (args) => prettify(argString(args, "source"))
|
|
1945
|
+
},
|
|
1946
|
+
yoHelpTool()
|
|
1947
|
+
];
|
|
1948
|
+
return {
|
|
1949
|
+
tools: tools.map(withHelp),
|
|
1950
|
+
instructions: assembleInstructions("develop"),
|
|
1951
|
+
skills: sharedSkills()
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1657
1954
|
|
|
1658
1955
|
// src/host.ts
|
|
1659
1956
|
var ENTRY = "index.malloy";
|
|
1957
|
+
function tileName(runExpr) {
|
|
1958
|
+
const arrow = runExpr.lastIndexOf("->");
|
|
1959
|
+
return (arrow >= 0 ? runExpr.slice(arrow + 2) : runExpr).trim();
|
|
1960
|
+
}
|
|
1961
|
+
async function validateQuery(runtime, entry, runExpr, givens) {
|
|
1962
|
+
try {
|
|
1963
|
+
const q = runtime.loadModel(entry).loadQuery(`run: ${runExpr}`);
|
|
1964
|
+
const has = givens && Object.keys(givens).length > 0;
|
|
1965
|
+
await q.getSQL(has ? { givens } : void 0);
|
|
1966
|
+
return { ok: true };
|
|
1967
|
+
} catch (e) {
|
|
1968
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
async function validateRestrictedText(runtime, entry, malloy) {
|
|
1972
|
+
const v = await validateRestricted(runtime, entry, malloy);
|
|
1973
|
+
if (v.ok) return { ok: true };
|
|
1974
|
+
const msg = v.problems.filter((p) => p.severity === "error").map((p) => p.message).join("; ");
|
|
1975
|
+
return { ok: false, error: msg || "restricted query failed to compile" };
|
|
1976
|
+
}
|
|
1660
1977
|
function fsReader() {
|
|
1661
1978
|
return {
|
|
1662
1979
|
readURL: async (u) => {
|
|
@@ -1677,56 +1994,123 @@ async function makeRunner(root) {
|
|
|
1677
1994
|
await import("@malloydata/malloy-connections");
|
|
1678
1995
|
const abs = path2.resolve(root);
|
|
1679
1996
|
const rootUrl = url2.pathToFileURL(abs + path2.sep);
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1997
|
+
const reader = fsReader();
|
|
1998
|
+
let configPromise = null;
|
|
1999
|
+
const getConfig = () => configPromise ??= loadConfig(rootUrl, reader);
|
|
2000
|
+
let inFlight = 0;
|
|
2001
|
+
async function leaseIn(entryFile, fn) {
|
|
2002
|
+
const config = await getConfig();
|
|
2003
|
+
const { reader: prepared, entry } = prepareSource(reader, { url: path2.join(abs, entryFile) });
|
|
1684
2004
|
const runtime = new Runtime({ config, urlReader: prepared });
|
|
2005
|
+
inFlight++;
|
|
1685
2006
|
try {
|
|
1686
2007
|
return await fn(runtime, entry);
|
|
1687
2008
|
} finally {
|
|
1688
|
-
|
|
2009
|
+
inFlight--;
|
|
2010
|
+
if (inFlight === 0) await config.shutdown("idle").catch(() => {
|
|
1689
2011
|
});
|
|
1690
2012
|
}
|
|
1691
2013
|
}
|
|
2014
|
+
const lease = (fn) => leaseIn(ENTRY, fn);
|
|
1692
2015
|
return {
|
|
1693
2016
|
root: abs,
|
|
1694
2017
|
entryExists: () => fs.existsSync(path2.join(abs, ENTRY)),
|
|
2018
|
+
async dispose() {
|
|
2019
|
+
if (!configPromise) return;
|
|
2020
|
+
const config = await configPromise.catch(() => null);
|
|
2021
|
+
configPromise = null;
|
|
2022
|
+
if (config) await config.shutdown("close").catch(() => {
|
|
2023
|
+
});
|
|
2024
|
+
},
|
|
1695
2025
|
run(runExpr, givens) {
|
|
1696
2026
|
return lease(
|
|
1697
2027
|
(runtime, entry) => run(runtime, entry, { runExpr, givens, stableResult: true, rowLimit: 5e3 })
|
|
1698
2028
|
);
|
|
1699
2029
|
},
|
|
2030
|
+
runIn(entryFile, runExpr, givens) {
|
|
2031
|
+
return leaseIn(
|
|
2032
|
+
entryFile,
|
|
2033
|
+
(runtime, entry) => run(runtime, entry, { runExpr, givens, stableResult: true, rowLimit: 5e3 })
|
|
2034
|
+
);
|
|
2035
|
+
},
|
|
1700
2036
|
runText(malloy, givens) {
|
|
1701
2037
|
return lease(
|
|
1702
2038
|
(runtime, entry) => runRestricted(runtime, entry, malloy, { givens, stableResult: true, rowLimit: 5e3 })
|
|
1703
2039
|
);
|
|
1704
2040
|
},
|
|
2041
|
+
runTextIn(entryFile, malloy, givens) {
|
|
2042
|
+
return leaseIn(
|
|
2043
|
+
entryFile,
|
|
2044
|
+
(runtime, entry) => runRestricted(runtime, entry, malloy, { givens, stableResult: true, rowLimit: 5e3 })
|
|
2045
|
+
);
|
|
2046
|
+
},
|
|
1705
2047
|
validateText(malloy) {
|
|
1706
|
-
return lease(
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
return { ok: false, error: msg || "restricted query failed to compile" };
|
|
1711
|
-
});
|
|
2048
|
+
return lease((runtime, entry) => validateRestrictedText(runtime, entry, malloy));
|
|
2049
|
+
},
|
|
2050
|
+
validateTextIn(entryFile, malloy) {
|
|
2051
|
+
return leaseIn(entryFile, (runtime, entry) => validateRestrictedText(runtime, entry, malloy));
|
|
1712
2052
|
},
|
|
1713
2053
|
givensForQuery(runExpr) {
|
|
1714
2054
|
return lease((runtime, entry) => dashboardGivenSpecs(runtime, entry, runExpr));
|
|
1715
2055
|
},
|
|
2056
|
+
givensForQueryIn(entryFile, runExpr) {
|
|
2057
|
+
return leaseIn(entryFile, (runtime, entry) => dashboardGivenSpecs(runtime, entry, runExpr));
|
|
2058
|
+
},
|
|
1716
2059
|
artifacts() {
|
|
1717
2060
|
return lease((runtime, entry) => artifactQueries(runtime, entry));
|
|
1718
2061
|
},
|
|
1719
|
-
|
|
1720
|
-
return lease(
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
2062
|
+
drillTargets() {
|
|
2063
|
+
return lease((runtime, entry) => collectDrillTargets(runtime, entry));
|
|
2064
|
+
},
|
|
2065
|
+
artifactForFile(entryFile, defaultName) {
|
|
2066
|
+
return leaseIn(entryFile, (runtime, entry) => modelArtifact(runtime, entry, defaultName));
|
|
2067
|
+
},
|
|
2068
|
+
runDashboard(entryFile, tiles, opts) {
|
|
2069
|
+
return leaseIn(entryFile, async (runtime, entry) => {
|
|
2070
|
+
const givens = opts.givens ?? {};
|
|
2071
|
+
const ran = [];
|
|
2072
|
+
for (const tile of tiles) {
|
|
2073
|
+
const specs = await dashboardGivenSpecs(runtime, entry, tile);
|
|
2074
|
+
const names = specs.ok ? new Set(specs.givens.map((s) => s.name)) : null;
|
|
2075
|
+
const tileGivens = names ? Object.fromEntries(Object.entries(givens).filter(([k]) => names.has(k))) : givens;
|
|
2076
|
+
const result = await run(runtime, entry, {
|
|
2077
|
+
runExpr: tile,
|
|
2078
|
+
givens: tileGivens,
|
|
2079
|
+
stableResult: true,
|
|
2080
|
+
rowLimit: 5e3
|
|
2081
|
+
});
|
|
2082
|
+
ran.push({ name: tileName(tile), result });
|
|
1728
2083
|
}
|
|
2084
|
+
const problems = ran.flatMap((t) => t.result.problems ?? []);
|
|
2085
|
+
const good = ran.filter((t) => t.result.ok && t.result.stable_result);
|
|
2086
|
+
if (good.length === 0) return { ok: false, problems };
|
|
2087
|
+
if (good.length === 1) {
|
|
2088
|
+
return { ok: true, stable_result: good[0].result.stable_result, problems };
|
|
2089
|
+
}
|
|
2090
|
+
const combined = combineTiles(
|
|
2091
|
+
good.map((t) => ({ name: t.name, result: t.result.stable_result })),
|
|
2092
|
+
{ columns: opts.columns }
|
|
2093
|
+
);
|
|
2094
|
+
return { ok: true, stable_result: combined, problems };
|
|
1729
2095
|
});
|
|
2096
|
+
},
|
|
2097
|
+
dashboardGivens(entryFile, tiles) {
|
|
2098
|
+
return leaseIn(entryFile, async (runtime, entry) => {
|
|
2099
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2100
|
+
for (const tile of tiles) {
|
|
2101
|
+
const specs = await dashboardGivenSpecs(runtime, entry, tile);
|
|
2102
|
+
if (specs.ok) {
|
|
2103
|
+
for (const s of specs.givens) if (!byName.has(s.name)) byName.set(s.name, s);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
return { ok: true, givens: [...byName.values()] };
|
|
2107
|
+
});
|
|
2108
|
+
},
|
|
2109
|
+
validate(runExpr, givens) {
|
|
2110
|
+
return lease((runtime, entry) => validateQuery(runtime, entry, runExpr, givens));
|
|
2111
|
+
},
|
|
2112
|
+
validateIn(entryFile, runExpr, givens) {
|
|
2113
|
+
return leaseIn(entryFile, (runtime, entry) => validateQuery(runtime, entry, runExpr, givens));
|
|
1730
2114
|
}
|
|
1731
2115
|
};
|
|
1732
2116
|
}
|
|
@@ -1754,28 +2138,37 @@ function gatherDirectory(dir) {
|
|
|
1754
2138
|
const config = existsSync2(configPath) ? readFileSync2(configPath, "utf8") : void 0;
|
|
1755
2139
|
return { files, config };
|
|
1756
2140
|
}
|
|
1757
|
-
function listDashboardDirs(dir) {
|
|
1758
|
-
const base = join2(dir, "dashboards");
|
|
1759
|
-
if (!existsSync2(base)) return [];
|
|
1760
|
-
return readdirSync(base).filter((name) => statSync(join2(base, name)).isDirectory()).sort();
|
|
1761
|
-
}
|
|
1762
2141
|
async function gatherDashboards(dir) {
|
|
2142
|
+
const dashDir = join2(dir, "dashboards");
|
|
2143
|
+
if (!existsSync2(dashDir)) return [];
|
|
1763
2144
|
const runner = await makeRunner(dir);
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
manifest
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
2145
|
+
try {
|
|
2146
|
+
const files = readdirSync(dashDir).filter((f) => f.endsWith(".malloy")).sort();
|
|
2147
|
+
const payloads = [];
|
|
2148
|
+
for (const file of files) {
|
|
2149
|
+
const base = file.slice(0, -".malloy".length);
|
|
2150
|
+
const entryFile = `dashboards/${file}`;
|
|
2151
|
+
const res = await runner.artifactForFile(entryFile, base);
|
|
2152
|
+
if (!res.ok) throw new Error(`dashboard ${file}: ${res.error}`);
|
|
2153
|
+
if (!res.artifact) continue;
|
|
2154
|
+
const a = res.artifact;
|
|
2155
|
+
const manifest = { title: a.title, entryFile };
|
|
2156
|
+
if (a.tiles) manifest.tiles = a.tiles;
|
|
2157
|
+
if (a.dashboard_columns !== void 0) manifest.dashboard_columns = a.dashboard_columns;
|
|
2158
|
+
if (a.description) manifest.description = a.description;
|
|
2159
|
+
if (a.givens) manifest.givens = a.givens;
|
|
2160
|
+
if (a.autorun === false) manifest.autorun = false;
|
|
2161
|
+
const component = ["jsx", "tsx"].map((ext) => join2(dashDir, `${base}.${ext}`)).find((p) => existsSync2(p));
|
|
2162
|
+
payloads.push({
|
|
2163
|
+
name: a.name || base,
|
|
2164
|
+
manifest,
|
|
2165
|
+
source: component ? readFileSync2(component, "utf8") : ""
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
return payloads;
|
|
2169
|
+
} finally {
|
|
2170
|
+
await runner.dispose();
|
|
2171
|
+
}
|
|
1779
2172
|
}
|
|
1780
2173
|
function gitInfo(dir) {
|
|
1781
2174
|
const git = (args) => execFileSync("git", args, {
|
|
@@ -1805,85 +2198,135 @@ function gitInfo(dir) {
|
|
|
1805
2198
|
}
|
|
1806
2199
|
|
|
1807
2200
|
// src/lint.ts
|
|
1808
|
-
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
2201
|
+
import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "node:fs";
|
|
1809
2202
|
import { join as join3, resolve } from "node:path";
|
|
1810
2203
|
import * as esbuild from "esbuild";
|
|
1811
2204
|
var quoteField = (f) => /^[A-Za-z_]\w*$/.test(f) ? f : `\`${f}\``;
|
|
2205
|
+
function componentQueryLiterals(source) {
|
|
2206
|
+
const out = /* @__PURE__ */ new Set();
|
|
2207
|
+
const re = /\bquery\s*=\s*(["'])([^"'\n]+)\1/g;
|
|
2208
|
+
let m;
|
|
2209
|
+
while ((m = re.exec(source)) !== null) out.add(m[2].trim());
|
|
2210
|
+
return [...out];
|
|
2211
|
+
}
|
|
1812
2212
|
async function lintDashboards(root) {
|
|
1813
2213
|
const abs = resolve(root);
|
|
1814
|
-
const dashboards = [];
|
|
1815
2214
|
const runner = await makeRunner(abs);
|
|
1816
|
-
|
|
1817
|
-
return
|
|
2215
|
+
try {
|
|
2216
|
+
return await runLint(abs, runner);
|
|
2217
|
+
} finally {
|
|
2218
|
+
await runner.dispose();
|
|
1818
2219
|
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
2220
|
+
}
|
|
2221
|
+
async function runLint(abs, runner) {
|
|
2222
|
+
const dashboards = [];
|
|
2223
|
+
if (runner.entryExists()) {
|
|
2224
|
+
const arts = await runner.artifacts();
|
|
2225
|
+
if (!arts.ok) dashboards.push({ name: "index.malloy", errors: [arts.error], warnings: [] });
|
|
2226
|
+
}
|
|
2227
|
+
const dir = join3(abs, "dashboards");
|
|
2228
|
+
if (!existsSync3(dir)) return { ok: dashboards.every((d) => d.errors.length === 0), dashboards };
|
|
2229
|
+
const entries = readdirSync2(dir);
|
|
2230
|
+
const malloyFiles = entries.filter((f) => f.endsWith(".malloy")).sort();
|
|
2231
|
+
const malloyBases = new Set(malloyFiles.map((f) => f.slice(0, -".malloy".length)));
|
|
2232
|
+
for (const c of entries.filter((f) => /\.(jsx|tsx)$/.test(f)).sort()) {
|
|
2233
|
+
const cbase = c.replace(/\.(jsx|tsx)$/, "");
|
|
2234
|
+
if (!malloyBases.has(cbase)) {
|
|
2235
|
+
dashboards.push({
|
|
2236
|
+
name: c,
|
|
2237
|
+
errors: [`component "${c}" has no matching "${cbase}.malloy" dashboard`],
|
|
2238
|
+
warnings: []
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
1822
2241
|
}
|
|
1823
|
-
const
|
|
1824
|
-
const
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
for (const dir of dirs) {
|
|
2242
|
+
const seenNames = /* @__PURE__ */ new Map();
|
|
2243
|
+
for (const file of malloyFiles) {
|
|
2244
|
+
const base = file.slice(0, -".malloy".length);
|
|
2245
|
+
const entryFile = join3("dashboards", file);
|
|
1828
2246
|
const errors = [];
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
);
|
|
2247
|
+
const warnings = [];
|
|
2248
|
+
const res = await runner.artifactForFile(entryFile, base);
|
|
2249
|
+
if (!res.ok) {
|
|
2250
|
+
dashboards.push({ name: base, errors: [res.error], warnings: [] });
|
|
2251
|
+
continue;
|
|
1833
2252
|
}
|
|
1834
|
-
if (!
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
);
|
|
2253
|
+
if (!res.artifact) continue;
|
|
2254
|
+
const art = res.artifact;
|
|
2255
|
+
if (seenNames.has(art.name)) {
|
|
2256
|
+
errors.push(`duplicate dashboard name "${art.name}" (also declared by ${seenNames.get(art.name)})`);
|
|
2257
|
+
} else {
|
|
2258
|
+
seenNames.set(art.name, file);
|
|
1838
2259
|
}
|
|
1839
|
-
if (
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
const
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
2260
|
+
if (art.dashboard_columns !== void 0 && (!Number.isInteger(art.dashboard_columns) || art.dashboard_columns < 1)) {
|
|
2261
|
+
errors.push(`dashboard_columns must be a positive integer (got ${JSON.stringify(art.dashboard_columns)})`);
|
|
2262
|
+
}
|
|
2263
|
+
const tiles = art.tiles ?? [];
|
|
2264
|
+
if (tiles.length === 0) errors.push(`\`## artifact\` declares no tiles`);
|
|
2265
|
+
for (const tile of tiles) {
|
|
2266
|
+
const v = await runner.validateIn(entryFile, tile, {});
|
|
2267
|
+
if (!v.ok) errors.push(`tile "${tile}": ${v.error}`);
|
|
2268
|
+
}
|
|
2269
|
+
const specs = await runner.dashboardGivens(entryFile, tiles);
|
|
1846
2270
|
if (specs.ok) {
|
|
1847
2271
|
for (const spec of specs.givens) {
|
|
1848
|
-
if (spec.tags?.suggest_query !== void 0) {
|
|
1849
|
-
errors.push(
|
|
1850
|
-
`given "${spec.name}": suggest_query is obsolete \u2014 declare # suggest { source=\u2026 dimension=\u2026 } or # suggest { query=\u2026 }`
|
|
1851
|
-
);
|
|
1852
|
-
}
|
|
1853
2272
|
const suggest = spec.suggest;
|
|
1854
2273
|
if (!suggest) continue;
|
|
1855
|
-
const
|
|
1856
|
-
if (
|
|
2274
|
+
const suggestBase = suggest.query ? `run: ${suggest.query}` : suggest.source && suggest.dimension ? `run: ${suggest.source} -> ${quoteField(suggest.dimension)}` : null;
|
|
2275
|
+
if (suggestBase === null) {
|
|
1857
2276
|
errors.push(
|
|
1858
|
-
`given "${spec.name}": suggest must be \`suggest { source=<source> dimension=<field> }\` or \`suggest { query=<
|
|
2277
|
+
`given "${spec.name}": suggest must be \`suggest { source=<source> dimension=<field> }\` or \`suggest { query=<query> [dimension=<field>] }\``
|
|
1859
2278
|
);
|
|
1860
2279
|
continue;
|
|
1861
2280
|
}
|
|
1862
|
-
const sv = await runner.
|
|
2281
|
+
const sv = await runner.validateTextIn(entryFile, suggestBase);
|
|
1863
2282
|
if (!sv.ok) errors.push(`given "${spec.name}": suggest does not compile \u2014 ${sv.error}`);
|
|
1864
2283
|
}
|
|
1865
2284
|
}
|
|
1866
|
-
const
|
|
1867
|
-
|
|
2285
|
+
for (const ext of ["jsx", "tsx"]) {
|
|
2286
|
+
const cp = join3(dir, `${base}.${ext}`);
|
|
2287
|
+
if (!existsSync3(cp)) continue;
|
|
2288
|
+
const source = readFileSync3(cp, "utf8");
|
|
1868
2289
|
try {
|
|
1869
|
-
await esbuild.transform(
|
|
2290
|
+
await esbuild.transform(source, { loader: ext, jsx: "automatic" });
|
|
1870
2291
|
} catch (e) {
|
|
1871
2292
|
const msg = e.errors?.map((x) => x.text).join("; ") ?? String(e);
|
|
1872
|
-
errors.push(
|
|
2293
|
+
errors.push(`${base}.${ext}: ${msg}`);
|
|
2294
|
+
continue;
|
|
2295
|
+
}
|
|
2296
|
+
for (const q of componentQueryLiterals(source)) {
|
|
2297
|
+
const v = await runner.validateIn(entryFile, q, {});
|
|
2298
|
+
if (!v.ok) errors.push(`${base}.${ext}: query "${q}" doesn't resolve \u2014 ${v.error}`);
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
dashboards.push({ name: art.name, errors, warnings });
|
|
2302
|
+
}
|
|
2303
|
+
const drills = await runner.drillTargets();
|
|
2304
|
+
if (drills.ok) {
|
|
2305
|
+
for (const target of drills.targets) {
|
|
2306
|
+
if (!seenNames.has(target)) {
|
|
2307
|
+
dashboards.push({
|
|
2308
|
+
name: `drill \u2192 ${target}`,
|
|
2309
|
+
errors: [
|
|
2310
|
+
`# drill { to=[${target}] } targets no dashboard \u2014 add dashboards/${target}.malloy, or fix the slug to a real dashboard`
|
|
2311
|
+
],
|
|
2312
|
+
warnings: []
|
|
2313
|
+
});
|
|
1873
2314
|
}
|
|
1874
2315
|
}
|
|
1875
|
-
dashboards.push({ name: artifact.name, errors });
|
|
1876
2316
|
}
|
|
1877
2317
|
return { ok: dashboards.every((d) => d.errors.length === 0), dashboards };
|
|
1878
2318
|
}
|
|
1879
2319
|
function printLintReport(report) {
|
|
1880
2320
|
for (const d of report.dashboards) {
|
|
1881
|
-
|
|
2321
|
+
const hasErr = d.errors.length > 0;
|
|
2322
|
+
const hasWarn = d.warnings.length > 0;
|
|
2323
|
+
if (!hasErr && !hasWarn) {
|
|
1882
2324
|
console.log(` \u2713 ${d.name}`);
|
|
1883
|
-
|
|
1884
|
-
console.log(` \u2717 ${d.name}`);
|
|
1885
|
-
for (const e of d.errors) console.log(` ${e}`);
|
|
2325
|
+
continue;
|
|
1886
2326
|
}
|
|
2327
|
+
console.log(` ${hasErr ? "\u2717" : "\u26A0"} ${d.name}`);
|
|
2328
|
+
for (const e of d.errors) console.log(` ${e}`);
|
|
2329
|
+
for (const w of d.warnings) console.log(` warning: ${w}`);
|
|
1887
2330
|
}
|
|
1888
2331
|
}
|
|
1889
2332
|
|
|
@@ -2167,271 +2610,12 @@ function attachSurface(server, surface, opts = {}) {
|
|
|
2167
2610
|
}
|
|
2168
2611
|
}
|
|
2169
2612
|
|
|
2170
|
-
// src/dashboard-guidance.ts
|
|
2171
|
-
var DASHBOARD_GUIDANCE = `
|
|
2172
|
-
|
|
2173
|
-
# Authoring dashboards
|
|
2174
|
-
|
|
2175
|
-
A dashboard is DECLARED IN THE MODEL \u2014 there is no manifest file and, for the
|
|
2176
|
-
basic case, no JavaScript at all. Preview with \`malloyyo dashboard dev\`; check
|
|
2177
|
-
with \`malloyyo lint\`.
|
|
2178
|
-
|
|
2179
|
-
## Make dashboards discoverable: the entry model
|
|
2180
|
-
|
|
2181
|
-
**The entry is \`index.malloy\`.** \`dashboard dev\`, \`lint\`, and the hosted
|
|
2182
|
-
server only see what that file EXPORTS. Three things must all be surfaced
|
|
2183
|
-
(imported AND exported) through it, or the feature looks broken:
|
|
2184
|
-
|
|
2185
|
-
1. **Whatever holds each \`# artifact\` tag.** A tag on a \`view:\` rides along
|
|
2186
|
-
with its SOURCE (export the source \u2014 you can't export a view on its own); a
|
|
2187
|
-
tag on a top-level \`query:\` needs that query exported. Not surfaced \u2192
|
|
2188
|
-
\`dashboard dev\` says "No dashboards declared" and \`lint\` says "no dashboards
|
|
2189
|
-
to lint", even though the model compiles clean.
|
|
2190
|
-
2. **Every filter given the dashboards reference.** An unexported given
|
|
2191
|
-
silently resolves to its declaration default \u2014 the control still renders
|
|
2192
|
-
but CAN'T CHANGE THE QUERY (the filter looks inert).
|
|
2193
|
-
3. **Whatever backs each \`suggest\`** \u2014 the named query (\`suggest {query=\u2026}\`)
|
|
2194
|
-
or source (\`suggest {source=\u2026}\`). Suggestions run against the entry model;
|
|
2195
|
-
an unexported one fails lint with "Reference to undefined object".
|
|
2196
|
-
|
|
2197
|
-
\`\`\`malloy
|
|
2198
|
-
##! experimental.givens
|
|
2199
|
-
import {
|
|
2200
|
-
order_items, // the source \u2014 carries its # artifact views
|
|
2201
|
-
BRAND, CATEGORY, PERIOD, // the filter givens
|
|
2202
|
-
brand_suggest // backs a suggest {query=\u2026}
|
|
2203
|
-
} from 'ecommerce.malloy'
|
|
2204
|
-
export { order_items, BRAND, CATEGORY, PERIOD, brand_suggest }
|
|
2205
|
-
\`\`\`
|
|
2206
|
-
|
|
2207
|
-
Exporting the source is often the whole job: its \`# artifact\` views, its
|
|
2208
|
-
dimensions (for \`suggest {source=\u2026}\`), and its measures all travel with it.
|
|
2209
|
-
|
|
2210
|
-
Prefer \`suggest { query=<named-query> \u2026 }\` over \`suggest { source=\u2026 }\` for
|
|
2211
|
-
anything beyond a throwaway: you export one small governed query instead of a
|
|
2212
|
-
whole base source.
|
|
2213
|
-
|
|
2214
|
-
## The model is the whole contract
|
|
2215
|
-
|
|
2216
|
-
**1. Tag a \`view:\` inside a source** with \`# artifact\` to declare a dashboard
|
|
2217
|
-
(the idiomatic form \u2014 a view is reusable, nestable, and explorable through the
|
|
2218
|
-
normal \`query\`/\`describe_source\` surface). For the common overview shape
|
|
2219
|
-
(top-level aggregates + nests), ALSO tag it \`# dashboard\` so the result
|
|
2220
|
-
renders as KPI tiles + a card grid instead of one flat table \u2014 they're
|
|
2221
|
-
partners: \`# artifact\` declares the dashboard, \`# dashboard\` is the renderer
|
|
2222
|
-
tag that draws it like one:
|
|
2223
|
-
|
|
2224
|
-
\`\`\`malloy
|
|
2225
|
-
source: order_items is \u2026 extend {
|
|
2226
|
-
#" Business health at a glance \u2014 sales, margin, orders.
|
|
2227
|
-
# artifact { title="Business Overview" } dashboard
|
|
2228
|
-
view: overview_dashboard is {
|
|
2229
|
-
where:
|
|
2230
|
-
inventory_items.product_brand ~ $BRAND, // multi-filter where: is
|
|
2231
|
-
inventory_items.product_category ~ $CATEGORY, // COMMA separated
|
|
2232
|
-
created_at ~ $PERIOD
|
|
2233
|
-
aggregate: total_sales, total_gross_margin, order_count
|
|
2234
|
-
nest:
|
|
2235
|
-
# line_chart
|
|
2236
|
-
sales_trend is by_month
|
|
2237
|
-
top_brands
|
|
2238
|
-
# shape_map
|
|
2239
|
-
sales_by_state
|
|
2240
|
-
}
|
|
2241
|
-
}
|
|
2242
|
-
\`\`\`
|
|
2243
|
-
|
|
2244
|
-
That's a complete dashboard: the runtime auto-renders a title (the tag's
|
|
2245
|
-
\`title\`, else the \`#"\` doc comment), a control for every given the view
|
|
2246
|
-
references, and the result panel. It runs as \`run: <source> -> <view>\` (here
|
|
2247
|
-
\`order_items -> overview_dashboard\`). \`name="slug"\` overrides the
|
|
2248
|
-
URL/directory slug (default: the view name). Note the \`where:\` clauses
|
|
2249
|
-
applying givens are COMMA-separated \u2014 newline-separated conditions do not
|
|
2250
|
-
parse.
|
|
2251
|
-
|
|
2252
|
-
Tagging a **top-level \`query:\`** still works and behaves identically (it runs
|
|
2253
|
-
as \`run: <name>\`) \u2014 reach for it only when the dashboard query doesn't belong
|
|
2254
|
-
to any one source.
|
|
2255
|
-
|
|
2256
|
-
Two dashboards can share a given but start on different values \u2014 a \`givens\`
|
|
2257
|
-
block in the tag sets PER-DASHBOARD defaults (given values, i.e. filter
|
|
2258
|
-
expressions; URL params still win):
|
|
2259
|
-
|
|
2260
|
-
\`\`\`malloy
|
|
2261
|
-
# artifact { name="manufacturer" title="Manufacturer Recall Profile" givens { MANUFACTURER="Ford Motor Company" } }
|
|
2262
|
-
\`\`\`
|
|
2263
|
-
|
|
2264
|
-
This replaces the "declare the given's default per dashboard" role the old
|
|
2265
|
-
manifests had: declare the given once with a neutral default (often \`f''\` =
|
|
2266
|
-
no filter), and let each tag pick its landing state.
|
|
2267
|
-
|
|
2268
|
-
**2. Declare the filters as \`filter<T>\` givens** \u2014 never raw strings/numbers.
|
|
2269
|
-
A \`filter<string>\` value accepts one value ('NY'), alternatives ('NY, CA'),
|
|
2270
|
-
wildcards ('Ann%'), negation ('-NY'); a \`filter<number>\` accepts ranges
|
|
2271
|
-
('[1910 to 1930]') and comparisons ('> 200'); a \`filter<timestamp>\` /
|
|
2272
|
-
\`filter<date>\` accepts relative windows ('7 days' = the last 7 days, 'today',
|
|
2273
|
-
'last month') and literal ranges ('2026-01-01 to 2026-07-01' \u2014 NO \`@\` in
|
|
2274
|
-
filter literals). Apply with \`~\`; \`f''\` = empty = no filter (the natural
|
|
2275
|
-
"All"/"all time" \u2014 just \`col ~ $X\`, no \`$X = '' or \u2026\` dance):
|
|
2276
|
-
|
|
2277
|
-
\`\`\`malloy
|
|
2278
|
-
##! experimental { givens }
|
|
2279
|
-
given:
|
|
2280
|
-
# label="State" control=select suggest { source=baby_names dimension=state }
|
|
2281
|
-
STATE :: filter<string> is f'NY'
|
|
2282
|
-
# label="Brand" suggest { query=brand_suggest dimension=product_brand }
|
|
2283
|
-
BRAND :: filter<string> is f''
|
|
2284
|
-
# label="Years" range_min=1910 range_max=2025
|
|
2285
|
-
YEAR_RANGE :: filter<number> is f'[1910 to 1930]'
|
|
2286
|
-
# label="Time period"
|
|
2287
|
-
PERIOD :: filter<timestamp> is f''
|
|
2288
|
-
# label="Include rare names"
|
|
2289
|
-
INCLUDE_RARE :: boolean is false
|
|
2290
|
-
\`\`\`
|
|
2291
|
-
|
|
2292
|
-
Tags on the declaration drive the control (tag syntax is \`key="value"\` \u2014
|
|
2293
|
-
equals, not colon):
|
|
2294
|
-
- \`label\` \u2014 control caption (defaults to the given's name)
|
|
2295
|
-
- \`suggest { \u2026 }\` \u2014 where the control's options come from. NO Malloy code in
|
|
2296
|
-
strings \u2014 just names:
|
|
2297
|
-
- \`suggest { query=brand_suggest dimension=product_brand }\` \u2014 the FIRST
|
|
2298
|
-
COLUMN of a named query (declare the query in the model \u2014 governed,
|
|
2299
|
-
reviewable, and only that query needs exporting). PREFER THIS FORM.
|
|
2300
|
-
- \`suggest { source=baby_names dimension=state }\` \u2014 the DISTINCT VALUES of
|
|
2301
|
-
a dimension on a source (the whole source must be exported)
|
|
2302
|
-
A \`dimension\` (in either form) is what enables SERVER-SIDE TYPEAHEAD: the
|
|
2303
|
-
runtime refines the base query with what the user has typed
|
|
2304
|
-
(\`\u2026 + { where: lower(field) ~ f'll%'; limit: 50 }\`, case-insensitive,
|
|
2305
|
-
escaped). Without a dimension the fetched list is filtered client-side.
|
|
2306
|
-
Runs as a restricted query; lint checks the declaration compiles.
|
|
2307
|
-
|
|
2308
|
-
**RELATED (faceted) filters** \u2014 query-form only: a suggest query may
|
|
2309
|
-
reference the OTHER givens, and the runtime runs it with the dashboard's
|
|
2310
|
-
current values (the suggested given itself is excluded, so the list never
|
|
2311
|
-
collapses to the current pick). Brand suggestions narrow when Category is
|
|
2312
|
-
set:
|
|
2313
|
-
|
|
2314
|
-
\`\`\`malloy
|
|
2315
|
-
query: brand_suggest is inventory_items -> product_brand + {
|
|
2316
|
-
where:
|
|
2317
|
-
product_category ~ $CATEGORY, // NOT product_brand ~ $BRAND
|
|
2318
|
-
product_department ~ $DEPARTMENT
|
|
2319
|
-
limit: 500
|
|
2320
|
-
}
|
|
2321
|
-
\`\`\`
|
|
2322
|
-
|
|
2323
|
-
Declare one \`*_suggest\` per filter, each referencing the others; \`f''\`
|
|
2324
|
-
defaults mean unset filters don't constrain. \`source=\` suggests can't do
|
|
2325
|
-
this (no place for a \`where:\`) \u2014 another reason to prefer \`query=\`.
|
|
2326
|
-
- \`control=select\` \u2014 a fixed dropdown instead of a typeahead search box
|
|
2327
|
-
- \`range_min\` / \`range_max\` \u2014 bounds; makes a filter<number> given a
|
|
2328
|
-
dual-thumb range slider
|
|
2329
|
-
- anything else passes through in \`spec.tags\` for custom components
|
|
2330
|
-
|
|
2331
|
-
Control picked from the declaration automatically: numeric range tags \u2192
|
|
2332
|
-
dual-thumb slider; \`filter<timestamp|timestamptz|date>\` \u2192 the TimeRange
|
|
2333
|
-
widget (relative presets: Today / Last 7 days / Last 30 days / \u2026 plus a
|
|
2334
|
-
"Custom range\u2026" from/to date picker); suggest + control=select \u2192 dropdown;
|
|
2335
|
-
boolean \u2192 checkbox; anything else \u2192 committing search box with typeahead.
|
|
2336
|
-
The suggest-driven options are DATA VALUES only \u2014 options that aren't column
|
|
2337
|
-
values (custom time presets, threshold buckets) need a custom component
|
|
2338
|
-
(below) with explicit \`{value, text}\` options where value is a filter
|
|
2339
|
-
expression built with \`filters.*\`.
|
|
2340
|
-
|
|
2341
|
-
## Custom components (optional): ./dashboards/<slug>/Dashboard.tsx
|
|
2342
|
-
|
|
2343
|
-
When the default UI isn't enough, add ONE file. It composes the runtime's
|
|
2344
|
-
widgets/hooks with your own React \u2014 you own layout, copy, and theming; the
|
|
2345
|
-
model still owns every query and filter:
|
|
2346
|
-
|
|
2347
|
-
\`\`\`tsx
|
|
2348
|
-
import React from "react";
|
|
2349
|
-
import { Controls, Given, Search, Select, TimeRange, Panel, filters, useGiven } from "@malloyyo/dashboard";
|
|
2350
|
-
|
|
2351
|
-
export default function Dashboard({ dashboard, givens }) {
|
|
2352
|
-
return (
|
|
2353
|
-
<div style={{ maxWidth: 860, margin: "0 auto", padding: 24 }}>
|
|
2354
|
-
<h1>{dashboard.title}</h1>
|
|
2355
|
-
<Controls>
|
|
2356
|
-
<Given name="STATE" /> {/* picks the control from the declaration */}
|
|
2357
|
-
<Search given="NAME" /> {/* committing input + typeahead + validation */}
|
|
2358
|
-
<TimeRange given="PERIOD" presets={[
|
|
2359
|
-
{ value: "", text: "All time" },
|
|
2360
|
-
{ value: filters.lastN(1, "day"), text: "Last day" },
|
|
2361
|
-
{ value: filters.lastN(1, "week"), text: "Last week" },
|
|
2362
|
-
{ value: filters.lastN(1, "month"), text: "Last month" },
|
|
2363
|
-
]} /> {/* "Custom range\u2026" is always appended */}
|
|
2364
|
-
<Select given="MIN_SAMPLE"
|
|
2365
|
-
options={[10, 200, 1000].map(n => ({ value: filters.greaterThan(n), text: \`> \${n}\` }))} />
|
|
2366
|
-
</Controls>
|
|
2367
|
-
<Panel givens={givens} /> {/* the tagged query, Malloy renderer */}
|
|
2368
|
-
<Panel malloy="baby_names -> births_by_decade" givens={givens} /> {/* restricted text */}
|
|
2369
|
-
</div>
|
|
2370
|
-
);
|
|
2371
|
-
}
|
|
2372
|
-
\`\`\`
|
|
2373
|
-
|
|
2374
|
-
From \`@malloyyo/dashboard\` (also handed to the component as props):
|
|
2375
|
-
- **Widgets** (headless-ish; restyle via className/style or CSS vars
|
|
2376
|
-
\`--dash-fg/-muted/-border/-accent/-control-bg/-controls-bg\`):
|
|
2377
|
-
\`<Controls/>\` (all givens, or compose children), \`<Given name/>\`,
|
|
2378
|
-
\`<Select given [options]/>\`, \`<Search given/>\`, \`<Range given [min max]/>\`,
|
|
2379
|
-
\`<TimeRange given [presets]/>\` (temporal presets + custom range),
|
|
2380
|
-
\`<Checkbox given/>\` (bound to a boolean given)
|
|
2381
|
-
- **Hooks**: \`useGiven(name)\` \u2192 {value, set, spec};
|
|
2382
|
-
\`useOptions(name, typed?)\` \u2192 {options, loading} (typeahead);
|
|
2383
|
-
\`useQuery({query|malloy, givens})\` \u2192 {rows, loading, error} \u2014 plain rows
|
|
2384
|
-
for your own visuals
|
|
2385
|
-
- **Helpers**: \`filters.oneOf/contains/between/atLeast/\u2026\` build
|
|
2386
|
-
filter-expression strings with correct escaping; temporal:
|
|
2387
|
-
\`filters.lastN(7, "day")\` \u2192 \`'7 days'\`, \`filters.dateRange("2026-01-01",
|
|
2388
|
-
"2026-07-01")\`, \`filters.afterDate/beforeDate\`; read back with
|
|
2389
|
-
\`filters.values/numberRange/threshold/inLast/temporalRange\`;
|
|
2390
|
-
\`filters.isValid(type, src)\` checks typed input.
|
|
2391
|
-
Never hand-concatenate a filter string.
|
|
2392
|
-
**Escaping rule for custom controls:** a filter given's value is an
|
|
2393
|
-
EXPRESSION, so committing a raw column value is wrong the moment it contains
|
|
2394
|
-
a comma/percent/dash ('Tesla, Inc.' parses as two alternatives and matches
|
|
2395
|
-
nothing). Commit \`filters.oneOf(value)\` (exact) or
|
|
2396
|
-
\`filters.contains(term)\` (substring), and unwrap for display with
|
|
2397
|
-
\`filters.values(src)\`. The stock \`<Select/>\` does this automatically;
|
|
2398
|
-
\`<Search/>\` deliberately commits raw text (its input IS a filter
|
|
2399
|
-
expression).
|
|
2400
|
-
- \`<Panel/>\` and \`runData(text, givens)\` \u2014 named queries are the primary
|
|
2401
|
-
form; arbitrary Malloy runs as a RESTRICTED query (no import / given: /
|
|
2402
|
-
connection.* / raw SQL / ##! flags \u2014 the model's published surface only).
|
|
2403
|
-
|
|
2404
|
-
## Rules
|
|
2405
|
-
- Declare data in the model: givens are \`filter<T>\`, options come from
|
|
2406
|
-
\`# suggest {\u2026}\` declarations, dashboards are \`# artifact\` tags. If a query or given you
|
|
2407
|
-
need is missing, add it to the \`.malloy\` file first (check with
|
|
2408
|
-
\`describe_source\`).
|
|
2409
|
-
- Surface everything through the entry model (see the top section).
|
|
2410
|
-
- Only React + \`@malloyyo/dashboard\` are importable. No other imports, no
|
|
2411
|
-
network \u2014 the runtime sandboxes the component.
|
|
2412
|
-
- Interactivity = setting given values (filter-expression strings), not
|
|
2413
|
-
rewriting query text per interaction.
|
|
2414
|
-
|
|
2415
|
-
## Preview & validate
|
|
2416
|
-
\`malloyyo dashboard dev\` \u2192 open the printed URL. Edits to \`.malloy\` (tags,
|
|
2417
|
-
givens, queries) and \`Dashboard.tsx\` hot-reload. \`malloyyo lint\` validates
|
|
2418
|
-
the tagged queries, given \`suggest\` declarations, and any Dashboard.tsx \u2014
|
|
2419
|
-
but only for dashboards REACHABLE FROM THE ENTRY: "no dashboards to lint"
|
|
2420
|
-
usually means the \`# artifact\` queries aren't exported through
|
|
2421
|
-
\`index.malloy\`, not that they don't exist.
|
|
2422
|
-
|
|
2423
|
-
Validation loop that works well: the local \`malloyyo mcp\` server hot-reloads
|
|
2424
|
-
working-directory edits \u2014 \`query(execute:false)\` to compile-check,
|
|
2425
|
-
\`execute:true\` to run. A \`# artifact\` view runs as
|
|
2426
|
-
\`run: <source> -> <view>\`; a top-level \`# artifact\` query runs as
|
|
2427
|
-
\`run: <name>\`. Either is only visible once surfaced through the entry (export
|
|
2428
|
-
the source for a view, the query for a top-level query). Don't validate local
|
|
2429
|
-
edits against a hosted/claude.ai connector \u2014 that serves the PUBLISHED model,
|
|
2430
|
-
which is stale until \`malloyyo publish\`.
|
|
2431
|
-
`;
|
|
2432
|
-
|
|
2433
2613
|
// src/mcp.ts
|
|
2434
2614
|
var ENTRY2 = "index.malloy";
|
|
2615
|
+
var MODE_STUB = {
|
|
2616
|
+
develop: "\n\n# DEVELOP (author) mode\nYou can author this Malloy model \u2014 compile / compile_file / prettify / query the files in this project (any .malloy path; no index.malloy required). For how-to, call yo_help \u2014 start with `dashboards/authoring`, then `dashboards/grid-layout`, `dashboards/vega-charts`, and the `develop/*` topics. To preview exactly what claude.ai web will see, relaunch as `malloyyo test`.",
|
|
2617
|
+
explore: "\n\n# TEST (explore) mode\nThis mirrors the claude.ai web experience \u2014 the same tools a hosted consumer gets, over this project's published entry model (index.malloy). Call yo_help for guidance. To author the model (compile / edit / dashboards), relaunch as `malloyyo author`."
|
|
2618
|
+
};
|
|
2435
2619
|
function defaultConfig(rootUrl) {
|
|
2436
2620
|
return new MalloyConfig2({ includeDefaultConnections: true }, {
|
|
2437
2621
|
rootDirectory: rootUrl.toString()
|
|
@@ -2525,16 +2709,21 @@ function makeExploreHost(root, currentConfig) {
|
|
|
2525
2709
|
}
|
|
2526
2710
|
};
|
|
2527
2711
|
}
|
|
2712
|
+
function makeDevelopHost(root, currentConfig) {
|
|
2713
|
+
return { withRuntime: makeWithRuntime(root, currentConfig) };
|
|
2714
|
+
}
|
|
2528
2715
|
async function serveMcp(opts) {
|
|
2529
2716
|
await import("@malloydata/malloy-connections");
|
|
2530
2717
|
const root = path3.resolve(opts.root ?? process.cwd());
|
|
2718
|
+
const mode = opts.mode ?? "explore";
|
|
2531
2719
|
const currentConfig = makeConfigSource(root);
|
|
2532
|
-
const surface = exploreSurface(makeExploreHost(root, currentConfig));
|
|
2720
|
+
const surface = mode === "develop" ? developSurface(makeDevelopHost(root, currentConfig)) : exploreSurface(makeExploreHost(root, currentConfig));
|
|
2533
2721
|
const instanceName = process.env.INSTANCE_NAME || "Malloyyo";
|
|
2722
|
+
const serverName = mode === "develop" ? "malloyyo-develop" : "malloyyo-explore";
|
|
2534
2723
|
const server = new McpServer(
|
|
2535
|
-
{ name:
|
|
2724
|
+
{ name: serverName, version: opts.version },
|
|
2536
2725
|
{
|
|
2537
|
-
instructions: renderInstructions(surface.instructions, instanceName) +
|
|
2726
|
+
instructions: renderInstructions(surface.instructions, instanceName) + MODE_STUB[mode],
|
|
2538
2727
|
capabilities: { tools: {}, prompts: {}, resources: {} }
|
|
2539
2728
|
}
|
|
2540
2729
|
);
|
|
@@ -2588,12 +2777,20 @@ function resolveRuntimeDir() {
|
|
|
2588
2777
|
}
|
|
2589
2778
|
var resolveFrameEntry = () => path4.join(resolveRuntimeDir(), "..", "frame-entry.tsx");
|
|
2590
2779
|
async function discoverDashboards(root, runner) {
|
|
2591
|
-
const
|
|
2592
|
-
if (!
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2780
|
+
const dir = path4.join(root, "dashboards");
|
|
2781
|
+
if (!fs3.existsSync(dir)) return [];
|
|
2782
|
+
const files = fs3.readdirSync(dir).filter((f) => f.endsWith(".malloy")).sort();
|
|
2783
|
+
const dashboards = [];
|
|
2784
|
+
for (const file of files) {
|
|
2785
|
+
const base = file.slice(0, -".malloy".length);
|
|
2786
|
+
const entryFile = path4.join("dashboards", file);
|
|
2787
|
+
const res = await runner.artifactForFile(entryFile, base);
|
|
2788
|
+
if (!res.ok) throw new Error(`dashboard ${file}: ${res.error}`);
|
|
2789
|
+
if (!res.artifact) continue;
|
|
2790
|
+
const component = ["jsx", "tsx"].map((ext) => path4.join(dir, `${base}.${ext}`)).find((p) => fs3.existsSync(p));
|
|
2791
|
+
dashboards.push({ ...res.artifact, name: res.artifact.name || base, entryFile, tsxPath: component });
|
|
2792
|
+
}
|
|
2793
|
+
return dashboards;
|
|
2597
2794
|
}
|
|
2598
2795
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2599
2796
|
function makeBundler() {
|
|
@@ -2657,7 +2854,9 @@ function parentShell(dash, frameBase, all, initialGivens) {
|
|
|
2657
2854
|
return `<a href="/?d=${encodeURIComponent(x.name)}" style="padding:4px 10px;border-radius:6px;text-decoration:none;${on ? "background:#1a1a1a;color:#fff" : "color:#333"}">${esc(x.title || x.name)}</a>`;
|
|
2658
2855
|
}).join("") + `</nav>` : "";
|
|
2659
2856
|
return html(
|
|
2660
|
-
`<div style="display:flex;flex-direction:column;height:100vh">` + nav +
|
|
2857
|
+
`<div style="display:flex;flex-direction:column;height:100vh">` + nav + // allow-popups(+escape-sandbox): let a # link mark open its target in a
|
|
2858
|
+
// normal new tab on click instead of being blocked by the sandbox.
|
|
2859
|
+
`<iframe id="f" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" src="${frameBase}/frame?d=${encodeURIComponent(dash.name)}${givensQs}" style="border:0;flex:1;width:100%"></iframe></div><script>
|
|
2661
2860
|
const f=document.getElementById('f');
|
|
2662
2861
|
try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}
|
|
2663
2862
|
window.addEventListener('message',async(e)=>{
|
|
@@ -2666,15 +2865,22 @@ window.addEventListener('message',async(e)=>{
|
|
|
2666
2865
|
if(m&&m.type==='givens'){
|
|
2667
2866
|
const u=new URL(location.href); u.search='';
|
|
2668
2867
|
u.searchParams.set('d',${d});
|
|
2669
|
-
for(const [k,v] of Object.entries(m.givens)) u.searchParams.set(k,String(v));
|
|
2868
|
+
for(const [k,v] of Object.entries(m.givens)) if(v!=null&&String(v)!=='') u.searchParams.set('$'+k,String(v));
|
|
2670
2869
|
history.replaceState(null,'',u.pathname+u.search);
|
|
2671
2870
|
return;
|
|
2672
2871
|
}
|
|
2872
|
+
if(m&&m.type==='navigate'&&typeof m.dashboard==='string'){
|
|
2873
|
+
const u=new URL(location.href); u.search='';
|
|
2874
|
+
u.searchParams.set('d',m.dashboard);
|
|
2875
|
+
for(const [k,v] of Object.entries(m.givens||{})) if(v!=null&&String(v)!=='') u.searchParams.set('$'+k,String(v));
|
|
2876
|
+
location.href=u.pathname+u.search;
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2673
2879
|
if(!m||m.type!=='run')return;
|
|
2674
2880
|
let out;
|
|
2675
2881
|
try{
|
|
2676
2882
|
const res=await fetch('/api/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
2677
|
-
body:JSON.stringify({d:${d},query:m.query,malloy:m.malloy,givens:m.givens})});
|
|
2883
|
+
body:JSON.stringify({d:${d},query:m.query,malloy:m.malloy,givens:m.givens,dashboard:m.dashboard})});
|
|
2678
2884
|
out=await res.json();
|
|
2679
2885
|
}catch(err){ out={ok:false,problems:[{message:String(err)}]}; }
|
|
2680
2886
|
f.contentWindow.postMessage({type:'result',id:m.id,...out},${fb});
|
|
@@ -2694,7 +2900,10 @@ function frameDoc(dash, givenSpecs, initialGivens) {
|
|
|
2694
2900
|
query: dash.query,
|
|
2695
2901
|
title: dash.title,
|
|
2696
2902
|
description: dash.description,
|
|
2697
|
-
|
|
2903
|
+
tiles: dash.tiles,
|
|
2904
|
+
dashboard_columns: dash.dashboard_columns,
|
|
2905
|
+
givens: dash.givens,
|
|
2906
|
+
autorun: dash.autorun
|
|
2698
2907
|
};
|
|
2699
2908
|
return html(
|
|
2700
2909
|
`<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
|
|
@@ -2758,7 +2967,7 @@ async function serveDashboard(opts) {
|
|
|
2758
2967
|
if (onFramePort) {
|
|
2759
2968
|
if (url4.pathname === "/frame") {
|
|
2760
2969
|
const dash = pick(url4);
|
|
2761
|
-
const specs = await runner.givensForQuery(dash.query);
|
|
2970
|
+
const specs = dash.tiles && dash.entryFile ? await runner.dashboardGivens(dash.entryFile, dash.tiles) : await runner.givensForQuery(dash.query);
|
|
2762
2971
|
if (!specs.ok) {
|
|
2763
2972
|
return send(
|
|
2764
2973
|
200,
|
|
@@ -2784,10 +2993,11 @@ async function serveDashboard(opts) {
|
|
|
2784
2993
|
return send(200, "text/html; charset=utf-8", parentShell(pick(url4), frameBase, dashboards, givensFromUrl(url4)));
|
|
2785
2994
|
}
|
|
2786
2995
|
if (url4.pathname === "/api/run" && req.method === "POST") {
|
|
2787
|
-
const { d, query, malloy, givens } = JSON.parse(await readBody(req));
|
|
2996
|
+
const { d, query, malloy, givens, dashboard } = JSON.parse(await readBody(req));
|
|
2788
2997
|
const dash = byName.get(d);
|
|
2789
2998
|
if (!dash) return send(404, "application/json", JSON.stringify({ ok: false, problems: [{ message: `no dashboard '${d}'` }] }));
|
|
2790
|
-
const
|
|
2999
|
+
const entry = dash.entryFile;
|
|
3000
|
+
const out = dashboard && dash.tiles && entry ? await runner.runDashboard(entry, dash.tiles, { columns: dash.dashboard_columns, givens: givens ?? {} }) : typeof malloy === "string" ? entry ? await runner.runTextIn(entry, malloy, givens ?? {}) : await runner.runText(malloy, givens ?? {}) : entry ? await runner.runIn(entry, String(query ?? ""), givens ?? {}) : await runner.run(String(query ?? dash.query), givens ?? {});
|
|
2791
3001
|
return send(200, "application/json", JSON.stringify(out));
|
|
2792
3002
|
}
|
|
2793
3003
|
send(404, "text/plain", "not found");
|
|
@@ -2812,8 +3022,126 @@ async function serveDashboard(opts) {
|
|
|
2812
3022
|
});
|
|
2813
3023
|
}
|
|
2814
3024
|
|
|
3025
|
+
// src/init.ts
|
|
3026
|
+
import fs4 from "node:fs";
|
|
3027
|
+
import path5 from "node:path";
|
|
3028
|
+
var AUTHOR_MCP = {
|
|
3029
|
+
mcpServers: {
|
|
3030
|
+
// No -C: the server roots at the launch cwd (the project dir), so this file
|
|
3031
|
+
// is portable/committable — no absolute paths baked in.
|
|
3032
|
+
malloyyo_author: { command: "malloyyo", args: ["mcp", "--develop"] }
|
|
3033
|
+
}
|
|
3034
|
+
};
|
|
3035
|
+
function exportableNames(src) {
|
|
3036
|
+
const names = /* @__PURE__ */ new Set();
|
|
3037
|
+
const code = src.replace(/\/\/[^\n]*/g, "");
|
|
3038
|
+
for (const m of code.matchAll(/^\s*(?:source|query)\s*:\s*([A-Za-z_]\w*)\s+is\b/gm)) {
|
|
3039
|
+
names.add(m[1]);
|
|
3040
|
+
}
|
|
3041
|
+
for (const m of code.matchAll(/^\s*([A-Za-z_]\w*)\s*::/gm)) {
|
|
3042
|
+
names.add(m[1]);
|
|
3043
|
+
}
|
|
3044
|
+
return [...names];
|
|
3045
|
+
}
|
|
3046
|
+
function scaffoldIndex(root) {
|
|
3047
|
+
const indexPath = path5.join(root, "index.malloy");
|
|
3048
|
+
if (fs4.existsSync(indexPath)) {
|
|
3049
|
+
return { wrote: false, note: "index.malloy already exists \u2014 left as-is" };
|
|
3050
|
+
}
|
|
3051
|
+
const models = fs4.readdirSync(root).filter((f) => f.endsWith(".malloy") && f !== "index.malloy").sort();
|
|
3052
|
+
if (models.length === 0) {
|
|
3053
|
+
return { wrote: false, note: "no .malloy files found \u2014 skipped index.malloy" };
|
|
3054
|
+
}
|
|
3055
|
+
const blocks = [
|
|
3056
|
+
"// Generated by `malloyyo init` \u2014 the entry model. `dashboard dev`, `lint`,",
|
|
3057
|
+
"// `malloyyo test`, and the hosted app only see what THIS file exports.",
|
|
3058
|
+
"// Review the re-exports below (add givens / suggest queries your dashboards",
|
|
3059
|
+
"// reference), then validate with `malloyyo mcp --develop` or `dashboard dev`.",
|
|
3060
|
+
""
|
|
3061
|
+
];
|
|
3062
|
+
let anyNames = false;
|
|
3063
|
+
for (const file of models) {
|
|
3064
|
+
const names = exportableNames(fs4.readFileSync(path5.join(root, file), "utf8"));
|
|
3065
|
+
if (names.length === 0) {
|
|
3066
|
+
blocks.push(`// ${file}: no top-level source/query/given detected \u2014 add exports by hand`);
|
|
3067
|
+
continue;
|
|
3068
|
+
}
|
|
3069
|
+
anyNames = true;
|
|
3070
|
+
const list = names.join(", ");
|
|
3071
|
+
blocks.push(`import { ${list} } from './${file}'`);
|
|
3072
|
+
blocks.push(`export { ${list} }`);
|
|
3073
|
+
blocks.push("");
|
|
3074
|
+
}
|
|
3075
|
+
fs4.writeFileSync(indexPath, blocks.join("\n") + "\n");
|
|
3076
|
+
return {
|
|
3077
|
+
wrote: true,
|
|
3078
|
+
note: anyNames ? `wrote index.malloy re-exporting ${models.length} model file(s) \u2014 REVIEW it` : "wrote index.malloy skeleton \u2014 no exports detected, fill them in by hand"
|
|
3079
|
+
};
|
|
3080
|
+
}
|
|
3081
|
+
async function initCmd(dir) {
|
|
3082
|
+
const root = path5.resolve(dir);
|
|
3083
|
+
if (!fs4.existsSync(root) || !fs4.statSync(root).isDirectory()) {
|
|
3084
|
+
throw new Error(`not a directory: ${root}`);
|
|
3085
|
+
}
|
|
3086
|
+
const mcpPath = path5.join(root, ".mcp.json");
|
|
3087
|
+
if (fs4.existsSync(mcpPath)) {
|
|
3088
|
+
console.log(`\u2022 .mcp.json exists \u2014 leaving it. For author-by-default it should be:`);
|
|
3089
|
+
console.log(` ${JSON.stringify(AUTHOR_MCP.mcpServers.malloyyo_author)}`);
|
|
3090
|
+
console.log(` (server key "malloyyo_author", command "malloyyo mcp --develop").`);
|
|
3091
|
+
} else {
|
|
3092
|
+
fs4.writeFileSync(mcpPath, JSON.stringify(AUTHOR_MCP, null, 2) + "\n");
|
|
3093
|
+
console.log(`\u2713 wrote .mcp.json \u2014 \`cd ${dir} && claude\` now opens in AUTHOR mode`);
|
|
3094
|
+
}
|
|
3095
|
+
const idx = scaffoldIndex(root);
|
|
3096
|
+
console.log(`${idx.wrote ? "\u2713" : "\u2022"} ${idx.note}`);
|
|
3097
|
+
console.log("");
|
|
3098
|
+
console.log("Next:");
|
|
3099
|
+
console.log(" claude # author mode (mcp__malloyyo_author__* tools)");
|
|
3100
|
+
console.log(" malloyyo test # preview exactly what claude.ai web will see");
|
|
3101
|
+
console.log(" malloyyo dashboard dev # see dashboards render in a browser");
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
// src/launch.ts
|
|
3105
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
3106
|
+
import fs5 from "node:fs";
|
|
3107
|
+
import os from "node:os";
|
|
3108
|
+
import path6 from "node:path";
|
|
3109
|
+
var SURFACE_FLAG = { author: "--develop", test: "--explore" };
|
|
3110
|
+
var SERVER_KEY = { author: "malloyyo_author", test: "malloyyo_test" };
|
|
3111
|
+
async function launchCmd(mode, opts) {
|
|
3112
|
+
const root = path6.resolve(opts.root ?? process.cwd());
|
|
3113
|
+
const tmpDir = fs5.mkdtempSync(path6.join(os.tmpdir(), "malloyyo-launch-"));
|
|
3114
|
+
const cfgPath = path6.join(tmpDir, "mcp.json");
|
|
3115
|
+
const cfg = {
|
|
3116
|
+
mcpServers: {
|
|
3117
|
+
// Absolute -C: an ephemeral config, so pinning the root is robust.
|
|
3118
|
+
[SERVER_KEY[mode]]: { command: "malloyyo", args: ["mcp", SURFACE_FLAG[mode], "-C", root] }
|
|
3119
|
+
}
|
|
3120
|
+
};
|
|
3121
|
+
fs5.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
3122
|
+
const label = mode === "author" ? "AUTHOR (compile/edit)" : "TEST (claude.ai web preview)";
|
|
3123
|
+
process.stderr.write(`\u25B6 launching Claude in ${label} mode over ${root}
|
|
3124
|
+
`);
|
|
3125
|
+
const child = spawn2("claude", ["--strict-mcp-config", "--mcp-config", cfgPath], {
|
|
3126
|
+
stdio: "inherit",
|
|
3127
|
+
cwd: root
|
|
3128
|
+
});
|
|
3129
|
+
await new Promise((resolve3) => {
|
|
3130
|
+
child.on("error", (e) => {
|
|
3131
|
+
process.stderr.write(
|
|
3132
|
+
`\u2717 could not launch \`claude\`: ${e.message}
|
|
3133
|
+
(is Claude Code installed and on PATH?)
|
|
3134
|
+
`
|
|
3135
|
+
);
|
|
3136
|
+
resolve3();
|
|
3137
|
+
});
|
|
3138
|
+
child.on("exit", () => resolve3());
|
|
3139
|
+
});
|
|
3140
|
+
fs5.rmSync(tmpDir, { recursive: true, force: true });
|
|
3141
|
+
}
|
|
3142
|
+
|
|
2815
3143
|
// package.json
|
|
2816
|
-
var version = "0.2.
|
|
3144
|
+
var version = "0.2.16";
|
|
2817
3145
|
|
|
2818
3146
|
// src/index.ts
|
|
2819
3147
|
function shortSha(sha) {
|
|
@@ -2899,10 +3227,26 @@ program.command("lint").argument("[dir]", "directory to lint", ".").description(
|
|
|
2899
3227
|
if (!report.ok) process.exit(1);
|
|
2900
3228
|
});
|
|
2901
3229
|
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);
|
|
2902
|
-
program.command("mcp").option("-C, --root <dir>", "project root (default: current directory)").description(
|
|
2903
|
-
"run a local stdio MCP server
|
|
3230
|
+
program.command("mcp").option("-C, --root <dir>", "project root (default: current directory)").option("--develop", "author surface: compile/prettify/query any .malloy in the project").option("--explore", "explore surface: the claude.ai web preview (index.malloy only) [default]").description(
|
|
3231
|
+
"run a local stdio MCP server over the Malloy model in the current directory. --develop for authoring, --explore (default) to preview the web experience"
|
|
2904
3232
|
).action(async (opts) => {
|
|
2905
|
-
|
|
3233
|
+
if (opts.develop && opts.explore) {
|
|
3234
|
+
throw new Error("pass only one of --develop / --explore");
|
|
3235
|
+
}
|
|
3236
|
+
await serveMcp({
|
|
3237
|
+
root: opts.root,
|
|
3238
|
+
version,
|
|
3239
|
+
mode: opts.develop ? "develop" : "explore"
|
|
3240
|
+
});
|
|
3241
|
+
});
|
|
3242
|
+
program.command("init").argument("[dir]", "model repo to set up", ".").description(
|
|
3243
|
+
"set up a model repo: write .mcp.json so `cd <repo> && claude` opens in author mode, and scaffold index.malloy if missing"
|
|
3244
|
+
).action(initCmd);
|
|
3245
|
+
program.command("author").option("-C, --root <dir>", "project root (default: current directory)").description("launch Claude wired ONLY to the author surface (compile/edit the model)").action(async (opts) => {
|
|
3246
|
+
await launchCmd("author", opts);
|
|
3247
|
+
});
|
|
3248
|
+
program.command("test").option("-C, --root <dir>", "project root (default: current directory)").description("launch Claude wired ONLY to the explore surface \u2014 the claude.ai web preview").action(async (opts) => {
|
|
3249
|
+
await launchCmd("test", opts);
|
|
2906
3250
|
});
|
|
2907
3251
|
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) => {
|
|
2908
3252
|
if (action !== "dev") throw new Error(`unknown dashboard action '${action}' (expected: dev)`);
|