@malloy-publisher/skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/dist/index.d.ts +22 -0
  4. package/dist/index.js +47 -0
  5. package/package.json +54 -0
  6. package/skills/malloy/SKILL.md +45 -0
  7. package/skills/malloy-analysis/SKILL.md +76 -0
  8. package/skills/malloy-analysis-pitfalls/SKILL.md +68 -0
  9. package/skills/malloy-analysis-report/SKILL.md +125 -0
  10. package/skills/malloy-analyze/SKILL.md +262 -0
  11. package/skills/malloy-charts/SKILL.md +417 -0
  12. package/skills/malloy-debug/SKILL.md +121 -0
  13. package/skills/malloy-define/SKILL.md +194 -0
  14. package/skills/malloy-discover/SKILL.md +182 -0
  15. package/skills/malloy-document/SKILL.md +165 -0
  16. package/skills/malloy-getting-started/SKILL.md +57 -0
  17. package/skills/malloy-gotchas-modeling/SKILL.md +277 -0
  18. package/skills/malloy-gotchas-queries/SKILL.md +132 -0
  19. package/skills/malloy-gotchas-rendering/SKILL.md +124 -0
  20. package/skills/malloy-html-data-app-embedding/SKILL.md +39 -0
  21. package/skills/malloy-html-data-app-runtime/SKILL.md +158 -0
  22. package/skills/malloy-html-data-apps/SKILL.md +165 -0
  23. package/skills/malloy-html-data-apps/reference/lazy-load.md +84 -0
  24. package/skills/malloy-html-data-apps/reference/verification-harness.md +121 -0
  25. package/skills/malloy-lookml-review/SKILL.md +79 -0
  26. package/skills/malloy-lookml-review/reference/_concepts.md +42 -0
  27. package/skills/malloy-lookml-review/reference/build-derived-tables.md +110 -0
  28. package/skills/malloy-lookml-review/reference/build-unnest.md +47 -0
  29. package/skills/malloy-lookml-review/reference/curate-visibility.md +66 -0
  30. package/skills/malloy-lookml-review/reference/discover.md +102 -0
  31. package/skills/malloy-lookml-review/reference/document.md +28 -0
  32. package/skills/malloy-lookml-review/reference/propose-fields.md +75 -0
  33. package/skills/malloy-lookml-review/reference/review-coverage.md +102 -0
  34. package/skills/malloy-materialization-tuning/SKILL.md +103 -0
  35. package/skills/malloy-model/SKILL.md +320 -0
  36. package/skills/malloy-model/reference/access-modifiers.md +21 -0
  37. package/skills/malloy-model/reference/analysis-to-model.md +82 -0
  38. package/skills/malloy-model/reference/bridge-tables.md +51 -0
  39. package/skills/malloy-model/reference/normalized-schemas.md +66 -0
  40. package/skills/malloy-model/reference/query-sources.md +57 -0
  41. package/skills/malloy-modeling/SKILL.md +174 -0
  42. package/skills/malloy-notebook-chat/SKILL.md +19 -0
  43. package/skills/malloy-notebooks/SKILL.md +225 -0
  44. package/skills/malloy-patterns/SKILL.md +117 -0
  45. package/skills/malloy-phrase-detection/SKILL.md +75 -0
  46. package/skills/malloy-publish/SKILL.md +111 -0
  47. package/skills/malloy-queries/SKILL.md +270 -0
  48. package/skills/malloy-review/SKILL.md +160 -0
  49. package/skills/malloy-review/reference/output-template.md +164 -0
  50. package/skills/malloy-review/reference/rubric-correctness.md +161 -0
  51. package/skills/malloy-review/reference/rubric-documentation.md +69 -0
  52. package/skills/malloy-review/reference/rubric-governance.md +37 -0
  53. package/skills/malloy-review/reference/rubric-queries.md +78 -0
  54. package/skills/malloy-review/reference/rubric-rendering.md +127 -0
  55. package/skills/malloy-review/reference/rubric-structure.md +68 -0
  56. package/skills/malloy-review/reference/rubric-style.md +59 -0
  57. package/skills/malloy-review/reference/scope-resolution.md +100 -0
  58. package/skills/malloy-review/reference/severity-taxonomy.md +139 -0
  59. package/skills/malloy-scope/SKILL.md +100 -0
@@ -0,0 +1,158 @@
1
+ ---
2
+ name: malloy-html-data-app-runtime
3
+ description: Write the JavaScript that drives an in-package HTML data app, calling Publisher.query, building queries from filter state, and handling results and errors. Read before writing the page's data code.
4
+ ---
5
+
6
+ # HTML Data App Runtime
7
+
8
+ > `Publisher.query(modelPath, malloy)` returns an array of plain row objects. Build the Malloy string, let the model do the work, and render the rows with whatever front-end code you like.
9
+
10
+ The runtime loads from the root-relative `<script src="/sdk/publisher.js">` and adds one global, `window.Publisher`.
11
+
12
+ ## The query contract
13
+
14
+ | Call | Returns | Use for |
15
+ |---|---|---|
16
+ | `Publisher.query(modelPath, malloy, opts?)` | `Promise<Array>` of rows | driving your own charts and tables |
17
+ | `Publisher.queryFull(modelPath, malloy, opts?)` | `Promise<MalloyResult>` | handing to `<malloy-render>` |
18
+
19
+ - `modelPath` is the model FILE path within the package, with `/` separators (`"subscriptions.malloy"`, `"models/events.malloy"`). It is not the source name.
20
+ - `malloy` is any query string, written in standard Malloy. This skill covers only the JavaScript glue, not Malloy syntax.
21
+ - `opts` (all optional): `sourceName`, `queryName`, `givens` (a `{ name: value }` map bound to the model's Malloy `given:` runtime parameters for this query; safe parameterization, values are bound by the runtime, not string-interpolated), `filterParams` (values for the model's legacy `#(filter)` source filters), `bypassFilters`, and `environment` / `package` (only if the page is served from outside `/environments/<env>/packages/<pkg>/`). `givens` and `filterParams` compose (both apply).
22
+
23
+ ## Structure the app as modules, not one inline script
24
+
25
+ Past a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: put your shared libraries first as plain globals, then one ES-module entry point that `import`s your own files.
26
+
27
+ ```html
28
+ <!-- Globals first: the runtime, then any vendored chart library. -->
29
+ <script src="/sdk/publisher.js"></script>
30
+ <script src="./vendor/chart.umd.js"></script>
31
+ <!-- One module entry; it imports the rest. ES modules resolve with no bundler. -->
32
+ <script type="module" src="./app.js"></script>
33
+ ```
34
+
35
+ A separation that keeps each piece testable and changeable on its own:
36
+
37
+ - **`format.js`**. Pure functions only: number/date formatting, a series-align-by-month helper, status thresholds. No DOM, no globals. This is the file `node --test` can cover directly.
38
+ - **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library.
39
+ - **`tiles.js`**. Your tiles as *data*: for each, its model/source/view (and target source, if any), plus a pure `build(rows)` that shapes query rows for the chart. This is the single source of truth for what each tile queries.
40
+ - **`app.js`**. The thin entry point: reads `tiles.js`, runs the queries, wires results to the DOM. Adding a tile means adding a `tiles.js` entry, not editing `app.js`.
41
+
42
+ Declare each tile's source and view names once, in `tiles.js`, and have everything else (render code, any agent prompt, tests) read from there. A second copy of those names in another file is the classic drift bug, and a *derived* name (`okr_4_4_2_targets` invented from a tile code) is simply wrong: a target source may have an irregular name or not exist at all. Read the model; don't compute names.
43
+
44
+ ## Patterns that work
45
+
46
+ These run against the example `html-data-app` package (source `subscriptions`; views `plan_mix`, `mrr_by_industry`, `kpis`). Swap in your own model and view names.
47
+
48
+ Run a named view:
49
+
50
+ ```js
51
+ const rows = await Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix");
52
+ ```
53
+
54
+ Refine a view from UI state by appending a `where:`. Restrict the values to ones you control (for example a dropdown populated from the model's own distinct values) and escape each interpolated value with a backslash before quotes and backslashes (Malloy rejects the SQL-style `''` doubling). An unescaped apostrophe in a value breaks out of the literal:
55
+
56
+ ```js
57
+ function whereClause(state) {
58
+ const q = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); // backslash-escape for Malloy
59
+ const parts = [];
60
+ if (state.plan) parts.push(`plan = '${q(state.plan)}'`);
61
+ if (state.industry) parts.push(`industry = '${q(state.industry)}'`);
62
+ return parts.length ? `where: ${parts.join(", ")}` : "";
63
+ }
64
+ const rows = await Publisher.query(
65
+ "subscriptions.malloy",
66
+ `run: subscriptions -> plan_mix + { ${whereClause(state)} }`,
67
+ );
68
+ ```
69
+
70
+ Do not interpolate free-text or otherwise untrusted input into the query string. Route parameterized input through `opts.givens` (or the legacy `opts.filterParams`) instead: those values are bound by the runtime as typed parameters, not string-interpolated, so they can't inject query syntax. (One nuance: a `filter<T>`-typed given takes Malloy filter syntax as its value, so validate it against a known set like any other input; scalar givens carry no syntax at all.) `opts.givens` is safe *parameterization*, not an authorization boundary: a client-supplied given is client-trusted unless a server upstream (a trusted gateway, or an operator's per-package config) strips or finalizes it. Where you must build query text from input, constrain it to a known set and escape it, or keep the filtering in model-defined views.
71
+
72
+ KPI or single-row view. Destructure element zero:
73
+
74
+ ```js
75
+ const [kpis] = await Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis");
76
+ el.textContent = kpis.active_mrr; // the result is an array; kpis.active_mrr, not rows.active_mrr
77
+ ```
78
+
79
+ Refresh a dashboard. Fire the tiles together:
80
+
81
+ ```js
82
+ const [planMix, byIndustry, kpisRows] = await Promise.all([
83
+ Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix"),
84
+ Publisher.query("subscriptions.malloy", "run: subscriptions -> mrr_by_industry"),
85
+ Publisher.query("subscriptions.malloy", "run: subscriptions -> kpis"),
86
+ ]);
87
+ ```
88
+
89
+ Prefer defining the views in the model (one per tile, pre-aggregated and sorted) over building long query strings in JS.
90
+
91
+ Get the numbers right. The fastest way to ship a wrong-but-convincing dashboard is to paper over missing data:
92
+
93
+ - **Missing is not zero.** When you join two series (actuals to a separately-keyed target) and a key is absent, leave it `null` so the chart skips it. Do not `|| 0`, which plots a real-looking zero and reads as "we hit nothing that month." Align on a normalized key (`"YYYY-MM"`), and let the renderer omit null points:
94
+
95
+ ```js
96
+ // monthKey/monthLabel are your own format.js helpers: monthKey normalizes a
97
+ // date to a "YYYY-MM" string; monthLabel formats it for display.
98
+ // target may not cover every actual month; an absent month stays null, never 0.
99
+ const target = new Map(planRows.map((r) => [monthKey(r.plan_month), Number(r.target_revenue)]));
100
+ const data = actualRows.map((r) => ({
101
+ label: monthLabel(r.order_month),
102
+ actual: Number(r.revenue),
103
+ target: target.has(monthKey(r.order_month)) ? target.get(monthKey(r.order_month)) : null,
104
+ }));
105
+ ```
106
+
107
+ - **"Current" means latest non-null.** For a KPI scorecard, scan back to the last month that actually has data rather than reading the final row, which may be an incomplete current month.
108
+ - **Guard division in Malloy, not after.** `avg(paid / nullif(active, 0))`. A `nullif` in the query beats catching `Infinity`/`NaN` in JS.
109
+ - **Convert units explicitly.** If the model stores a 0 to 1 fraction and you show a percent, multiply once in `build()` and comment it. Mismatched units are a silent off-by-100.
110
+
111
+ Loading, empty, and error states. Handle all three; a bare `.then()` that assumes rows leaves the page blank when the query is slow or fails:
112
+
113
+ ```js
114
+ const el = document.getElementById("out");
115
+ el.textContent = "Loading...";
116
+ Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix")
117
+ .then((rows) => {
118
+ if (!rows.length) { el.textContent = "No data."; return; }
119
+ render(rows);
120
+ })
121
+ .catch((err) => {
122
+ el.textContent = `Query failed (${err.status ?? ""}): ${err.response?.message ?? err.message}`;
123
+ });
124
+ ```
125
+
126
+ Render through `<malloy-render>`. `queryFull` returns the full Malloy result envelope (the JSON form of the server's result, not a live result object) to hand to the component:
127
+
128
+ ```js
129
+ const el = document.querySelector("malloy-render");
130
+ el.result = await Publisher.queryFull("subscriptions.malloy", "run: subscriptions -> plan_mix");
131
+ ```
132
+
133
+ Publisher does not serve or bundle `<malloy-render>`; you must obtain a built component bundle matched to your model's Malloy version and vendor it into `public/` yourself, then confirm it accepts the envelope as-is. The shipped example renders rows with a plain chart library instead, so this path is not exercised there. A view tagged in the model (for example `# bar_chart`) drives how it draws.
134
+
135
+ Validate every query before wiring it into render code, using whatever query tool your environment provides, or by POSTing the query to a running Publisher at `/api/v0/environments/<env>/packages/<pkg>/models/<modelPath>/query` with body `{"compactJson":true,"query":"..."}`, or by running `Publisher.query` once and logging the rows. Malloy names result columns after the `group_by` / `aggregate` field names (`group_by: plan` gives a `plan` column; `aggregate: account_count` gives an `account_count` column), so confirm those names against real output before you read them.
136
+
137
+ If you validate the rendered page in a headless browser (Playwright or Puppeteer), do not wait for network idle: `publisher.js` holds the live-reload SSE stream open, so the page never reaches it. Wait on `domcontentloaded` or `load` plus a content selector instead.
138
+
139
+ ## Context, auth, live reload (all automatic)
140
+
141
+ - Context. A page served under `/environments/<env>/packages/<pkg>/...` infers its environment and package, so `query` needs no env or package args. Serving from elsewhere? Pass `opts.environment` and `opts.package`.
142
+ - Auth. By default the runtime sends cookies (`credentials: include`), so a signed-in user is authenticated with no code. For a bearer token, call `Publisher.setToken(token)` first; `Publisher.setToken(null)` reverts to cookies.
143
+ - Live reload. Under `--watch-env`, the page reloads on package changes by itself. Nothing to wire.
144
+
145
+ ## When the app fails
146
+
147
+ | Symptom | Likely cause and fix |
148
+ |---|---|
149
+ | 404 or "model not found" | `modelPath` wrong. It is the file path (`"subscriptions.malloy"`), with `/` separators, not the source name. |
150
+ | "source/view not defined" | View or source name guessed. Read the model (your environment's context tool, or open the `.malloy` file) and use the real names. |
151
+ | Promise rejects, message starts `Publisher.query:` | Read `error.status` and `error.response` for the server's reason (compile error, missing required parameter, permission). |
152
+ | Empty array when you expect rows | Filter value mismatch (case, spelling, type, or a non-ASCII character like `≤` or an en-dash in the literal). Copy the literal verbatim from the model, do not retype the user's paraphrase, and confirm it with a distinct-values query (`run: src -> { group_by: the_dimension }`). Quote strings, use `@` for dates. |
153
+ | 400 on a given (ungated source) | An unknown given name (check spelling; names are case-sensitive), a required given left unset, or a value that doesn't fit the declared type. Malloy rejects it when preparing the query; supply declared givens via `opts.givens` with the right shape (see the givens type table). |
154
+ | 403 on a query that should be allowed, when passing givens to a gated source | On a source with `#(authorize)`, a bad given (unknown name or wrong-typed value) fails closed in the authorize check, so it looks like access denied rather than validation. Check the given names and values against the model. |
155
+ | KPI shows `undefined` | The result is an array. Read `rows[0].field` (or destructure `const [k] = ...`), not `rows.field`. |
156
+ | Page loads in dev but is not listed or not served | The file is not under the package's `public/` directory. Publisher serves only `public/`; a page written anywhere else (for example `/tmp`) is never reachable at `/environments/<env>/packages/<pkg>/<file>`. |
157
+ | Queries fail only when embedded cross-origin | Cookies are not sent cross-site. Serve same-origin, or pass a bearer token. |
158
+ | No live reload | Watch mode is off. Start with `--watch-env <env>`; without it the events stream reports `mode: disabled` and never reloads. |
@@ -0,0 +1,165 @@
1
+ ---
2
+ name: malloy-html-data-apps
3
+ description: Build or modify an in-package HTML data app for a Malloy Publisher package (a public/ directory the package serves). Use when the user wants a hand-authored HTML dashboard or web page backed by a package's Malloy models, with no build step.
4
+ ---
5
+
6
+ # In-Package HTML Data Apps
7
+
8
+ > A package becomes a web app by adding a `public/` directory. Publisher serves those files and gives the page `Publisher.query(...)` to run Malloy against the package's models. No build step, no npm, no framework.
9
+
10
+ ## When this is the right tool
11
+
12
+ | The user wants | Use |
13
+ |---|---|
14
+ | A hand-authored HTML/JS dashboard, no toolchain | this skill (an HTML data app) |
15
+ | A React app with managed components | the Publisher React SDK (out of scope here) |
16
+ | An analyst notebook with charts | a Malloy notebook (`.malloynb`) |
17
+ | Point-and-click exploration, no code | the Publisher Explorer |
18
+
19
+ Pick an HTML data app when the user wants full control of the markup and only plain web files.
20
+
21
+ ## Package anatomy
22
+
23
+ ```
24
+ my-package/
25
+ publisher.json # name, version, description
26
+ subscriptions.malloy # the model(s), stays private
27
+ subscriptions.parquet # data, stays private
28
+ public/ # ONLY this directory is web-served
29
+ index.html
30
+ app.js
31
+ vendor/ # chart library, vendored rather than loaded from a CDN
32
+ chart.umd.js
33
+ ```
34
+
35
+ Only `public/` is reachable over the web, at `/environments/<env>/packages/<pkg>/<file>`. Models, data, and `publisher.json` are private and reached only through the query API, which still applies the model's filters, access modifiers, and authorize rules. There is no flag to set: a `public/` directory is what makes a package an app.
36
+
37
+ ## Build sequence
38
+
39
+ The agent orchestrates these. Each query and chart step hands off to a focused skill.
40
+
41
+ 1. READ THE MODEL FIRST. Get the model's real source and view names, through your environment's context tool if it has one, or by opening the `.malloy` file directly. Never guess field or view names.
42
+ 2. SCAFFOLD the package (template below).
43
+ 3. WRITE THE QUERIES with `skill:malloy-html-data-app-runtime`. Validate each before pasting it into the page, using whatever query tool your environment provides or a running Publisher (see `skill:malloy-html-data-app-runtime`). Malloy syntax questions go to `skill:malloy-queries`.
44
+ 4. CHOOSE CHARTS with `skill:malloy-charts` when rendering through `<malloy-render>`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into `public/` and load it locally, not from a CDN. Two reasons: embedded author JavaScript runs with the viewing user's data authority, and a blocked CDN (agent sandboxes and many corporate networks block them) is easy to miss, because the script never runs and the charts come up empty. The `html-data-app` and `storefront` examples both ship their chart library in `public/vendor/` and load it from `public/index.html` as `./vendor/chart.umd.js`. Copy that, but resolve the path against the page's own directory: a page in a subdirectory (`public/reports/index.html`) needs `../vendor/chart.umd.js`. A wrong relative path 404s and leaves the charts blank, which is the failure you are trying to avoid.
45
+ 5. EMBED (optional) with `skill:malloy-html-data-app-embedding`.
46
+ 6. PREVIEW with the local authoring loop (below).
47
+ 7. VERIFY before you call it done (see "What 'done' means" below). This step is not optional.
48
+
49
+ The scaffold in step 2 only proves the wiring. It is the start, not the deliverable. What you ship is a production app that meets the recipe below.
50
+
51
+ ## What "done" means (production recipe)
52
+
53
+ A data app you can defend has all of these. Build to this list, not to the scaffold.
54
+
55
+ - **Real names, never guessed.** Every source, view, and field name comes from the model you read in step 1. A name you derived or assumed is a bug waiting to surface as an empty tile.
56
+ - **DOM-only - never `innerHTML` with interpolated values.** Build every element with `createElement` + `textContent`; do not assign `innerHTML` (or `insertAdjacentHTML`, `document.write`) with any string that contains a model value. Query results render any markup they contain - an XSS vector, and blocked outright under a Trusted-Types CSP. This is a hard build rule, not a lint suggestion: an app that interpolates a model value into `innerHTML` is not done.
57
+ - **Modular, not one inline blob.** Split the page into modules per `skill:malloy-html-data-app-runtime` (pure formatting helpers, a chart layer, your tile/query definitions as data, a thin entry point). One source of truth for each tile's model/source/view, no parallel maps that drift.
58
+ - **Every tile handles loading, empty, and error on its own.** One failing query must not blank the page. (`skill:malloy-html-data-app-runtime`.)
59
+ - **Defensible numbers.** Missing ≠ zero (omit the point, don't plot a fake 0); show the latest non-null value for "current"; guard division with `nullif`; convert units explicitly. (`skill:malloy-html-data-app-runtime`.)
60
+ - **Visible assumptions.** When you assume something (two sources joined by month, an in-month proxy that differs from a certified definition) or a metric is incomplete, say so *in the app*: a caption, a footnote, a placeholder card with the reason. The non-technical user cannot see your reasoning; bury a caveat and you have misled them. Don't silently drop a metric you couldn't model. Show a placeholder that names what's missing and why.
61
+ - **Looks decent.** Give it real layout, type, and color: a styled card grid with a clear hierarchy, not raw unstyled tables.
62
+ - **Vendored libraries.** Chart and helper libraries live in `public/`, loaded locally (step 4).
63
+ - **Lazy-load below the fold (once there are many tiles).** Don't fire every tile's query on load. `reference/lazy-load.md` is the recipe: `IntersectionObserver` (rootMargin ~240px) + a small concurrency cap + reserve each tile's height so lazy tiles don't reflow. Includes the verification trap - on a short/tall-default viewport all tiles intersect at once and you get a false "everything deferred" pass, so test on a deliberately small viewport.
64
+
65
+ ### Verify before you call it done
66
+
67
+ You are building for someone who cannot tell a correct dashboard from a broken one. Verification is your job, not theirs.
68
+
69
+ - **Validate every query against the model before wiring it in** (step 3): confirm it compiles and that the column names match what your render code reads.
70
+ - **Load the finished page and confirm every tile shows real numbers**: not stuck on "Loading…", not an error, not an empty state you didn't intend. In a headless browser, wait on `load` plus a content selector, not network idle (`publisher.js` holds an SSE stream open; see `skill:malloy-html-data-app-runtime`). Don't hand-roll this each time - `reference/verification-harness.md` is a copy-adaptable recipe: a mock `sdk/publisher.js` returning canned rows keyed by `(model, query)`, a `python3 -m http.server` webroot, and Playwright assertions (KPIs non-null, no `.is-error`, no stuck `.kit-skeleton`, a chart/table present). It also documents the false-"stuck skeleton" trap (assert after the mock's async delay, never on `networkidle`).
71
+ - **Unit-test any non-trivial pure logic** (a month-join, a de-cumulation, a unit conversion). Keep that logic in DOM-free helpers so `node --test` can cover it, and run it.
72
+
73
+ ## Minimal scaffold
74
+
75
+ `publisher.json` at the package root:
76
+
77
+ ```json
78
+ { "name": "my-package", "version": "0.0.1", "description": "..." }
79
+ ```
80
+
81
+ `public/index.html` is a NEW file you create (make the `public/` directory if it does not exist). Load the runtime root-relative, then query. The examples below use the shipped `html-data-app` package (source `subscriptions`); swap in your own model and a view it defines.
82
+
83
+ Start with the smallest page that proves the wiring, dumping the rows:
84
+
85
+ ```html
86
+ <!doctype html>
87
+ <title>My dashboard</title>
88
+ <pre id="out"></pre>
89
+ <script src="/sdk/publisher.js"></script>
90
+ <script>
91
+ Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix").then((rows) => {
92
+ document.getElementById("out").textContent = JSON.stringify(rows, null, 2);
93
+ });
94
+ </script>
95
+ ```
96
+
97
+ Then render the rows. This page builds a table from whatever columns the view returns, so it does not depend on the exact field names:
98
+
99
+ ```html
100
+ <!doctype html>
101
+ <title>Account mix by plan</title>
102
+ <table id="t"><thead></thead><tbody></tbody></table>
103
+ <script src="/sdk/publisher.js"></script>
104
+ <script>
105
+ Publisher.query("subscriptions.malloy", "run: subscriptions -> plan_mix").then((rows) => {
106
+ const t = document.getElementById("t");
107
+ if (!rows.length) { t.textContent = "No rows."; return; }
108
+ const cols = Object.keys(rows[0]);
109
+ const headRow = t.tHead.insertRow();
110
+ for (const c of cols) {
111
+ const th = document.createElement("th");
112
+ th.textContent = c;
113
+ headRow.appendChild(th);
114
+ }
115
+ for (const r of rows) {
116
+ const tr = t.tBodies[0].insertRow();
117
+ for (const c of cols) tr.insertCell().textContent = r[c];
118
+ }
119
+ });
120
+ </script>
121
+ ```
122
+
123
+ Build row content with `textContent`, not `innerHTML` with model values: an `innerHTML` table renders any markup a value contains. This is the HTML-output side of the don't-trust-interpolated-values rule that `skill:malloy-html-data-app-runtime` applies to Malloy query strings.
124
+
125
+ Two invariants break a page most often:
126
+
127
+ - **The file must live under `public/`.** Publisher serves only `public/`, so a page written anywhere else (for example `/tmp`) is never reachable at `/environments/<env>/packages/<pkg>/<file>`.
128
+ - **The script src must be the root-relative `/sdk/publisher.js`**, not a relative path.
129
+
130
+ A third gotcha: the first argument to `Publisher.query` is the model FILE path (`"subscriptions.malloy"`), not the source name.
131
+
132
+ ## Authoring loop and publishing
133
+
134
+ Authoring happens locally, then you publish. These are two stages.
135
+
136
+ ### Author locally (with live reload)
137
+
138
+ Run a local Publisher from the directory that holds your `publisher.config.json` and package folder(s):
139
+
140
+ ```sh
141
+ npx @malloy-publisher/server --server_root . --port 4000 --watch-env <env>
142
+ ```
143
+
144
+ `--watch-env <env>` (or `PUBLISHER_WATCH=<env>`) mounts that environment's local-dir packages in place (a symlink, not a copy) and watches them: editing a `.malloy` recompiles the package, and editing a `public/` file live-reloads any open page over an SSE stream. Nothing to wire in the page. The app is served at `http://localhost:4000/environments/<env>/packages/<pkg>/index.html`.
145
+
146
+ `publisher.config.json` (at `--server_root`) declares the environment, its packages, and its connections:
147
+
148
+ ```json
149
+ {
150
+ "frozenConfig": false,
151
+ "environments": [
152
+ {
153
+ "name": "<env>",
154
+ "packages": [{ "name": "<pkg>", "location": "./<pkg>" }],
155
+ "connections": []
156
+ }
157
+ ]
158
+ }
159
+ ```
160
+
161
+ A local package uses a filesystem `location` (`"./<pkg>"`, relative to the directory holding `publisher.config.json`); a remote one uses a GitHub `tree` URL. If one model in the package fails to compile, the **whole package** fails to load, so a stray notebook/model error blanks every tile. (Common one: a `.malloynb` whose cells each `import "x.malloy"`, the notebook compiles as one batch, so the repeated import errors `Cannot redefine 'x'`. Import once in the first cell.)
162
+
163
+ ### Publishing
164
+
165
+ Publishing an app is publishing its package: get the package into publishable shape and hand it to your host's publishing workflow. A deployed package serves its `public/` app the same way a local one does, at `/environments/<env>/packages/<pkg>/<file>`. A deployed environment has no `--watch-env` live reload, so the loop there is author, publish, then view.
@@ -0,0 +1,84 @@
1
+ # Lazy-Loading Tiles Below the Fold
2
+
3
+ > Defer off-screen tile queries until they scroll into view, so a dashboard with many tiles doesn't fire every query on load. Referenced from `SKILL.md`. Add this once a page has enough tiles that loading them all at once is wasteful or slow.
4
+
5
+ Three parts, all required — drop any one and you get a subtle bug rather than an obvious one:
6
+
7
+ 1. **`IntersectionObserver`** with a `rootMargin` (~240px) so a tile starts loading just *before* it scrolls into view, not the instant it appears.
8
+ 2. **A small concurrency cap** so a fast scroll to the bottom doesn't fire twenty queries at once.
9
+ 3. **Reserve each tile's height** while its skeleton shows, so lazy-loaded tiles don't reflow the page and retrigger the observer.
10
+
11
+ ```js
12
+ // lazy.js — observe tiles, run each tile's query once, capped concurrency.
13
+ const MARGIN = "240px"; // start loading before the tile is visible
14
+ const MAX_INFLIGHT = 3; // cap concurrent queries
15
+
16
+ let inflight = 0;
17
+ const queue = [];
18
+
19
+ function pump() {
20
+ while (inflight < MAX_INFLIGHT && queue.length) {
21
+ const run = queue.shift();
22
+ inflight++;
23
+ run().finally(() => { inflight--; pump(); });
24
+ }
25
+ }
26
+
27
+ // loadTile(el) runs the tile's query (from tiles.js) and renders it; returns a Promise.
28
+ export function lazyLoad(tileEls, loadTile) {
29
+ const io = new IntersectionObserver((entries, obs) => {
30
+ for (const e of entries) {
31
+ if (!e.isIntersecting) continue;
32
+ const el = e.target;
33
+ obs.unobserve(el); // load once; never re-fire
34
+ queue.push(() => loadTile(el));
35
+ pump();
36
+ }
37
+ }, { rootMargin: MARGIN });
38
+
39
+ for (const el of tileEls) {
40
+ reserveHeight(el); // see below — prevents reflow loops
41
+ io.observe(el);
42
+ }
43
+ }
44
+
45
+ // Reserve space before the query resolves. Without this, an empty tile has ~0
46
+ // height, so many tiles intersect at once (false "all visible"), and when each
47
+ // resolves the page reflows and the observer re-fires. Give the skeleton a real
48
+ // min-height matched to the rendered tile.
49
+ function reserveHeight(el) {
50
+ if (!el.style.minHeight) el.style.minHeight = "220px"; // match your tile height
51
+ }
52
+ ```
53
+
54
+ ## Verification gotcha — test on a *small* viewport
55
+
56
+ On a short page (or a tall default headless viewport like 1280×720+), every tile intersects the `rootMargin` box at once, so *nothing* actually defers — and your test reports a false "everything lazy-loaded correctly" pass while the feature does nothing. **Verify with a deliberately small viewport** so tiles genuinely start off-screen:
57
+
58
+ ```js
59
+ // In the Playwright harness (see reference/verification-harness.md):
60
+ await page.setViewportSize({ width: 480, height: 520 }); // force real off-screen tiles
61
+
62
+ const total = (await page.$$(".tile")).length;
63
+ const loaded = () => page.$$eval(".tile .value", (e) => e.length);
64
+
65
+ // Let the top tiles settle, THEN measure. Don't sample right after the first
66
+ // value: with MAX_INFLIGHT capped, an eager (broken) page and a correct page
67
+ // both show only ~cap tiles at that instant — the guard wouldn't fire. Wait for
68
+ // the loaded count to go quiet, then require that NOT ALL tiles loaded.
69
+ await page.waitForFunction(() => document.querySelector(".tile .value"));
70
+ let prev = -1, settled = await loaded();
71
+ while (settled !== prev) { prev = settled; await page.waitForTimeout(150); settled = await loaded(); }
72
+ if (settled >= total) throw new Error("nothing deferred — viewport too tall to test lazy-load");
73
+
74
+ // Scroll INCREMENTALLY, one viewport at a time. A single jump to the bottom
75
+ // skips past mid-page tiles — IntersectionObserver never fires for them, their
76
+ // queries never run, and the final wait times out even though lazy-load works.
77
+ const vh = 520;
78
+ const pageH = await page.evaluate(() => document.body.scrollHeight);
79
+ for (let y = 0; y <= pageH; y += vh) {
80
+ await page.evaluate((yy) => window.scrollTo(0, yy), y);
81
+ await page.waitForTimeout(150); // let observers fire + queries resolve
82
+ }
83
+ await page.waitForFunction((n) => document.querySelectorAll(".tile .value").length >= n, total);
84
+ ```
@@ -0,0 +1,121 @@
1
+ # Headless Verification Harness
2
+
3
+ > Scaffolding to verify a finished data app end-to-end without a live warehouse. Referenced from `SKILL.md` ("Verify before you call it done"). You are building for someone who cannot tell a correct dashboard from a broken one — this harness is how you check, so they don't have to.
4
+
5
+ The app talks to the world through exactly one seam: `window.Publisher.query(modelPath, malloy)`. Mock that seam and you can load the real page in a real browser with canned data, then assert on what actually rendered.
6
+
7
+ ## 1. Mock `sdk/publisher.js`
8
+
9
+ Serve a stand-in at the same root-relative path the page loads (`/sdk/publisher.js`). It returns canned rows keyed by `(modelPath, query)`, and — this is the part that bites — it reproduces the **async delay** of the real runtime, so your assertions exercise the loading→loaded transition instead of racing a synchronous stub.
10
+
11
+ ```js
12
+ // mock/sdk/publisher.js — served at /sdk/publisher.js during verification
13
+ (function () {
14
+ // Key canned data by "modelPath::query" (exact strings from tiles.js).
15
+ const FIXTURES = {
16
+ "carriers.malloy::run: carriers -> kpis": [{ total: 1234, active: 1180 }],
17
+ "carriers.malloy::run: carriers -> by_letter": [
18
+ { letter: "A", n: 12 }, { letter: "B", n: 7 },
19
+ ],
20
+ // ...one entry per (model, query) your tiles.js declares
21
+ };
22
+ const DELAY_MS = 40; // > 0 on purpose: mimic the real async round-trip
23
+
24
+ function resolve(modelPath, malloy) {
25
+ const rows = FIXTURES[`${modelPath}::${malloy}`];
26
+ // Unknown key = test bug (query string drifted). Fail loudly, don't return [].
27
+ if (!rows) return Promise.reject(new Error(`No fixture for ${modelPath}::${malloy}`));
28
+ return new Promise((r) => setTimeout(() => r(rows.map((x) => ({ ...x }))), DELAY_MS));
29
+ }
30
+ window.Publisher = {
31
+ query: resolve,
32
+ // Placeholder shape ONLY. The real queryFull returns a Malloy result
33
+ // *envelope* handed to `<malloy-render>` el.result (see
34
+ // skill:malloy-html-data-app-runtime), NOT { data: rows }. If any tile renders
35
+ // via <malloy-render>, make this fixture a real envelope or that tile breaks.
36
+ queryFull: (m, q) => resolve(m, q).then((rows) => ({ data: rows })),
37
+ setToken() {},
38
+ };
39
+ })();
40
+ ```
41
+
42
+ Point the harness at the mock by serving it *over* the real path. Copy `public/` and the mock into a webroot so `/sdk/publisher.js` resolves to the mock:
43
+
44
+ ```sh
45
+ webroot=$(mktemp -d)
46
+ cp -r public/* "$webroot"/
47
+ mkdir -p "$webroot/sdk" && cp mock/sdk/publisher.js "$webroot/sdk/publisher.js"
48
+ python3 -m http.server 4173 --directory "$webroot" &
49
+ server=$!
50
+ trap 'kill "$server" 2>/dev/null; rm -rf "$webroot"' EXIT # always tear the server down
51
+ ```
52
+
53
+ ## 2. Drive it with Playwright and assert on the rendered DOM
54
+
55
+ ```js
56
+ // verify.mjs — node verify.mjs (assumes the server above is on :4173)
57
+ import { chromium } from "playwright";
58
+
59
+ const browser = await chromium.launch();
60
+ const page = await browser.newPage();
61
+ const errors = [];
62
+ page.on("pageerror", (e) => errors.push(e.message));
63
+ page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
64
+
65
+ await page.goto("http://localhost:4173/index.html", { waitUntil: "load" });
66
+ // publisher.js holds an SSE stream open (even with watch off), so networkidle NEVER fires.
67
+ // Wait on CONTENT — and for ALL tiles to RESOLVE, not just the first to appear.
68
+ // Asserting after only the first .value renders races the others and yields a
69
+ // false "stuck skeleton" / "empty value" FAIL. "Resolved" = every tile has a
70
+ // value and no skeleton remains. This single wait subsumes the DELAY_MS delay
71
+ // AND the stuck-skeleton check; if it times out, a tile really is stuck.
72
+ // NOTE: assumes a NON-lazy page — every tile loads on open. For a lazy-loaded
73
+ // page (reference/lazy-load.md) below-fold tiles legitimately keep their
74
+ // skeletons until scrolled in, so this wait would (correctly) time out. Test a
75
+ // lazy page with the scroll-and-assert loop in lazy-load.md instead.
76
+ await page.waitForFunction(() => {
77
+ const tiles = [...document.querySelectorAll(".tile")];
78
+ // Assert PER TILE, not global counts. `.tile .value` count >= tile count
79
+ // false-FAILs a chart/table tile (it has no .value) and false-PASSes when one
80
+ // multi-.value KPI tile inflates the total enough to mask a silently-empty
81
+ // sibling. "Resolved" = each tile shows its OWN content: a value, a chart, a
82
+ // table row, or its error state.
83
+ return tiles.length > 0 &&
84
+ document.querySelectorAll(".kit-skeleton").length === 0 &&
85
+ tiles.every((t) => t.querySelector(".value, canvas, table tbody tr, .is-error"));
86
+ }, null, { timeout: 5000 });
87
+
88
+ // Per tile: flag any tile that is content-empty (nothing rendered at all) or
89
+ // whose KPI values are blank/"NaN". A global .value sweep would let a valueless
90
+ // tile vanish; walking tiles keeps every one accountable.
91
+ const tileReport = await page.$$eval(".tile", (tiles) =>
92
+ tiles.map((t, i) => {
93
+ const vals = [...t.querySelectorAll(".value")].map((e) => e.textContent.trim());
94
+ return {
95
+ i,
96
+ empty: !t.querySelector(".value, canvas, table tbody tr, .is-error"),
97
+ errored: !!t.querySelector(".is-error"),
98
+ badVals: vals.filter((v) => !v || /^(loading|nan|undefined|null)$/i.test(v)),
99
+ };
100
+ }));
101
+
102
+ const problems = [];
103
+ const emptyTiles = tileReport.filter((t) => t.empty);
104
+ const badValueTiles = tileReport.filter((t) => t.badVals.length);
105
+ const errorTiles = tileReport.filter((t) => t.errored);
106
+ if (emptyTiles.length) problems.push(`empty tiles (no content): ${emptyTiles.map((t) => t.i)}`);
107
+ if (badValueTiles.length) problems.push(`blank/NaN values: ${JSON.stringify(badValueTiles)}`);
108
+ if (errorTiles.length) problems.push(`${errorTiles.length} tile(s) in error state: ${errorTiles.map((t) => t.i)}`);
109
+ if (errors.length) problems.push(`console/page errors: ${errors.join(" | ")}`);
110
+
111
+ await browser.close();
112
+ if (problems.length) { console.error("FAIL:\n- " + problems.join("\n- ")); process.exit(1); }
113
+ console.log(`OK — all ${tileReport.length} tiles rendered content`);
114
+ ```
115
+
116
+ ## Gotchas (each cost a real debugging cycle)
117
+
118
+ - **Wait out the mock's async delay before asserting.** Asserting immediately after `load` reads the skeleton, not the resolved tile, and reports a false "stuck skeleton." Wait until every tile shows its own resolved content (the per-tile `waitForFunction` above), never on `networkidle` — `publisher.js` keeps the live-reload SSE stream open, so the page never reaches network idle.
119
+ - **A missing fixture is a test bug, not empty data.** Reject on an unknown `(model, query)` key so a drifted query string fails loudly, instead of returning `[]` and masquerading as a real empty state.
120
+ - **Assert on the rendered DOM, not on `Publisher.query` return values.** The bugs live in the render path (NaN formatting, wrong column read, `|| 0` faking a zero). Reading the query result back proves nothing the model didn't already prove.
121
+ - Match the selectors (`.tile .value`, `.is-error`, `.kit-skeleton`, `canvas`) to whatever your app actually emits.
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: malloy-lookml-review
3
+ description: Analyze LookML files as prior art for Malloy modeling. Used during Step 1 (DISCOVER) when .lkml files are present. Coordinates reference files that extract business logic, relationships, and curation decisions. Works with or without a database connection.
4
+ ---
5
+
6
+ # LookML Review
7
+
8
+ > **Purpose:** Evaluate a LookML project as prior art for building a Malloy semantic model. This skill coordinates the LookML adapter. The implementation lives in reference files under `reference/`.
9
+
10
+ > **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.
11
+
12
+ > **This is NOT a blind conversion.** Each LookML pattern is evaluated for quality and relevance to Malloy. Bad practices, Looker-specific UI patterns, and performance-only constructs are identified and skipped.
13
+
14
+ ## When to Use
15
+
16
+ - **Auto-detected:** The agent finds `.lkml` files during Step 1 (DISCOVER) and the user confirms they should be used as prior art.
17
+ - **Explicitly requested:** The user says "model from LookML", "convert LookML", or provides a path to LookML files.
18
+
19
+ ## Two Modes
20
+
21
+ | Mode | When | Behavior |
22
+ |------|------|----------|
23
+ | **LookML + live data** | A connection is configured and you can query the data | LookML provides prior art; the data validates it. Full data-driven proposals. |
24
+ | **LookML only** | No connection, or queries return nothing | LookML provides all context. Proposals flagged as **unvalidated**. |
25
+
26
+ If in LookML-only mode, warn the user: "No database connection found. I'll use LookML as the sole source of context, but proposals cannot be validated against live data."
27
+
28
+ ## Numeric Parity Validation (preflight before you trust the Looker path)
29
+
30
+ To prove the Malloy numbers match Looker, there are two channels, and the "obvious" one fails silently more often than you'd expect.
31
+
32
+ **Preflight the Looker-API path before attempting it.** Running the original explore through the Looker API only works if the API service account **satisfies that explore's `required_access_grants`**. A service account that doesn't (e.g. its `org_id` user attribute is empty/`NULL`, or an `*_user_id` attribute the grant keys on is unset) gets a **404 on every restricted explore**, indistinguishable at a glance from "explore not found", and cannot self-provision without `administer`/`sudo`. So before you build a parity harness on the Looker API:
33
+
34
+ 1. Identify the target explore's `required_access_grants` and the user attributes they key on.
35
+ 2. Verify the service account actually has non-empty values for those attributes. If it doesn't, the Looker path is a dead end: don't spend time discovering that through 404s.
36
+
37
+ **SQL-level parity against the same warehouse is a first-class fallback, not a consolation prize.** When the Looker path is blocked (or just as the primary method), validate by running equivalent SQL directly against the **same warehouse** the LookML explore reads and comparing to the Malloy result (`execute_query`). This is what actually validates the numbers in practice: reach for it first if access grants are in doubt.
38
+
39
+ ## Reference Files
40
+
41
+ Each reference file is loaded by the workflow phase that needs it (via dispatch tables in each phase skill). You do not need to read them all at once.
42
+
43
+ | Reference File | Phase | What It Does |
44
+ |------------|-------|-------------|
45
+ | `reference/discover.md` | Step 1 (DISCOVER) | Inventory .lkml files, extract source candidates, capture prior-art notes |
46
+ | `reference/propose-fields.md` | Step 4 (DEFINE) | Extract field proposals from .lkml views |
47
+ | `reference/build-derived-tables.md` | Step 5 (BUILD) | Classify and convert LookML derived tables |
48
+ | `reference/build-unnest.md` | Step 5 (BUILD) | Convert UNNEST joins and struct field access |
49
+ | `reference/curate-visibility.md` | Step 8 (CURATE) | Map LookML visibility mechanisms to Malloy access modifiers |
50
+ | `reference/document.md` | Step 9 (DOCUMENT) | Extract LookML descriptions as `#(doc)` tag seeds |
51
+ | `reference/review-coverage.md` | Step 7 (REVIEW) | Compare Malloy model against LookML: source, field, and join coverage with rationale for gaps |
52
+
53
+ ### Shared Reference
54
+
55
+ `reference/_concepts.md` is the LookML to Malloy concept mapping table. Referenced by `propose-fields.md` and `build-derived-tables.md` for type mapping and syntax translation.
56
+
57
+ ## What LookML Provides
58
+
59
+ - Field names, descriptions, and business logic (accelerates Step 4)
60
+ - Join relationships and cardinality (accelerates Step 3)
61
+ - Field visibility decisions: `hidden: yes`, `fields` exclusions, `required_access_grants` (accelerates Step 8)
62
+ - Organizational structure via `group_label` and `view_label` (informs source design)
63
+ - Derived table intent: NDTs to computed sources, PDTs to evaluate
64
+
65
+ ## What to Skip
66
+
67
+ - Looker UI patterns (`link:`, `drill_fields:`, `html:`, `action:`)
68
+ - Liquid templating (`{% %}`, `{{ }}`): strip and note intent
69
+ - `parameter:` definitions: note the business intent, don't replicate
70
+ - PDT optimization (`partition_keys:`, `datagroup_trigger:`, `increment_key:`)
71
+ - Dashboard files (`.dashboard.lookml`)
72
+ - `sql_always_where:`: document as context, don't bake into Malloy
73
+
74
+ ## What to Flag for User Decision
75
+
76
+ - Complex SQL dimensions (50+ lines): default is simplify or push upstream
77
+ - Derived tables: classify as performance-only, transformation, or aggregation
78
+ - Refinement structure (`+view`): consolidate vs. preserve layering via `extend`
79
+ - Synthetic primary keys: ask about actual grain