@malloydata/malloyyo 0.2.27 → 0.2.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/frame-wasm-entry.tsx +2 -1
- package/dist/index.js +471 -195
- package/dist/shared/json-rows.ts +46 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -99,8 +99,8 @@ function resolveTarget(dir, name) {
|
|
|
99
99
|
}
|
|
100
100
|
function resolveInstance(dir, arg) {
|
|
101
101
|
if (arg && /^https?:\/\//i.test(arg)) {
|
|
102
|
-
const
|
|
103
|
-
return { name:
|
|
102
|
+
const url6 = normalizeUrl(arg);
|
|
103
|
+
return { name: url6, url: url6 };
|
|
104
104
|
}
|
|
105
105
|
const targets = readTargetMap(dir);
|
|
106
106
|
const entries = Object.entries(targets);
|
|
@@ -125,9 +125,9 @@ import { join as join2, relative, sep } from "node:path";
|
|
|
125
125
|
import { execFileSync } from "node:child_process";
|
|
126
126
|
|
|
127
127
|
// src/host.ts
|
|
128
|
-
import
|
|
129
|
-
import
|
|
130
|
-
import
|
|
128
|
+
import fs2 from "node:fs";
|
|
129
|
+
import path3 from "node:path";
|
|
130
|
+
import url3 from "node:url";
|
|
131
131
|
import {
|
|
132
132
|
MalloyConfig,
|
|
133
133
|
Runtime,
|
|
@@ -152,8 +152,8 @@ var contentFiles = {
|
|
|
152
152
|
"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 `useUrlState(key, initial)` \u2192 [value, setValue] \u2014 shareable view-state (below)\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## Shareable view-state: `useUrlState`\n\n`useState` is invisible to the page that owns the URL, so a component built on\nit has an address bar that never changes \u2014 the result can\'t be shared or\nbookmarked. **`useUrlState(key, initial)` is a `useState` twin whose value lives\nin the URL**, under a `~key` param:\n\n```jsx\nimport { useUrlState } from "@malloyyo/dashboard";\n\nconst [rack, setRack] = useUrlState("rack", ""); // string\nconst [reuse, setReuse] = useUrlState("reuse", false); // boolean\nconst [board, setBoard] = useUrlState("board", "........"); // string\nconst [topN, setTopN] = useUrlState("n", 20); // number\n```\n\n- Same shape as `useState`: `[value, setValue]`, and `setValue` takes a value\n **or** an updater fn (`setBoard(b => \u2026)`).\n- The value comes from the URL on load, else `initial`. Every change is written\n back (debounced, `replaceState` \u2014 no history spam), so the address bar is\n always a shareable link.\n- Typed by `initial`: string / number / boolean / any JSON-serializable value.\n Strings stay readable in the URL (`~rack=retinas`); objects and arrays are\n JSON. A value equal to `initial` is dropped from the URL, so defaults never\n clutter it, and a malformed value falls back to `initial` instead of throwing.\n- Works identically in `malloyyo dashboard dev`, on a bundled static site, and\n on a hosted instance \u2014 including inside the sandboxed iframe, which cannot\n reach the top-level URL on its own. That\'s why this is a hook and not\n something a component can do with `history.replaceState`.\n\n**Use it for view-state, not query parameters.** A `given:` is the governed,\nfilter-typed query contract: it\'s declared in the model, drives the default\ncontrols, and is visible over MCP \u2014 bind those with `useGiven` and they already\nround-trip through the URL as `$NAME`. `useUrlState` is for everything else a\ncustom component needs to make shareable: a letter rack whose real query inputs\n(allowed letters, min/max length) are computed from it in JS, a board layout, a\nmode toggle. The two namespaces (`$NAME` vs `~key`) never collide.\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\n**Use `--dash-*` and nothing else.** A custom component renders in its own\n**iframe**, so CSS variables defined by the surrounding page \u2014 including the\nbundled site\'s `--line` / `--card` / `--muted` \u2014 are NOT in scope inside it. A\ncomponent styled against those still renders, but every rule referencing them\nresolves to nothing: borders, dividers and panel backgrounds vanish silently\nwhile text and layout survive, so the page looks *almost* right and the cause\nisn\'t obvious. If you\'re porting CSS that has to work both inside the frame and\non a bundled page, resolve each colour once through the chain and use the alias:\n\n```css\n.my-card {\n --edge: var(--dash-border, var(--line, #e4e6eb));\n --surface: var(--dash-panel-bg, var(--card, #fff));\n border: 1px solid var(--edge);\n background: var(--surface);\n}\n```\n\nThis is a class of bug `lint` cannot see and a screenshot can \u2014 look at custom\ncomponents in `malloyyo dashboard dev` before shipping them.\n\nFor charts beyond the Malloy renderer\'s `#` tags, use `<VegaChart>` \u2014\n`yo_help dashboards/vega-charts`.\n',
|
|
153
153
|
"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",
|
|
154
154
|
"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",
|
|
155
|
-
"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',
|
|
156
|
-
"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',
|
|
155
|
+
"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- **Huge integers arrive as strings, on purpose.** Integers serialize as JSON\n numbers, but a value beyond \xB12^53 (a snowflake ID, a 64-bit hash) keeps its\n full precision by staying a string \u2014 JSON has no int64. Bound to a\n `quantitative` or `temporal` channel, Vega-Lite would sort such a column\n lexicographically (`"1","10","11","2"`). If a column can get that big and you\n need to plot it, narrow it in the QUERY \u2014 bucket it, rank it, or emit the\n value you actually want on the axis.\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',
|
|
156
|
+
"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\n*No connection named* has one cause that is **not** about your config: if more\nthan one copy of `@malloydata/malloy` is installed, the connectors register with\none copy and malloyyo reads another, and every connection looks missing. malloyyo\ndetects that and says so instead \u2014 if it doesn\'t mention duplicate copies, the\nproblem really is the config. To check by hand: `npm ls @malloydata/malloy`.\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',
|
|
157
157
|
"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',
|
|
158
158
|
"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",
|
|
159
159
|
"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.',
|
|
@@ -330,8 +330,8 @@ function errorProblem(e, uri) {
|
|
|
330
330
|
uri
|
|
331
331
|
};
|
|
332
332
|
}
|
|
333
|
-
function codeProblem(code,
|
|
334
|
-
const out = { severity: "error", code, message, uri };
|
|
333
|
+
function codeProblem(code, message2, uri) {
|
|
334
|
+
const out = { severity: "error", code, message: message2, uri };
|
|
335
335
|
const topic = helpTopicForCode(code);
|
|
336
336
|
if (topic) out.help_topic = topic;
|
|
337
337
|
return out;
|
|
@@ -959,6 +959,28 @@ function modelCatalogEntry(model_ref, model) {
|
|
|
959
959
|
if (sources.length) entry.sources = sources;
|
|
960
960
|
return entry;
|
|
961
961
|
}
|
|
962
|
+
var MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
963
|
+
var MIN_SAFE = -MAX_SAFE;
|
|
964
|
+
function narrowBigint(value) {
|
|
965
|
+
return value <= MAX_SAFE && value >= MIN_SAFE ? Number(value) : value.toString();
|
|
966
|
+
}
|
|
967
|
+
function jsonValue(value) {
|
|
968
|
+
if (typeof value === "bigint") return narrowBigint(value);
|
|
969
|
+
if (value instanceof Date) return value.toISOString();
|
|
970
|
+
if (Array.isArray(value)) return value.map(jsonValue);
|
|
971
|
+
if (value !== null && typeof value === "object" && value.constructor === Object) {
|
|
972
|
+
const out = {};
|
|
973
|
+
for (const [k, v] of Object.entries(value)) out[k] = jsonValue(v);
|
|
974
|
+
return out;
|
|
975
|
+
}
|
|
976
|
+
return value;
|
|
977
|
+
}
|
|
978
|
+
function jsonRows(result) {
|
|
979
|
+
if (!result.hasSchema) {
|
|
980
|
+
return result.toJSON().queryResult.result;
|
|
981
|
+
}
|
|
982
|
+
return result.data.toObject().map((row) => jsonValue(row));
|
|
983
|
+
}
|
|
962
984
|
var DEFAULT_ROW_LIMIT = 1e4;
|
|
963
985
|
async function executeMaterialized(query, opts, loadProblems, decorate = (p) => p, uri) {
|
|
964
986
|
const rowLimit = opts.rowLimit ?? DEFAULT_ROW_LIMIT;
|
|
@@ -970,7 +992,7 @@ async function executeMaterialized(query, opts, loadProblems, decorate = (p) =>
|
|
|
970
992
|
const t1 = Date.now();
|
|
971
993
|
const results = await retry(() => query.run({ rowLimit, ...compileOpts }));
|
|
972
994
|
const t2 = Date.now();
|
|
973
|
-
const rows = results
|
|
995
|
+
const rows = jsonRows(results);
|
|
974
996
|
const out = {
|
|
975
997
|
ok: true,
|
|
976
998
|
sql,
|
|
@@ -1967,6 +1989,239 @@ function developSurface(host, opts = {}) {
|
|
|
1967
1989
|
};
|
|
1968
1990
|
}
|
|
1969
1991
|
|
|
1992
|
+
// src/connections.ts
|
|
1993
|
+
import fs from "node:fs";
|
|
1994
|
+
import path2 from "node:path";
|
|
1995
|
+
import url2 from "node:url";
|
|
1996
|
+
import { createRequire } from "node:module";
|
|
1997
|
+
import { getRegisteredConnectionTypes } from "@malloydata/malloy";
|
|
1998
|
+
var CONNECTIONS_PKG = "@malloydata/malloy-connections";
|
|
1999
|
+
var CONNECTOR_IMPORTS = [
|
|
2000
|
+
"@malloydata/db-bigquery",
|
|
2001
|
+
"@malloydata/db-duckdb/native",
|
|
2002
|
+
"@malloydata/db-mysql",
|
|
2003
|
+
"@malloydata/db-databricks",
|
|
2004
|
+
"@malloydata/db-postgres",
|
|
2005
|
+
"@malloydata/db-snowflake",
|
|
2006
|
+
"@malloydata/db-trino"
|
|
2007
|
+
];
|
|
2008
|
+
var REGISTRY_WRITERS = [
|
|
2009
|
+
CONNECTIONS_PKG,
|
|
2010
|
+
"@malloydata/db-bigquery",
|
|
2011
|
+
"@malloydata/db-databricks",
|
|
2012
|
+
"@malloydata/db-duckdb",
|
|
2013
|
+
"@malloydata/db-mysql",
|
|
2014
|
+
"@malloydata/db-postgres",
|
|
2015
|
+
"@malloydata/db-snowflake",
|
|
2016
|
+
"@malloydata/db-trino"
|
|
2017
|
+
];
|
|
2018
|
+
function message(e) {
|
|
2019
|
+
return e instanceof Error ? e.message : String(e);
|
|
2020
|
+
}
|
|
2021
|
+
function oneLine(text) {
|
|
2022
|
+
return text.split("\n")[0];
|
|
2023
|
+
}
|
|
2024
|
+
function indent(text, by = " ") {
|
|
2025
|
+
return text.split("\n").map((l) => l ? by + l : l).join("\n");
|
|
2026
|
+
}
|
|
2027
|
+
function requireFrom(dir) {
|
|
2028
|
+
return createRequire(path2.join(dir, "__resolve__.cjs"));
|
|
2029
|
+
}
|
|
2030
|
+
function packageRootOf(entry) {
|
|
2031
|
+
let dir = path2.dirname(entry);
|
|
2032
|
+
for (; ; ) {
|
|
2033
|
+
if (fs.existsSync(path2.join(dir, "package.json"))) return dir;
|
|
2034
|
+
const up = path2.dirname(dir);
|
|
2035
|
+
if (up === dir) return null;
|
|
2036
|
+
dir = up;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
function packageDir(spec, from) {
|
|
2040
|
+
const req = requireFrom(from);
|
|
2041
|
+
try {
|
|
2042
|
+
return fs.realpathSync(path2.dirname(req.resolve(`${spec}/package.json`)));
|
|
2043
|
+
} catch {
|
|
2044
|
+
}
|
|
2045
|
+
try {
|
|
2046
|
+
const root = packageRootOf(req.resolve(spec));
|
|
2047
|
+
return root ? fs.realpathSync(root) : null;
|
|
2048
|
+
} catch {
|
|
2049
|
+
return null;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
function readVersion(pkgDir) {
|
|
2053
|
+
try {
|
|
2054
|
+
const raw = fs.readFileSync(path2.join(pkgDir, "package.json"), "utf8");
|
|
2055
|
+
return String(JSON.parse(raw).version ?? "?");
|
|
2056
|
+
} catch {
|
|
2057
|
+
return "?";
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
function selfDir() {
|
|
2061
|
+
return path2.dirname(url2.fileURLToPath(import.meta.url));
|
|
2062
|
+
}
|
|
2063
|
+
function malloyCopies(from = selfDir()) {
|
|
2064
|
+
const here = from;
|
|
2065
|
+
const byDir = /* @__PURE__ */ new Map();
|
|
2066
|
+
const note = (from2, importer) => {
|
|
2067
|
+
if (!from2) return;
|
|
2068
|
+
const dir = packageDir("@malloydata/malloy", from2);
|
|
2069
|
+
if (!dir) return;
|
|
2070
|
+
const found = byDir.get(dir);
|
|
2071
|
+
if (found) {
|
|
2072
|
+
if (!found.importers.includes(importer)) found.importers.push(importer);
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
byDir.set(dir, { dir, version: readVersion(dir), importers: [importer] });
|
|
2076
|
+
};
|
|
2077
|
+
note(here, "malloyyo");
|
|
2078
|
+
const connectionsDir = packageDir(CONNECTIONS_PKG, here);
|
|
2079
|
+
for (const spec of REGISTRY_WRITERS) {
|
|
2080
|
+
note(packageDir(spec, connectionsDir ?? here) ?? packageDir(spec, here), spec);
|
|
2081
|
+
}
|
|
2082
|
+
return [...byDir.values()];
|
|
2083
|
+
}
|
|
2084
|
+
function duplicateMalloyReport(from) {
|
|
2085
|
+
const copies = malloyCopies(from);
|
|
2086
|
+
if (copies.length < 2) return null;
|
|
2087
|
+
const listing = copies.map((c) => `${c.version.padEnd(9)} ${c.dir}
|
|
2088
|
+
${" ".repeat(10)}\u21B3 ${c.importers.join(", ")}`).join("\n");
|
|
2089
|
+
return `${copies.length} copies of @malloydata/malloy are installed. The connection
|
|
2090
|
+
registry is module-level state, so each copy has its own: the connectors
|
|
2091
|
+
register with the copy THEY resolve, malloyyo reads the copy IT resolves,
|
|
2092
|
+
and when those differ every connection looks missing.
|
|
2093
|
+
|
|
2094
|
+
${indent(listing)}
|
|
2095
|
+
|
|
2096
|
+
This is about paths, not versions \u2014 two copies of the same version are still
|
|
2097
|
+
two registries. Collapse the tree to one copy:
|
|
2098
|
+
|
|
2099
|
+
npm ls @malloydata/malloy # show who pulls in the extra copy
|
|
2100
|
+
npm dedupe # often enough on its own
|
|
2101
|
+
|
|
2102
|
+
Or run malloyyo somewhere that has no @malloydata/* dependencies of its own
|
|
2103
|
+
(a global install, or \`npx @malloydata/malloyyo\` from an empty directory).`;
|
|
2104
|
+
}
|
|
2105
|
+
async function importConnector(spec, from) {
|
|
2106
|
+
let target = spec;
|
|
2107
|
+
try {
|
|
2108
|
+
target = url2.pathToFileURL(requireFrom(from).resolve(spec)).href;
|
|
2109
|
+
} catch {
|
|
2110
|
+
}
|
|
2111
|
+
await import(target);
|
|
2112
|
+
}
|
|
2113
|
+
var pending = null;
|
|
2114
|
+
function loadConnectionTypes() {
|
|
2115
|
+
return pending ??= register();
|
|
2116
|
+
}
|
|
2117
|
+
async function register() {
|
|
2118
|
+
const here = selfDir();
|
|
2119
|
+
const failures = [];
|
|
2120
|
+
try {
|
|
2121
|
+
await import(CONNECTIONS_PKG);
|
|
2122
|
+
} catch (bulk) {
|
|
2123
|
+
failures.push({ pkg: CONNECTIONS_PKG, error: message(bulk) });
|
|
2124
|
+
const from = packageDir(CONNECTIONS_PKG, here) ?? here;
|
|
2125
|
+
for (const spec of CONNECTOR_IMPORTS) {
|
|
2126
|
+
try {
|
|
2127
|
+
await importConnector(spec, from);
|
|
2128
|
+
} catch (e) {
|
|
2129
|
+
failures.push({ pkg: spec, error: message(e) });
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
const types = getRegisteredConnectionTypes();
|
|
2134
|
+
const duplicates = malloyCopies();
|
|
2135
|
+
const split = duplicates.length > 1 ? duplicates : null;
|
|
2136
|
+
if (types.length === 0) {
|
|
2137
|
+
throw new Error(noTypesRegistered(failures));
|
|
2138
|
+
}
|
|
2139
|
+
return { types, failures, duplicates: split };
|
|
2140
|
+
}
|
|
2141
|
+
function noTypesRegistered(failures) {
|
|
2142
|
+
const dup = duplicateMalloyReport();
|
|
2143
|
+
const head = `0 connection types registered after loading ${CONNECTIONS_PKG} \u2014 no database connection can be created.
|
|
2144
|
+
`;
|
|
2145
|
+
const why = dup ? `
|
|
2146
|
+
${dup}
|
|
2147
|
+
` : `
|
|
2148
|
+
Only one @malloydata/malloy was found from here, so the connector packages
|
|
2149
|
+
themselves did not load or did not register. Check the install:
|
|
2150
|
+
|
|
2151
|
+
npm ls @malloydata/malloy ${CONNECTIONS_PKG}
|
|
2152
|
+
`;
|
|
2153
|
+
return head + why + failureDetail(failures) + `
|
|
2154
|
+
Nothing is wrong with your malloy-config.json.`;
|
|
2155
|
+
}
|
|
2156
|
+
function failureDetail(failures) {
|
|
2157
|
+
if (failures.length === 0) return "";
|
|
2158
|
+
return `
|
|
2159
|
+
Connector packages that failed to load:
|
|
2160
|
+
` + failures.map((f) => indent(`${f.pkg}: ${f.error}`)).join("\n") + `
|
|
2161
|
+
`;
|
|
2162
|
+
}
|
|
2163
|
+
var warned2 = false;
|
|
2164
|
+
var warnedAboutDuplicates = false;
|
|
2165
|
+
function warnConnectionIssues(result) {
|
|
2166
|
+
if (warned2) return;
|
|
2167
|
+
warned2 = true;
|
|
2168
|
+
if (result.duplicates) {
|
|
2169
|
+
warnedAboutDuplicates = true;
|
|
2170
|
+
console.error(
|
|
2171
|
+
`warning: ${duplicateMalloyReport()}
|
|
2172
|
+
Registered so far: ${result.types.join(", ")}
|
|
2173
|
+
`
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
const backends = result.failures.filter((f) => f.pkg !== CONNECTIONS_PKG);
|
|
2177
|
+
for (const f of backends) {
|
|
2178
|
+
console.error(`warning: connection backend ${f.pkg} unavailable \u2014 ${oneLine(f.error)}`);
|
|
2179
|
+
}
|
|
2180
|
+
const bulk = backends.length === 0 && result.failures.find((f) => f.pkg === CONNECTIONS_PKG);
|
|
2181
|
+
if (bulk) {
|
|
2182
|
+
console.error(
|
|
2183
|
+
`warning: ${CONNECTIONS_PKG} failed to load (${oneLine(bulk.error)}); loaded each backend directly instead \u2014 connections are available.`
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
async function initConnections() {
|
|
2188
|
+
const result = await loadConnectionTypes();
|
|
2189
|
+
warnConnectionIssues(result);
|
|
2190
|
+
return result;
|
|
2191
|
+
}
|
|
2192
|
+
var MISSING_CONNECTION = /No connection named ".*" found in config/;
|
|
2193
|
+
function annotateConnectionError(text) {
|
|
2194
|
+
if (!MISSING_CONNECTION.test(text)) return text;
|
|
2195
|
+
const registered = getRegisteredConnectionTypes();
|
|
2196
|
+
const dup = duplicateMalloyReport();
|
|
2197
|
+
const parts = [text, ""];
|
|
2198
|
+
if (dup) {
|
|
2199
|
+
parts.push(
|
|
2200
|
+
warnedAboutDuplicates ? `This is very likely the split @malloydata/malloy install warned about above.
|
|
2201
|
+
` : `This is very likely the cause:
|
|
2202
|
+
|
|
2203
|
+
${indent(dup)}
|
|
2204
|
+
`
|
|
2205
|
+
);
|
|
2206
|
+
}
|
|
2207
|
+
parts.push(
|
|
2208
|
+
`Connection types registered: ${registered.length ? registered.join(", ") : "(none)"}`,
|
|
2209
|
+
`Connections come from malloy-config.json, e.g.`,
|
|
2210
|
+
indent(`{ "connections": { "duckdb": { "is": "duckdb" } } }`)
|
|
2211
|
+
);
|
|
2212
|
+
return parts.join("\n");
|
|
2213
|
+
}
|
|
2214
|
+
async function withConnectionDiagnostics(fn) {
|
|
2215
|
+
try {
|
|
2216
|
+
return await fn();
|
|
2217
|
+
} catch (e) {
|
|
2218
|
+
const text = message(e);
|
|
2219
|
+
const annotated = annotateConnectionError(text);
|
|
2220
|
+
if (annotated === text) throw e;
|
|
2221
|
+
throw new Error(annotated, { cause: e });
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1970
2225
|
// src/host.ts
|
|
1971
2226
|
var ENTRY = "index.malloy";
|
|
1972
2227
|
var IDLE_SHUTDOWN_MS = 6e4;
|
|
@@ -1996,7 +2251,7 @@ function fsReader() {
|
|
|
1996
2251
|
if (u.protocol !== "file:") {
|
|
1997
2252
|
throw new Error(`unsupported URL scheme for import: ${u.href}`);
|
|
1998
2253
|
}
|
|
1999
|
-
return
|
|
2254
|
+
return fs2.promises.readFile(u, "utf8");
|
|
2000
2255
|
}
|
|
2001
2256
|
};
|
|
2002
2257
|
}
|
|
@@ -2007,9 +2262,9 @@ async function loadConfig(rootUrl, reader) {
|
|
|
2007
2262
|
});
|
|
2008
2263
|
}
|
|
2009
2264
|
async function makeRunner(root) {
|
|
2010
|
-
await
|
|
2011
|
-
const abs =
|
|
2012
|
-
const rootUrl =
|
|
2265
|
+
await initConnections();
|
|
2266
|
+
const abs = path3.resolve(root);
|
|
2267
|
+
const rootUrl = url3.pathToFileURL(abs + path3.sep);
|
|
2013
2268
|
const reader = fsReader();
|
|
2014
2269
|
let configPromise = null;
|
|
2015
2270
|
const getConfig = () => configPromise ??= loadConfig(rootUrl, reader);
|
|
@@ -2032,12 +2287,12 @@ async function makeRunner(root) {
|
|
|
2032
2287
|
};
|
|
2033
2288
|
async function leaseIn(entryFile, fn) {
|
|
2034
2289
|
const config = await getConfig();
|
|
2035
|
-
const { reader: prepared, entry } = prepareSource(reader, { url:
|
|
2290
|
+
const { reader: prepared, entry } = prepareSource(reader, { url: path3.join(abs, entryFile) });
|
|
2036
2291
|
const runtime = new Runtime({ config, urlReader: prepared });
|
|
2037
2292
|
inFlight++;
|
|
2038
2293
|
clearIdleTimer();
|
|
2039
2294
|
try {
|
|
2040
|
-
return await fn(runtime, entry);
|
|
2295
|
+
return await withConnectionDiagnostics(() => fn(runtime, entry));
|
|
2041
2296
|
} finally {
|
|
2042
2297
|
inFlight--;
|
|
2043
2298
|
if (inFlight === 0) scheduleIdleShutdown();
|
|
@@ -2046,7 +2301,7 @@ async function makeRunner(root) {
|
|
|
2046
2301
|
const lease = (fn) => leaseIn(ENTRY, fn);
|
|
2047
2302
|
return {
|
|
2048
2303
|
root: abs,
|
|
2049
|
-
entryExists: () =>
|
|
2304
|
+
entryExists: () => fs2.existsSync(path3.join(abs, ENTRY)),
|
|
2050
2305
|
async dispose() {
|
|
2051
2306
|
clearIdleTimer();
|
|
2052
2307
|
if (!configPromise) return;
|
|
@@ -2375,24 +2630,24 @@ function readAll() {
|
|
|
2375
2630
|
return {};
|
|
2376
2631
|
}
|
|
2377
2632
|
}
|
|
2378
|
-
function loadCreds(
|
|
2379
|
-
return readAll()[
|
|
2633
|
+
function loadCreds(url6) {
|
|
2634
|
+
return readAll()[url6];
|
|
2380
2635
|
}
|
|
2381
|
-
function saveCreds(
|
|
2636
|
+
function saveCreds(url6, creds) {
|
|
2382
2637
|
const p = credsPath();
|
|
2383
2638
|
mkdirSync(dirname(p), { recursive: true });
|
|
2384
2639
|
const all = readAll();
|
|
2385
|
-
all[
|
|
2640
|
+
all[url6] = creds;
|
|
2386
2641
|
writeFileSync(p, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
2387
2642
|
try {
|
|
2388
2643
|
chmodSync(p, 384);
|
|
2389
2644
|
} catch {
|
|
2390
2645
|
}
|
|
2391
2646
|
}
|
|
2392
|
-
function clearCreds(
|
|
2647
|
+
function clearCreds(url6) {
|
|
2393
2648
|
const all = readAll();
|
|
2394
|
-
if (!(
|
|
2395
|
-
delete all[
|
|
2649
|
+
if (!(url6 in all)) return false;
|
|
2650
|
+
delete all[url6];
|
|
2396
2651
|
writeFileSync(credsPath(), JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
2397
2652
|
return true;
|
|
2398
2653
|
}
|
|
@@ -2425,8 +2680,8 @@ async function registerClient(registrationEndpoint, redirectUri) {
|
|
|
2425
2680
|
if (!res.ok) throw new Error(`client registration failed: ${res.status} ${await res.text()}`);
|
|
2426
2681
|
return (await res.json()).client_id;
|
|
2427
2682
|
}
|
|
2428
|
-
function openBrowser(
|
|
2429
|
-
const [cmd, args] = process.platform === "darwin" ? ["open", [
|
|
2683
|
+
function openBrowser(url6) {
|
|
2684
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url6]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url6]] : ["xdg-open", [url6]];
|
|
2430
2685
|
try {
|
|
2431
2686
|
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
2432
2687
|
} catch {
|
|
@@ -2555,9 +2810,9 @@ Run: malloyyo login ${target.name}`);
|
|
|
2555
2810
|
}
|
|
2556
2811
|
|
|
2557
2812
|
// src/mcp.ts
|
|
2558
|
-
import
|
|
2559
|
-
import
|
|
2560
|
-
import
|
|
2813
|
+
import fs3 from "node:fs";
|
|
2814
|
+
import path4 from "node:path";
|
|
2815
|
+
import url4 from "node:url";
|
|
2561
2816
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2562
2817
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2563
2818
|
import {
|
|
@@ -2645,7 +2900,7 @@ function defaultConfig(rootUrl) {
|
|
|
2645
2900
|
});
|
|
2646
2901
|
}
|
|
2647
2902
|
async function loadConfig2(root, reader) {
|
|
2648
|
-
const rootUrl =
|
|
2903
|
+
const rootUrl = url4.pathToFileURL(root + path4.sep);
|
|
2649
2904
|
let discovered;
|
|
2650
2905
|
try {
|
|
2651
2906
|
discovered = await discoverConfig2(rootUrl, rootUrl, reader);
|
|
@@ -2667,13 +2922,13 @@ function fsReader2() {
|
|
|
2667
2922
|
if (u.protocol !== "file:") {
|
|
2668
2923
|
throw new Error(`unsupported URL scheme for import: ${u.href}`);
|
|
2669
2924
|
}
|
|
2670
|
-
return
|
|
2925
|
+
return fs3.promises.readFile(u, "utf8");
|
|
2671
2926
|
}
|
|
2672
2927
|
};
|
|
2673
2928
|
}
|
|
2674
2929
|
function resolveUnderRoot(root, p) {
|
|
2675
|
-
const abs = p.includes("://") ?
|
|
2676
|
-
if (abs !== root && !abs.startsWith(root +
|
|
2930
|
+
const abs = p.includes("://") ? path4.resolve(decodeURIComponent(new URL(p).pathname)) : path4.resolve(root, p);
|
|
2931
|
+
if (abs !== root && !abs.startsWith(root + path4.sep)) {
|
|
2677
2932
|
throw new Error(`path is outside the project root: ${p}`);
|
|
2678
2933
|
}
|
|
2679
2934
|
return abs;
|
|
@@ -2682,7 +2937,7 @@ function makeConfigSource(root) {
|
|
|
2682
2937
|
let cached;
|
|
2683
2938
|
const signature = () => ["malloy-config.json", "malloy-config-local.json"].map((name) => {
|
|
2684
2939
|
try {
|
|
2685
|
-
const st =
|
|
2940
|
+
const st = fs3.statSync(path4.join(root, name));
|
|
2686
2941
|
return `${name}:${st.mtimeMs}:${st.size}`;
|
|
2687
2942
|
} catch {
|
|
2688
2943
|
return `${name}:absent`;
|
|
@@ -2702,12 +2957,12 @@ function makeWithRuntime(root, currentConfig) {
|
|
|
2702
2957
|
return gateConfigProblems(problems, async () => {
|
|
2703
2958
|
const resolved = "url" in input ? { url: resolveUnderRoot(root, input.url) } : {
|
|
2704
2959
|
source: input.source,
|
|
2705
|
-
baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root +
|
|
2960
|
+
baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root + path4.sep
|
|
2706
2961
|
};
|
|
2707
2962
|
const { reader, entry, readSource } = prepareSource(fsReader2(), resolved);
|
|
2708
2963
|
const runtime = new Runtime2({ config, urlReader: reader });
|
|
2709
2964
|
try {
|
|
2710
|
-
return await fn({ runtime, entry, readSource });
|
|
2965
|
+
return await withConnectionDiagnostics(() => fn({ runtime, entry, readSource }));
|
|
2711
2966
|
} finally {
|
|
2712
2967
|
await config.shutdown("idle");
|
|
2713
2968
|
}
|
|
@@ -2716,7 +2971,7 @@ function makeWithRuntime(root, currentConfig) {
|
|
|
2716
2971
|
}
|
|
2717
2972
|
function makeExploreHost(root, currentConfig) {
|
|
2718
2973
|
const withRuntime = makeWithRuntime(root, currentConfig);
|
|
2719
|
-
const published = (ref) => ref === ENTRY2 &&
|
|
2974
|
+
const published = (ref) => ref === ENTRY2 && fs3.existsSync(path4.join(root, ENTRY2));
|
|
2720
2975
|
return {
|
|
2721
2976
|
withModel: (ref, fn) => {
|
|
2722
2977
|
if (!published(ref)) throw new Error(`no published model '${ref}'`);
|
|
@@ -2736,8 +2991,8 @@ function makeDevelopHost(root, currentConfig) {
|
|
|
2736
2991
|
return { withRuntime: makeWithRuntime(root, currentConfig) };
|
|
2737
2992
|
}
|
|
2738
2993
|
async function serveMcp(opts) {
|
|
2739
|
-
await
|
|
2740
|
-
const root =
|
|
2994
|
+
await initConnections();
|
|
2995
|
+
const root = path4.resolve(opts.root ?? process.cwd());
|
|
2741
2996
|
const mode = opts.mode ?? "explore";
|
|
2742
2997
|
const currentConfig = makeConfigSource(root);
|
|
2743
2998
|
const surface = mode === "develop" ? developSurface(makeDevelopHost(root, currentConfig)) : exploreSurface(makeExploreHost(root, currentConfig));
|
|
@@ -2761,8 +3016,8 @@ async function serveMcp(opts) {
|
|
|
2761
3016
|
|
|
2762
3017
|
// src/dashboard.ts
|
|
2763
3018
|
import http2 from "node:http";
|
|
2764
|
-
import
|
|
2765
|
-
import
|
|
3019
|
+
import fs5 from "node:fs";
|
|
3020
|
+
import path6 from "node:path";
|
|
2766
3021
|
import * as esbuild2 from "esbuild";
|
|
2767
3022
|
|
|
2768
3023
|
// src/shared/givens-url.ts
|
|
@@ -2807,11 +3062,11 @@ function navHtml(active, all, href, homeHref = "./") {
|
|
|
2807
3062
|
}
|
|
2808
3063
|
|
|
2809
3064
|
// src/discover.ts
|
|
2810
|
-
import
|
|
2811
|
-
import
|
|
3065
|
+
import fs4 from "node:fs";
|
|
3066
|
+
import path5 from "node:path";
|
|
2812
3067
|
import { fileURLToPath } from "node:url";
|
|
2813
|
-
import { createRequire } from "node:module";
|
|
2814
|
-
var require2 =
|
|
3068
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
3069
|
+
var require2 = createRequire2(import.meta.url);
|
|
2815
3070
|
var HOST_LIBS = [
|
|
2816
3071
|
"react",
|
|
2817
3072
|
"react-dom",
|
|
@@ -2835,7 +3090,7 @@ function resolveRuntimeDir() {
|
|
|
2835
3090
|
new URL("../src/frame-runtime/", import.meta.url)
|
|
2836
3091
|
// built dist/ next to sibling src/ (checkout)
|
|
2837
3092
|
].map((u) => fileURLToPath(u));
|
|
2838
|
-
const found = candidates.find((c) =>
|
|
3093
|
+
const found = candidates.find((c) => fs4.existsSync(c));
|
|
2839
3094
|
if (!found) {
|
|
2840
3095
|
throw new Error(
|
|
2841
3096
|
"frame-runtime/ not found next to the CLI (looked in ./frame-runtime and ../src/frame-runtime). A published install should ship it in dist/; reinstall the CLI, or rebuild with `npm run build`."
|
|
@@ -2853,31 +3108,31 @@ var hostAliasPlugin = {
|
|
|
2853
3108
|
}
|
|
2854
3109
|
};
|
|
2855
3110
|
async function discoverDashboards(root, runner) {
|
|
2856
|
-
const dir =
|
|
2857
|
-
if (!
|
|
2858
|
-
const files =
|
|
3111
|
+
const dir = path5.join(root, "dashboards");
|
|
3112
|
+
if (!fs4.existsSync(dir)) return [];
|
|
3113
|
+
const files = fs4.readdirSync(dir).filter((f) => f.endsWith(".malloy")).sort();
|
|
2859
3114
|
const dashboards = [];
|
|
2860
3115
|
for (const file of files) {
|
|
2861
3116
|
const base = file.slice(0, -".malloy".length);
|
|
2862
|
-
const entryFile =
|
|
3117
|
+
const entryFile = path5.join("dashboards", file);
|
|
2863
3118
|
const res = await runner.artifactForFile(entryFile, base);
|
|
2864
3119
|
if (!res.ok) throw new Error(`dashboard ${file}: ${res.error}`);
|
|
2865
3120
|
if (!res.artifact) continue;
|
|
2866
|
-
const component = ["jsx", "tsx"].map((ext) =>
|
|
3121
|
+
const component = ["jsx", "tsx"].map((ext) => path5.join(dir, `${base}.${ext}`)).find((p) => fs4.existsSync(p));
|
|
2867
3122
|
dashboards.push({ ...res.artifact, name: res.artifact.name || base, entryFile, tsxPath: component });
|
|
2868
3123
|
}
|
|
2869
3124
|
return dashboards;
|
|
2870
3125
|
}
|
|
2871
3126
|
function browserBuildBase() {
|
|
2872
|
-
const shims =
|
|
3127
|
+
const shims = path5.join(resolveRuntimeDir(), "..", "shims");
|
|
2873
3128
|
return {
|
|
2874
3129
|
platform: "browser",
|
|
2875
3130
|
jsx: "automatic",
|
|
2876
3131
|
loader: { ".css": "empty" },
|
|
2877
3132
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
2878
3133
|
alias: {
|
|
2879
|
-
assert:
|
|
2880
|
-
util:
|
|
3134
|
+
assert: path5.join(shims, "assert.cjs"),
|
|
3135
|
+
util: path5.join(shims, "util.cjs")
|
|
2881
3136
|
},
|
|
2882
3137
|
banner: {
|
|
2883
3138
|
js: "globalThis.process||={env:{},platform:'browser',versions:{},argv:[],cwd:()=>'/'};"
|
|
@@ -2886,17 +3141,17 @@ function browserBuildBase() {
|
|
|
2886
3141
|
}
|
|
2887
3142
|
|
|
2888
3143
|
// src/dashboard.ts
|
|
2889
|
-
var resolveFrameEntry = () =>
|
|
2890
|
-
var resolveInPageEntry = () =>
|
|
3144
|
+
var resolveFrameEntry = () => path6.join(resolveRuntimeDir(), "..", "frame-entry.tsx");
|
|
3145
|
+
var resolveInPageEntry = () => path6.join(resolveRuntimeDir(), "..", "frame-inpage-entry.tsx");
|
|
2891
3146
|
var esc2 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2892
3147
|
function makeBundler() {
|
|
2893
3148
|
const cache = /* @__PURE__ */ new Map();
|
|
2894
3149
|
const frameEntry = resolveFrameEntry();
|
|
2895
3150
|
const runtimeDir = resolveRuntimeDir();
|
|
2896
|
-
const runtimeIndex =
|
|
2897
|
-
const runtimeStamp = () =>
|
|
3151
|
+
const runtimeIndex = path6.join(runtimeDir, "index.ts");
|
|
3152
|
+
const runtimeStamp = () => fs5.statSync(frameEntry).mtimeMs + fs5.readdirSync(runtimeDir).map((f) => fs5.statSync(path6.join(runtimeDir, f)).mtimeMs).reduce((a, b) => a + b, 0);
|
|
2898
3153
|
return async function bundle(dash) {
|
|
2899
|
-
const stamp = runtimeStamp() + (dash.tsxPath ?
|
|
3154
|
+
const stamp = runtimeStamp() + (dash.tsxPath ? fs5.statSync(dash.tsxPath).mtimeMs : 0);
|
|
2900
3155
|
const hit = cache.get(dash.name);
|
|
2901
3156
|
if (hit && hit.stamp === stamp) return hit.js;
|
|
2902
3157
|
const result = await esbuild2.build({
|
|
@@ -2941,7 +3196,7 @@ function makeInPageBundler() {
|
|
|
2941
3196
|
let cached;
|
|
2942
3197
|
const entry = resolveInPageEntry();
|
|
2943
3198
|
const runtimeDir = resolveRuntimeDir();
|
|
2944
|
-
const stampOf = () =>
|
|
3199
|
+
const stampOf = () => fs5.statSync(entry).mtimeMs + fs5.readdirSync(runtimeDir).map((f) => fs5.statSync(path6.join(runtimeDir, f)).mtimeMs).reduce((a, b) => a + b, 0);
|
|
2945
3200
|
return async function bundle() {
|
|
2946
3201
|
const stamp = stampOf();
|
|
2947
3202
|
if (cached && cached.stamp === stamp) return cached.js;
|
|
@@ -3040,11 +3295,11 @@ window.addEventListener('message',async(e)=>{
|
|
|
3040
3295
|
dash.title
|
|
3041
3296
|
);
|
|
3042
3297
|
}
|
|
3043
|
-
function givensFromUrl(
|
|
3044
|
-
return givensFromSearch(
|
|
3298
|
+
function givensFromUrl(url6) {
|
|
3299
|
+
return givensFromSearch(url6.search);
|
|
3045
3300
|
}
|
|
3046
|
-
function urlStateFromUrl(
|
|
3047
|
-
return urlStateFromSearch(
|
|
3301
|
+
function urlStateFromUrl(url6) {
|
|
3302
|
+
return urlStateFromSearch(url6.search);
|
|
3048
3303
|
}
|
|
3049
3304
|
function frameDoc(dash, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
|
|
3050
3305
|
const info = {
|
|
@@ -3071,8 +3326,8 @@ async function readBody(req) {
|
|
|
3071
3326
|
return Buffer.concat(chunks).toString("utf8");
|
|
3072
3327
|
}
|
|
3073
3328
|
async function serveDashboard(opts) {
|
|
3074
|
-
await
|
|
3075
|
-
const root =
|
|
3329
|
+
await initConnections();
|
|
3330
|
+
const root = path6.resolve(opts.root ?? process.cwd());
|
|
3076
3331
|
const port = opts.port ?? 4173;
|
|
3077
3332
|
const framePort = port + 1;
|
|
3078
3333
|
const frameBase = `http://localhost:${framePort}`;
|
|
@@ -3089,7 +3344,7 @@ async function serveDashboard(opts) {
|
|
|
3089
3344
|
let byName = new Map(dashboards.map((d) => [d.name, d]));
|
|
3090
3345
|
const bundle = makeBundler();
|
|
3091
3346
|
const inPageBundle = makeInPageBundler();
|
|
3092
|
-
const pick = (
|
|
3347
|
+
const pick = (url6) => byName.get(url6.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
|
|
3093
3348
|
async function resolveGivens(dash) {
|
|
3094
3349
|
if (dash.tiles && dash.entryFile) {
|
|
3095
3350
|
const t = await runner.dashboardTiles(dash.entryFile, dash.tiles);
|
|
@@ -3105,7 +3360,7 @@ async function serveDashboard(opts) {
|
|
|
3105
3360
|
};
|
|
3106
3361
|
let debounce;
|
|
3107
3362
|
try {
|
|
3108
|
-
|
|
3363
|
+
fs5.watch(root, { recursive: true }, (_evt, filename) => {
|
|
3109
3364
|
const f = filename?.toString() ?? "";
|
|
3110
3365
|
if (!f.endsWith(".malloy") && !f.includes("dashboards")) return;
|
|
3111
3366
|
clearTimeout(debounce);
|
|
@@ -3123,15 +3378,15 @@ async function serveDashboard(opts) {
|
|
|
3123
3378
|
}
|
|
3124
3379
|
const handler = async (req, res) => {
|
|
3125
3380
|
const onFramePort = (req.socket.localPort ?? port) === framePort;
|
|
3126
|
-
const
|
|
3381
|
+
const url6 = new URL(req.url ?? "/", `http://localhost:${onFramePort ? framePort : port}`);
|
|
3127
3382
|
const send = (code, type, body, extra = {}) => {
|
|
3128
3383
|
res.writeHead(code, { "content-type": type, ...extra });
|
|
3129
3384
|
res.end(body);
|
|
3130
3385
|
};
|
|
3131
3386
|
try {
|
|
3132
3387
|
if (onFramePort) {
|
|
3133
|
-
if (
|
|
3134
|
-
const dash = pick(
|
|
3388
|
+
if (url6.pathname === "/frame") {
|
|
3389
|
+
const dash = pick(url6);
|
|
3135
3390
|
const g = await resolveGivens(dash);
|
|
3136
3391
|
if (!g.ok) {
|
|
3137
3392
|
return send(
|
|
@@ -3143,23 +3398,23 @@ async function serveDashboard(opts) {
|
|
|
3143
3398
|
return send(
|
|
3144
3399
|
200,
|
|
3145
3400
|
"text/html; charset=utf-8",
|
|
3146
|
-
frameDoc(dash, g.union, givensFromUrl(
|
|
3401
|
+
frameDoc(dash, g.union, givensFromUrl(url6), urlStateFromUrl(url6), g.tiles)
|
|
3147
3402
|
);
|
|
3148
3403
|
}
|
|
3149
|
-
if (
|
|
3150
|
-
return send(200, "application/javascript; charset=utf-8", await bundle(pick(
|
|
3404
|
+
if (url6.pathname === "/bundle.js") {
|
|
3405
|
+
return send(200, "application/javascript; charset=utf-8", await bundle(pick(url6)));
|
|
3151
3406
|
}
|
|
3152
3407
|
return send(404, "text/plain", "not found");
|
|
3153
3408
|
}
|
|
3154
|
-
if (
|
|
3409
|
+
if (url6.pathname === "/events") {
|
|
3155
3410
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
3156
3411
|
res.write("retry: 1000\n\n");
|
|
3157
3412
|
sseClients.add(res);
|
|
3158
3413
|
req.on("close", () => sseClients.delete(res));
|
|
3159
3414
|
return;
|
|
3160
3415
|
}
|
|
3161
|
-
if (
|
|
3162
|
-
const dash = pick(
|
|
3416
|
+
if (url6.pathname === "/") {
|
|
3417
|
+
const dash = pick(url6);
|
|
3163
3418
|
if (!dash.tsxPath) {
|
|
3164
3419
|
const g = await resolveGivens(dash);
|
|
3165
3420
|
if (!g.ok) {
|
|
@@ -3172,19 +3427,19 @@ async function serveDashboard(opts) {
|
|
|
3172
3427
|
return send(
|
|
3173
3428
|
200,
|
|
3174
3429
|
"text/html; charset=utf-8",
|
|
3175
|
-
inPageShell(dash, dashboards, g.union, givensFromUrl(
|
|
3430
|
+
inPageShell(dash, dashboards, g.union, givensFromUrl(url6), urlStateFromUrl(url6), g.tiles)
|
|
3176
3431
|
);
|
|
3177
3432
|
}
|
|
3178
3433
|
return send(
|
|
3179
3434
|
200,
|
|
3180
3435
|
"text/html; charset=utf-8",
|
|
3181
|
-
parentShell(dash, frameBase, dashboards, givensFromUrl(
|
|
3436
|
+
parentShell(dash, frameBase, dashboards, givensFromUrl(url6), urlStateFromUrl(url6))
|
|
3182
3437
|
);
|
|
3183
3438
|
}
|
|
3184
|
-
if (
|
|
3439
|
+
if (url6.pathname === "/inpage.js") {
|
|
3185
3440
|
return send(200, "application/javascript; charset=utf-8", await inPageBundle());
|
|
3186
3441
|
}
|
|
3187
|
-
if (
|
|
3442
|
+
if (url6.pathname === "/api/run" && req.method === "POST") {
|
|
3188
3443
|
const { d, query, malloy, givens } = JSON.parse(await readBody(req));
|
|
3189
3444
|
const dash = byName.get(d);
|
|
3190
3445
|
if (!dash) return send(404, "application/json", JSON.stringify({ ok: false, problems: [{ message: `no dashboard '${d}'` }] }));
|
|
@@ -3215,15 +3470,15 @@ async function serveDashboard(opts) {
|
|
|
3215
3470
|
}
|
|
3216
3471
|
|
|
3217
3472
|
// src/bundle.ts
|
|
3218
|
-
import
|
|
3219
|
-
import
|
|
3220
|
-
import { createRequire as
|
|
3473
|
+
import fs7 from "node:fs";
|
|
3474
|
+
import path8 from "node:path";
|
|
3475
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
3221
3476
|
import * as esbuild3 from "esbuild";
|
|
3222
3477
|
|
|
3223
3478
|
// src/static-server.ts
|
|
3224
|
-
import
|
|
3479
|
+
import fs6 from "node:fs";
|
|
3225
3480
|
import http3 from "node:http";
|
|
3226
|
-
import
|
|
3481
|
+
import path7 from "node:path";
|
|
3227
3482
|
var MIME = {
|
|
3228
3483
|
".html": "text/html; charset=utf-8",
|
|
3229
3484
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -3238,12 +3493,12 @@ var MIME = {
|
|
|
3238
3493
|
function serveStatic(dir, port) {
|
|
3239
3494
|
const server = http3.createServer((req, res) => {
|
|
3240
3495
|
const rel = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
3241
|
-
let file =
|
|
3496
|
+
let file = path7.join(dir, rel === "/" ? "index.html" : rel);
|
|
3242
3497
|
if (!file.startsWith(dir)) return void res.writeHead(403).end();
|
|
3243
|
-
if (
|
|
3244
|
-
if (!
|
|
3245
|
-
const st =
|
|
3246
|
-
const type = MIME[
|
|
3498
|
+
if (fs6.existsSync(file) && fs6.statSync(file).isDirectory()) file = path7.join(file, "index.html");
|
|
3499
|
+
if (!fs6.existsSync(file)) return void res.writeHead(404).end("not found");
|
|
3500
|
+
const st = fs6.statSync(file);
|
|
3501
|
+
const type = MIME[path7.extname(file)] ?? "application/octet-stream";
|
|
3247
3502
|
const range = req.headers.range;
|
|
3248
3503
|
if (range) {
|
|
3249
3504
|
const m = /bytes=(\d*)-(\d*)/.exec(range);
|
|
@@ -3255,10 +3510,10 @@ function serveStatic(dir, port) {
|
|
|
3255
3510
|
"Accept-Ranges": "bytes",
|
|
3256
3511
|
"Content-Length": end - start + 1
|
|
3257
3512
|
});
|
|
3258
|
-
return void
|
|
3513
|
+
return void fs6.createReadStream(file, { start, end }).pipe(res);
|
|
3259
3514
|
}
|
|
3260
3515
|
res.writeHead(200, { "Content-Type": type, "Content-Length": st.size, "Accept-Ranges": "bytes" });
|
|
3261
|
-
|
|
3516
|
+
fs6.createReadStream(file).pipe(res);
|
|
3262
3517
|
});
|
|
3263
3518
|
return new Promise((resolve3, reject) => {
|
|
3264
3519
|
let attempt = 0;
|
|
@@ -3275,7 +3530,7 @@ function serveStatic(dir, port) {
|
|
|
3275
3530
|
if (p !== port) console.log(`
|
|
3276
3531
|
(port ${port} busy \u2014 using ${p})`);
|
|
3277
3532
|
console.log(`
|
|
3278
|
-
serving ${
|
|
3533
|
+
serving ${path7.basename(dir)}/ on http://localhost:${p} (ctrl-c to stop)`);
|
|
3279
3534
|
resolve3();
|
|
3280
3535
|
});
|
|
3281
3536
|
};
|
|
@@ -3284,19 +3539,19 @@ serving ${path6.basename(dir)}/ on http://localhost:${p} (ctrl-c to stop)`);
|
|
|
3284
3539
|
}
|
|
3285
3540
|
|
|
3286
3541
|
// src/bundle.ts
|
|
3287
|
-
var require3 =
|
|
3542
|
+
var require3 = createRequire3(import.meta.url);
|
|
3288
3543
|
var esc3 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3289
3544
|
function inlineModelFiles(root) {
|
|
3290
3545
|
const files = {};
|
|
3291
3546
|
const skip = /* @__PURE__ */ new Set(["node_modules", ".git", "docs", "dist"]);
|
|
3292
3547
|
const walk = (dir) => {
|
|
3293
|
-
for (const entry of
|
|
3548
|
+
for (const entry of fs7.readdirSync(dir, { withFileTypes: true })) {
|
|
3294
3549
|
if (entry.name.startsWith(".") || skip.has(entry.name)) continue;
|
|
3295
|
-
const abs =
|
|
3550
|
+
const abs = path8.join(dir, entry.name);
|
|
3296
3551
|
if (entry.isDirectory()) walk(abs);
|
|
3297
3552
|
else if (entry.name.endsWith(".malloy")) {
|
|
3298
|
-
const rel =
|
|
3299
|
-
files[`file:///${rel}`] =
|
|
3553
|
+
const rel = path8.relative(root, abs).split(path8.sep).join("/");
|
|
3554
|
+
files[`file:///${rel}`] = fs7.readFileSync(abs, "utf8");
|
|
3300
3555
|
}
|
|
3301
3556
|
}
|
|
3302
3557
|
};
|
|
@@ -3359,12 +3614,12 @@ function copyDuckDBAssets(outDir) {
|
|
|
3359
3614
|
"duckdb-browser-mvp.worker.js",
|
|
3360
3615
|
"duckdb-browser-eh.worker.js"
|
|
3361
3616
|
];
|
|
3362
|
-
const dir =
|
|
3363
|
-
|
|
3617
|
+
const dir = path8.join(outDir, "duckdb");
|
|
3618
|
+
fs7.mkdirSync(dir, { recursive: true });
|
|
3364
3619
|
const copied = [];
|
|
3365
3620
|
for (const n of names) {
|
|
3366
3621
|
const src = require3.resolve(`@duckdb/duckdb-wasm/dist/${n}`);
|
|
3367
|
-
|
|
3622
|
+
fs7.copyFileSync(src, path8.join(dir, n));
|
|
3368
3623
|
copied.push(n);
|
|
3369
3624
|
}
|
|
3370
3625
|
return copied;
|
|
@@ -3463,37 +3718,37 @@ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,B
|
|
|
3463
3718
|
.index span{font-size:13px;color:var(--muted)}
|
|
3464
3719
|
` + NAV_CSS;
|
|
3465
3720
|
async function bundleDashboards(opts = {}) {
|
|
3466
|
-
const root =
|
|
3467
|
-
const outDir =
|
|
3468
|
-
const title = opts.title ??
|
|
3721
|
+
const root = path8.resolve(opts.root ?? process.cwd());
|
|
3722
|
+
const outDir = path8.resolve(root, opts.out ?? "docs");
|
|
3723
|
+
const title = opts.title ?? path8.basename(root);
|
|
3469
3724
|
const target = opts.target ?? "pages";
|
|
3470
3725
|
const analytics = opts.analytics ?? readSiteConfig(root).analytics;
|
|
3471
3726
|
const cleanUrls = target === "vercel";
|
|
3472
3727
|
const selfHostDuckdb = opts.duckdb === "bundled";
|
|
3473
3728
|
const runner = await makeRunner(root);
|
|
3474
3729
|
const dashboards = await discoverDashboards(root, runner);
|
|
3475
|
-
if (dashboards.length === 0) throw new Error(`no dashboards found in ${
|
|
3476
|
-
const manifestPath =
|
|
3730
|
+
if (dashboards.length === 0) throw new Error(`no dashboards found in ${path8.join(root, "dashboards")}`);
|
|
3731
|
+
const manifestPath = path8.join(outDir, ".bundle-manifest.json");
|
|
3477
3732
|
let priorData = [];
|
|
3478
3733
|
try {
|
|
3479
|
-
priorData = JSON.parse(
|
|
3734
|
+
priorData = JSON.parse(fs7.readFileSync(manifestPath, "utf8")).dataFiles ?? [];
|
|
3480
3735
|
} catch {
|
|
3481
3736
|
}
|
|
3482
3737
|
for (const sub of ["assets", "duckdb"]) {
|
|
3483
|
-
|
|
3738
|
+
fs7.rmSync(path8.join(outDir, sub), { recursive: true, force: true });
|
|
3484
3739
|
}
|
|
3485
|
-
if (
|
|
3486
|
-
for (const f of
|
|
3487
|
-
if (f.endsWith(".html"))
|
|
3740
|
+
if (fs7.existsSync(outDir)) {
|
|
3741
|
+
for (const f of fs7.readdirSync(outDir)) {
|
|
3742
|
+
if (f.endsWith(".html")) fs7.rmSync(path8.join(outDir, f), { force: true });
|
|
3488
3743
|
}
|
|
3489
3744
|
}
|
|
3490
|
-
|
|
3745
|
+
fs7.mkdirSync(path8.join(outDir, "assets"), { recursive: true });
|
|
3491
3746
|
if (target === "pages") {
|
|
3492
|
-
|
|
3747
|
+
fs7.writeFileSync(path8.join(outDir, ".nojekyll"), "");
|
|
3493
3748
|
} else {
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3749
|
+
fs7.rmSync(path8.join(outDir, ".nojekyll"), { force: true });
|
|
3750
|
+
fs7.writeFileSync(
|
|
3751
|
+
path8.join(outDir, "vercel.json"),
|
|
3497
3752
|
JSON.stringify(
|
|
3498
3753
|
{
|
|
3499
3754
|
$schema: "https://openapi.vercel.sh/vercel.json",
|
|
@@ -3515,53 +3770,53 @@ async function bundleDashboards(opts = {}) {
|
|
|
3515
3770
|
);
|
|
3516
3771
|
}
|
|
3517
3772
|
const modelFiles = inlineModelFiles(root);
|
|
3518
|
-
const outRel =
|
|
3773
|
+
const outRel = path8.relative(root, outDir).split(path8.sep).join("/");
|
|
3519
3774
|
const usedFiles = reachableModelFiles(
|
|
3520
3775
|
modelFiles,
|
|
3521
3776
|
dashboards.map((d) => d.entryFile).filter((f) => !!f)
|
|
3522
3777
|
);
|
|
3523
3778
|
const { map: tableFiles, copies } = tableFilePlan(usedFiles, outRel);
|
|
3524
3779
|
for (const rel of copies) {
|
|
3525
|
-
const from =
|
|
3526
|
-
if (!
|
|
3780
|
+
const from = path8.join(root, rel);
|
|
3781
|
+
if (!fs7.existsSync(from)) {
|
|
3527
3782
|
throw new Error(
|
|
3528
3783
|
`model reads '${rel}' but ${from} does not exist.
|
|
3529
3784
|
Data files are referenced by a path relative to the project root.`
|
|
3530
3785
|
);
|
|
3531
3786
|
}
|
|
3532
|
-
const to =
|
|
3533
|
-
|
|
3534
|
-
|
|
3787
|
+
const to = path8.join(outDir, rel);
|
|
3788
|
+
fs7.mkdirSync(path8.dirname(to), { recursive: true });
|
|
3789
|
+
fs7.copyFileSync(from, to);
|
|
3535
3790
|
}
|
|
3536
3791
|
const copiedData = copies;
|
|
3537
3792
|
for (const stale of priorData) {
|
|
3538
3793
|
if (copies.includes(stale)) continue;
|
|
3539
|
-
|
|
3794
|
+
fs7.rmSync(path8.join(outDir, stale), { force: true });
|
|
3540
3795
|
try {
|
|
3541
|
-
|
|
3796
|
+
fs7.rmdirSync(path8.dirname(path8.join(outDir, stale)));
|
|
3542
3797
|
} catch {
|
|
3543
3798
|
}
|
|
3544
3799
|
console.log(` removed stale ${stale}`);
|
|
3545
3800
|
}
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3801
|
+
fs7.writeFileSync(manifestPath, JSON.stringify({ dataFiles: copies }, null, 2) + "\n");
|
|
3802
|
+
fs7.writeFileSync(
|
|
3803
|
+
path8.join(outDir, "assets", "model-files.js"),
|
|
3549
3804
|
`window.__MODEL_FILES__ = ${JSON.stringify(modelFiles)};
|
|
3550
3805
|
window.__TABLE_FILES__ = ${JSON.stringify(tableFiles)};
|
|
3551
3806
|
` + (selfHostDuckdb ? `window.__DUCKDB_BASE__ = "./duckdb/";
|
|
3552
3807
|
` : "")
|
|
3553
3808
|
);
|
|
3554
|
-
const landing = ["jsx", "tsx"].map((ext) =>
|
|
3809
|
+
const landing = ["jsx", "tsx"].map((ext) => path8.join(root, "dashboards", `index.${ext}`)).find((f) => fs7.existsSync(f));
|
|
3555
3810
|
const runtimeDir = resolveRuntimeDir();
|
|
3556
|
-
const runtimeIndex =
|
|
3557
|
-
const wasmEntry =
|
|
3811
|
+
const runtimeIndex = path8.join(runtimeDir, "index.ts");
|
|
3812
|
+
const wasmEntry = path8.join(runtimeDir, "..", "frame-wasm-entry.tsx");
|
|
3558
3813
|
const byEntry = new Map(dashboards.map((d) => [`vdash:${d.name}`, d]));
|
|
3559
3814
|
await esbuild3.build({
|
|
3560
3815
|
entryPoints: Object.fromEntries(dashboards.map((d) => [d.name, `vdash:${d.name}`])),
|
|
3561
3816
|
bundle: true,
|
|
3562
3817
|
splitting: true,
|
|
3563
3818
|
format: "esm",
|
|
3564
|
-
outdir:
|
|
3819
|
+
outdir: path8.join(outDir, "assets"),
|
|
3565
3820
|
minify: true,
|
|
3566
3821
|
logLevel: "warning",
|
|
3567
3822
|
...browserBuildBase(),
|
|
@@ -3582,7 +3837,7 @@ boot(Dashboard);
|
|
|
3582
3837
|
loader: "js",
|
|
3583
3838
|
// Resolve the component's own imports (react, @malloyyo/dashboard)
|
|
3584
3839
|
// from the model repo's directory, matching `dashboard dev`.
|
|
3585
|
-
resolveDir:
|
|
3840
|
+
resolveDir: path8.dirname(dash.tsxPath ?? path8.join(root, "dashboards", "x"))
|
|
3586
3841
|
};
|
|
3587
3842
|
});
|
|
3588
3843
|
b.onResolve({ filter: /^@malloyyo\/dashboard$/ }, () => ({ path: runtimeIndex }));
|
|
@@ -3591,7 +3846,7 @@ boot(Dashboard);
|
|
|
3591
3846
|
hostAliasPlugin
|
|
3592
3847
|
]
|
|
3593
3848
|
});
|
|
3594
|
-
|
|
3849
|
+
fs7.writeFileSync(path8.join(outDir, "assets", "site.css"), SITE_CSS);
|
|
3595
3850
|
for (const d of dashboards) {
|
|
3596
3851
|
let specs = [];
|
|
3597
3852
|
let tileSpecs;
|
|
@@ -3604,9 +3859,9 @@ boot(Dashboard);
|
|
|
3604
3859
|
if (!got.ok) throw new Error(`dashboard ${d.name}: ${got.error}`);
|
|
3605
3860
|
specs = got.givens;
|
|
3606
3861
|
}
|
|
3607
|
-
|
|
3862
|
+
fs7.writeFileSync(path8.join(outDir, `${d.name}.html`), page(d, dashboards, title, specs, tileSpecs, cleanUrls, analytics));
|
|
3608
3863
|
}
|
|
3609
|
-
|
|
3864
|
+
fs7.writeFileSync(path8.join(outDir, "index.html"), indexPage(dashboards, title, !!landing, cleanUrls, analytics));
|
|
3610
3865
|
if (landing) {
|
|
3611
3866
|
await esbuild3.build({
|
|
3612
3867
|
stdin: {
|
|
@@ -3615,13 +3870,13 @@ import { createRoot } from "react-dom/client";
|
|
|
3615
3870
|
import Landing from ${JSON.stringify(landing)};
|
|
3616
3871
|
createRoot(document.getElementById("root")).render(React.createElement(Landing, { dashboards: window.__DASHBOARDS__ || [] }));
|
|
3617
3872
|
`,
|
|
3618
|
-
resolveDir:
|
|
3873
|
+
resolveDir: path8.dirname(landing),
|
|
3619
3874
|
loader: "js"
|
|
3620
3875
|
},
|
|
3621
3876
|
bundle: true,
|
|
3622
3877
|
format: "esm",
|
|
3623
3878
|
minify: true,
|
|
3624
|
-
outfile:
|
|
3879
|
+
outfile: path8.join(outDir, "assets", "index.js"),
|
|
3625
3880
|
logLevel: "warning",
|
|
3626
3881
|
// Same base as the dashboard pass. A landing page needs no Malloy today,
|
|
3627
3882
|
// but one that imported anything reaching antlr4ts would otherwise die at
|
|
@@ -3631,12 +3886,12 @@ createRoot(document.getElementById("root")).render(React.createElement(Landing,
|
|
|
3631
3886
|
});
|
|
3632
3887
|
}
|
|
3633
3888
|
const duck = selfHostDuckdb ? copyDuckDBAssets(outDir) : [];
|
|
3634
|
-
if (!selfHostDuckdb)
|
|
3635
|
-
const bytes = (p) =>
|
|
3636
|
-
const assetDir =
|
|
3637
|
-
const jsTotal =
|
|
3889
|
+
if (!selfHostDuckdb) fs7.rmSync(path8.join(outDir, "duckdb"), { recursive: true, force: true });
|
|
3890
|
+
const bytes = (p) => fs7.statSync(p).size;
|
|
3891
|
+
const assetDir = path8.join(outDir, "assets");
|
|
3892
|
+
const jsTotal = fs7.readdirSync(assetDir).filter((f) => f.endsWith(".js")).reduce((a, f) => a + bytes(path8.join(assetDir, f)), 0);
|
|
3638
3893
|
console.log(`
|
|
3639
|
-
bundled ${dashboards.length} dashboard(s) \u2192 ${
|
|
3894
|
+
bundled ${dashboards.length} dashboard(s) \u2192 ${path8.relative(process.cwd(), outDir) || "."}/`);
|
|
3640
3895
|
for (const d of dashboards) console.log(` ${d.name}.html ${d.title ?? ""}`);
|
|
3641
3896
|
console.log(`
|
|
3642
3897
|
js ${(jsTotal / 1048576).toFixed(2)} MB`);
|
|
@@ -3646,13 +3901,13 @@ bundled ${dashboards.length} dashboard(s) \u2192 ${path7.relative(process.cwd(),
|
|
|
3646
3901
|
console.log(` model ${Object.keys(modelFiles).length} .malloy files inlined`);
|
|
3647
3902
|
if (analytics) console.log(` analytics ${analytics}`);
|
|
3648
3903
|
if (copiedData.length) {
|
|
3649
|
-
const mb = copiedData.reduce((a, r) => a +
|
|
3904
|
+
const mb = copiedData.reduce((a, r) => a + fs7.statSync(path8.join(root, r)).size, 0) / 1048576;
|
|
3650
3905
|
console.log(` data ${copiedData.length} file(s) copied, ${mb.toFixed(1)} MB (same-origin)`);
|
|
3651
3906
|
}
|
|
3652
3907
|
console.log(
|
|
3653
3908
|
target === "pages" ? `
|
|
3654
|
-
Publish: commit ${
|
|
3655
|
-
Publish: deploy ${
|
|
3909
|
+
Publish: commit ${path8.basename(outDir)}/ and point GitHub Pages at it.` : `
|
|
3910
|
+
Publish: deploy ${path8.basename(outDir)}/ to Vercel (vercel.json written: clean URLs + asset caching).`
|
|
3656
3911
|
);
|
|
3657
3912
|
if (opts.serve !== false) {
|
|
3658
3913
|
await serveStatic(outDir, opts.port ?? 4180);
|
|
@@ -3662,8 +3917,8 @@ Publish: deploy ${path7.basename(outDir)}/ to Vercel (vercel.json written: clean
|
|
|
3662
3917
|
}
|
|
3663
3918
|
|
|
3664
3919
|
// src/init.ts
|
|
3665
|
-
import
|
|
3666
|
-
import
|
|
3920
|
+
import fs8 from "node:fs";
|
|
3921
|
+
import path9 from "node:path";
|
|
3667
3922
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
3668
3923
|
var AUTHOR_SERVER = "malloyyo_author";
|
|
3669
3924
|
var AUTHOR_MCP = {
|
|
@@ -3698,11 +3953,11 @@ function withAuthorPermissions(input) {
|
|
|
3698
3953
|
return { settings, added };
|
|
3699
3954
|
}
|
|
3700
3955
|
function allowAuthorTools(root) {
|
|
3701
|
-
const file =
|
|
3956
|
+
const file = path9.join(root, ".claude", "settings.json");
|
|
3702
3957
|
let existing;
|
|
3703
|
-
if (
|
|
3958
|
+
if (fs8.existsSync(file)) {
|
|
3704
3959
|
try {
|
|
3705
|
-
existing = JSON.parse(
|
|
3960
|
+
existing = JSON.parse(fs8.readFileSync(file, "utf8"));
|
|
3706
3961
|
} catch {
|
|
3707
3962
|
return { added: [], note: ".claude/settings.json isn't valid JSON \u2014 left as-is" };
|
|
3708
3963
|
}
|
|
@@ -3710,8 +3965,8 @@ function allowAuthorTools(root) {
|
|
|
3710
3965
|
const merged = withAuthorPermissions(existing);
|
|
3711
3966
|
if ("error" in merged) return { added: [], note: `${merged.error} \u2014 left as-is` };
|
|
3712
3967
|
if (merged.added.length === 0) return { added: [] };
|
|
3713
|
-
|
|
3714
|
-
|
|
3968
|
+
fs8.mkdirSync(path9.dirname(file), { recursive: true });
|
|
3969
|
+
fs8.writeFileSync(file, JSON.stringify(merged.settings, null, 2) + "\n");
|
|
3715
3970
|
return { added: merged.added };
|
|
3716
3971
|
}
|
|
3717
3972
|
function exportableNames(src) {
|
|
@@ -3726,11 +3981,11 @@ function exportableNames(src) {
|
|
|
3726
3981
|
return [...names];
|
|
3727
3982
|
}
|
|
3728
3983
|
function scaffoldIndex(root) {
|
|
3729
|
-
const indexPath =
|
|
3730
|
-
if (
|
|
3984
|
+
const indexPath = path9.join(root, "index.malloy");
|
|
3985
|
+
if (fs8.existsSync(indexPath)) {
|
|
3731
3986
|
return { wrote: false, note: "index.malloy already exists \u2014 left as-is" };
|
|
3732
3987
|
}
|
|
3733
|
-
const models =
|
|
3988
|
+
const models = fs8.readdirSync(root).filter((f) => f.endsWith(".malloy") && f !== "index.malloy").sort();
|
|
3734
3989
|
if (models.length === 0) {
|
|
3735
3990
|
return { wrote: false, note: "no .malloy files found \u2014 skipped index.malloy" };
|
|
3736
3991
|
}
|
|
@@ -3743,7 +3998,7 @@ function scaffoldIndex(root) {
|
|
|
3743
3998
|
];
|
|
3744
3999
|
let anyNames = false;
|
|
3745
4000
|
for (const file of models) {
|
|
3746
|
-
const names = exportableNames(
|
|
4001
|
+
const names = exportableNames(fs8.readFileSync(path9.join(root, file), "utf8"));
|
|
3747
4002
|
if (names.length === 0) {
|
|
3748
4003
|
blocks.push(`// ${file}: no top-level source/query/given detected \u2014 add exports by hand`);
|
|
3749
4004
|
continue;
|
|
@@ -3754,49 +4009,49 @@ function scaffoldIndex(root) {
|
|
|
3754
4009
|
blocks.push(`export { ${list} }`);
|
|
3755
4010
|
blocks.push("");
|
|
3756
4011
|
}
|
|
3757
|
-
|
|
4012
|
+
fs8.writeFileSync(indexPath, blocks.join("\n") + "\n");
|
|
3758
4013
|
return {
|
|
3759
4014
|
wrote: true,
|
|
3760
4015
|
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"
|
|
3761
4016
|
};
|
|
3762
4017
|
}
|
|
3763
4018
|
function installSkills(root) {
|
|
3764
|
-
const distDir =
|
|
4019
|
+
const distDir = path9.dirname(fileURLToPath2(import.meta.url));
|
|
3765
4020
|
const candidates = [
|
|
3766
|
-
|
|
3767
|
-
|
|
4021
|
+
path9.join(distDir, "templates", "skills"),
|
|
4022
|
+
path9.join(distDir, "..", "src", "templates", "skills")
|
|
3768
4023
|
];
|
|
3769
|
-
const srcSkills = candidates.find((p) =>
|
|
4024
|
+
const srcSkills = candidates.find((p) => fs8.existsSync(p));
|
|
3770
4025
|
if (!srcSkills) return { wrote: [], skipped: [], note: "no skill templates found \u2014 skipped" };
|
|
3771
|
-
const destSkills =
|
|
3772
|
-
|
|
4026
|
+
const destSkills = path9.join(root, ".claude", "skills");
|
|
4027
|
+
fs8.mkdirSync(destSkills, { recursive: true });
|
|
3773
4028
|
const wrote = [];
|
|
3774
4029
|
const skipped = [];
|
|
3775
|
-
for (const name of
|
|
3776
|
-
const from =
|
|
3777
|
-
if (!
|
|
3778
|
-
const to =
|
|
3779
|
-
if (
|
|
4030
|
+
for (const name of fs8.readdirSync(srcSkills)) {
|
|
4031
|
+
const from = path9.join(srcSkills, name);
|
|
4032
|
+
if (!fs8.statSync(from).isDirectory()) continue;
|
|
4033
|
+
const to = path9.join(destSkills, name);
|
|
4034
|
+
if (fs8.existsSync(to)) {
|
|
3780
4035
|
skipped.push(name);
|
|
3781
4036
|
continue;
|
|
3782
4037
|
}
|
|
3783
|
-
|
|
4038
|
+
fs8.cpSync(from, to, { recursive: true });
|
|
3784
4039
|
wrote.push(name);
|
|
3785
4040
|
}
|
|
3786
4041
|
return { wrote, skipped };
|
|
3787
4042
|
}
|
|
3788
4043
|
async function initCmd(dir) {
|
|
3789
|
-
const root =
|
|
3790
|
-
if (!
|
|
4044
|
+
const root = path9.resolve(dir);
|
|
4045
|
+
if (!fs8.existsSync(root) || !fs8.statSync(root).isDirectory()) {
|
|
3791
4046
|
throw new Error(`not a directory: ${root}`);
|
|
3792
4047
|
}
|
|
3793
|
-
const mcpPath =
|
|
3794
|
-
if (
|
|
4048
|
+
const mcpPath = path9.join(root, ".mcp.json");
|
|
4049
|
+
if (fs8.existsSync(mcpPath)) {
|
|
3795
4050
|
console.log(`\u2022 .mcp.json exists \u2014 leaving it. For author-by-default it should be:`);
|
|
3796
4051
|
console.log(` ${JSON.stringify(AUTHOR_MCP.mcpServers.malloyyo_author)}`);
|
|
3797
4052
|
console.log(` (server key "malloyyo_author", command "malloyyo mcp --develop").`);
|
|
3798
4053
|
} else {
|
|
3799
|
-
|
|
4054
|
+
fs8.writeFileSync(mcpPath, JSON.stringify(AUTHOR_MCP, null, 2) + "\n");
|
|
3800
4055
|
console.log(`\u2713 wrote .mcp.json \u2014 \`cd ${dir} && claude\` now opens in AUTHOR mode`);
|
|
3801
4056
|
}
|
|
3802
4057
|
const idx = scaffoldIndex(root);
|
|
@@ -3830,27 +4085,45 @@ async function initCmd(dir) {
|
|
|
3830
4085
|
}
|
|
3831
4086
|
|
|
3832
4087
|
// src/sql.ts
|
|
3833
|
-
import
|
|
3834
|
-
import
|
|
3835
|
-
import
|
|
3836
|
-
import { MalloyConfig as MalloyConfig3, discoverConfig as discoverConfig3 } from "@malloydata/malloy";
|
|
4088
|
+
import fs9 from "node:fs";
|
|
4089
|
+
import path10 from "node:path";
|
|
4090
|
+
import url5 from "node:url";
|
|
4091
|
+
import { MalloyConfig as MalloyConfig3, Runtime as Runtime3, discoverConfig as discoverConfig3 } from "@malloydata/malloy";
|
|
3837
4092
|
function fileReader() {
|
|
3838
4093
|
return {
|
|
3839
4094
|
readURL: async (u) => {
|
|
3840
4095
|
if (u.protocol !== "file:") {
|
|
3841
4096
|
throw new Error(`unsupported URL scheme for import: ${u.href}`);
|
|
3842
4097
|
}
|
|
3843
|
-
return
|
|
4098
|
+
return fs9.promises.readFile(u, "utf8");
|
|
3844
4099
|
}
|
|
3845
4100
|
};
|
|
3846
4101
|
}
|
|
3847
4102
|
async function loadConfig3(rootDir) {
|
|
3848
|
-
const rootUrl =
|
|
4103
|
+
const rootUrl = url5.pathToFileURL(rootDir.endsWith(path10.sep) ? rootDir : rootDir + path10.sep);
|
|
3849
4104
|
const discovered = await discoverConfig3(rootUrl, rootUrl, fileReader()).catch(() => null);
|
|
3850
4105
|
return discovered ?? new MalloyConfig3({ includeDefaultConnections: true }, {
|
|
3851
4106
|
rootDirectory: rootUrl.toString()
|
|
3852
4107
|
});
|
|
3853
4108
|
}
|
|
4109
|
+
var TYPED_ROW_CAP = 1e6;
|
|
4110
|
+
function malloyStringLiteral(sql) {
|
|
4111
|
+
const body = sql.trim().replace(/;\s*$/, "") + "\n";
|
|
4112
|
+
const escaped = body.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
4113
|
+
return `"${escaped}"`;
|
|
4114
|
+
}
|
|
4115
|
+
async function runTyped(cfg, name, sql) {
|
|
4116
|
+
if (name.includes("`")) return null;
|
|
4117
|
+
try {
|
|
4118
|
+
const runtime = new Runtime3({ config: cfg, urlReader: fileReader() });
|
|
4119
|
+
const query = `run: \`${name}\`.sql(${malloyStringLiteral(sql)})`;
|
|
4120
|
+
const result = await runtime.loadQuery(query).run({ rowLimit: TYPED_ROW_CAP });
|
|
4121
|
+
const rows = jsonRows(result);
|
|
4122
|
+
return rows.length >= TYPED_ROW_CAP ? null : rows;
|
|
4123
|
+
} catch {
|
|
4124
|
+
return null;
|
|
4125
|
+
}
|
|
4126
|
+
}
|
|
3854
4127
|
async function readStdin() {
|
|
3855
4128
|
const chunks = [];
|
|
3856
4129
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
@@ -3858,22 +4131,25 @@ async function readStdin() {
|
|
|
3858
4131
|
}
|
|
3859
4132
|
async function resolveSql(opts) {
|
|
3860
4133
|
if (opts.execute != null) return opts.execute;
|
|
3861
|
-
if (opts.file) return
|
|
4134
|
+
if (opts.file) return fs9.promises.readFile(opts.file, "utf8");
|
|
3862
4135
|
return readStdin();
|
|
3863
4136
|
}
|
|
3864
4137
|
async function sqlCmd(connection, opts) {
|
|
3865
4138
|
const name = connection ?? "duckdb";
|
|
3866
|
-
const rootDir =
|
|
4139
|
+
const rootDir = path10.resolve(opts.root ?? ".");
|
|
3867
4140
|
const sql = (await resolveSql(opts)).trim();
|
|
3868
4141
|
if (!sql) {
|
|
3869
4142
|
throw new Error("no SQL provided \u2014 pass -e <sql>, -f <file>, or pipe it via stdin");
|
|
3870
4143
|
}
|
|
3871
|
-
await
|
|
4144
|
+
await initConnections();
|
|
3872
4145
|
const cfg = await loadConfig3(rootDir);
|
|
3873
4146
|
try {
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
4147
|
+
let rows = await runTyped(cfg, name, sql);
|
|
4148
|
+
if (rows === null) {
|
|
4149
|
+
const conn = await withConnectionDiagnostics(() => cfg.connections.lookupConnection(name));
|
|
4150
|
+
const result = await conn.runSQL(sql);
|
|
4151
|
+
rows = result?.rows ?? [];
|
|
4152
|
+
}
|
|
3877
4153
|
if (opts.json) {
|
|
3878
4154
|
process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
|
|
3879
4155
|
} else if (rows.length === 0) {
|
|
@@ -3888,22 +4164,22 @@ async function sqlCmd(connection, opts) {
|
|
|
3888
4164
|
|
|
3889
4165
|
// src/launch.ts
|
|
3890
4166
|
import { spawn as spawn2 } from "node:child_process";
|
|
3891
|
-
import
|
|
4167
|
+
import fs10 from "node:fs";
|
|
3892
4168
|
import os from "node:os";
|
|
3893
|
-
import
|
|
4169
|
+
import path11 from "node:path";
|
|
3894
4170
|
var SURFACE_FLAG = { author: "--develop", test: "--explore" };
|
|
3895
4171
|
var SERVER_KEY = { author: "malloyyo_author", test: "malloyyo_test" };
|
|
3896
4172
|
async function launchCmd(mode, opts) {
|
|
3897
|
-
const root =
|
|
3898
|
-
const tmpDir =
|
|
3899
|
-
const cfgPath =
|
|
4173
|
+
const root = path11.resolve(opts.root ?? process.cwd());
|
|
4174
|
+
const tmpDir = fs10.mkdtempSync(path11.join(os.tmpdir(), "malloyyo-launch-"));
|
|
4175
|
+
const cfgPath = path11.join(tmpDir, "mcp.json");
|
|
3900
4176
|
const cfg = {
|
|
3901
4177
|
mcpServers: {
|
|
3902
4178
|
// Absolute -C: an ephemeral config, so pinning the root is robust.
|
|
3903
4179
|
[SERVER_KEY[mode]]: { command: "malloyyo", args: ["mcp", SURFACE_FLAG[mode], "-C", root] }
|
|
3904
4180
|
}
|
|
3905
4181
|
};
|
|
3906
|
-
|
|
4182
|
+
fs10.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
3907
4183
|
const label = mode === "author" ? "AUTHOR (compile/edit)" : "TEST (claude.ai web preview)";
|
|
3908
4184
|
process.stderr.write(`\u25B6 launching Claude in ${label} mode over ${root}
|
|
3909
4185
|
`);
|
|
@@ -3922,11 +4198,11 @@ async function launchCmd(mode, opts) {
|
|
|
3922
4198
|
});
|
|
3923
4199
|
child.on("exit", () => resolve3());
|
|
3924
4200
|
});
|
|
3925
|
-
|
|
4201
|
+
fs10.rmSync(tmpDir, { recursive: true, force: true });
|
|
3926
4202
|
}
|
|
3927
4203
|
|
|
3928
4204
|
// package.json
|
|
3929
|
-
var version = "0.2.
|
|
4205
|
+
var version = "0.2.29";
|
|
3930
4206
|
|
|
3931
4207
|
// src/index.ts
|
|
3932
4208
|
function shortSha(sha) {
|