@graphit/cli 0.1.49 → 0.1.106

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 (65) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +3 -2
  4. package/README.md +99 -0
  5. package/bin/graphit +45 -0
  6. package/bin/graphit.cmd +8 -0
  7. package/bin/graphit.ps1 +38 -0
  8. package/dist/api/client.js +11 -0
  9. package/dist/api/client.js.map +1 -1
  10. package/dist/commands/auth.js +14 -6
  11. package/dist/commands/auth.js.map +1 -1
  12. package/dist/commands/ds-poll.d.ts +8 -0
  13. package/dist/commands/ds-poll.js +53 -0
  14. package/dist/commands/ds-poll.js.map +1 -0
  15. package/dist/commands/ds.js +113 -57
  16. package/dist/commands/ds.js.map +1 -1
  17. package/dist/commands/governance.js +15 -11
  18. package/dist/commands/governance.js.map +1 -1
  19. package/dist/commands/kb.js +14 -4
  20. package/dist/commands/kb.js.map +1 -1
  21. package/dist/commands/query.js +9 -7
  22. package/dist/commands/query.js.map +1 -1
  23. package/dist/index.js +2 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/output/format.d.ts +1 -0
  26. package/dist/output/format.js +6 -0
  27. package/dist/output/format.js.map +1 -1
  28. package/dist/output/table.d.ts +1 -0
  29. package/dist/output/table.js +20 -3
  30. package/dist/output/table.js.map +1 -1
  31. package/dist/skill-version.d.ts +12 -0
  32. package/dist/skill-version.js +21 -0
  33. package/dist/skill-version.js.map +1 -1
  34. package/dist/update-check.d.ts +19 -0
  35. package/dist/update-check.js +105 -12
  36. package/dist/update-check.js.map +1 -1
  37. package/hooks/hooks.json +13 -0
  38. package/package.json +11 -3
  39. package/scripts/block-legacy-setup.mjs +90 -0
  40. package/scripts/generate-commands-doc.mjs +182 -0
  41. package/scripts/plugin-status.mjs +179 -29
  42. package/scripts/sync-plugin-version.mjs +26 -4
  43. package/skills/graphit/SKILL.md +172 -431
  44. package/skills/graphit/VERSION.json +1 -1
  45. package/skills/graphit/cursor/README.md +11 -0
  46. package/skills/graphit/cursor/graphit-chart-patterns.mdc +3 -0
  47. package/skills/graphit/cursor/graphit-kb-exploration.mdc +2 -0
  48. package/skills/graphit/cursor/graphit-sql-reference.mdc +1 -1
  49. package/skills/graphit/graphit.mdc +6 -2
  50. package/skills/graphit/references/chart-patterns.md +3 -0
  51. package/skills/graphit/references/dashboard-planning.md +1 -7
  52. package/skills/graphit/references/data-sources.md +30 -1
  53. package/skills/graphit/references/filters-advanced.md +61 -0
  54. package/skills/graphit/references/filters.md +27 -93
  55. package/skills/graphit/references/governance.md +49 -66
  56. package/skills/graphit/references/graphit-style.md +6 -18
  57. package/skills/graphit/references/kb-actions.md +44 -52
  58. package/skills/graphit/references/kb-discovery.md +12 -13
  59. package/skills/graphit/references/kb-structure.md +10 -8
  60. package/skills/graphit/references/kb-traversal.md +16 -16
  61. package/skills/graphit/references/operations.md +115 -0
  62. package/skills/graphit/references/parameterized-metrics.md +32 -44
  63. package/skills/graphit/references/runtime.md +172 -0
  64. package/skills/graphit/references/sql-reference.md +5 -42
  65. package/skills/graphit/references/domain-lenses.md +0 -116
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@graphit/cli",
3
- "version": "0.1.49",
3
+ "version": "0.1.106",
4
4
  "source": "cli/package.json"
5
5
  }
@@ -0,0 +1,11 @@
1
+ # Cursor references - FROZEN
2
+
3
+ These `graphit-*.mdc` files are the Cursor mirror of the Graphit CLI skill. They are FROZEN.
4
+
5
+ - They are kept on disk so existing Cursor users keep working.
6
+ - They are no longer synced from `SKILL.md` + `references/`, and the version stamper no longer touches them.
7
+ - They may be stale relative to the live skill.
8
+
9
+ The source of truth for the Graphit CLI skill is `cli/skills/graphit/SKILL.md` plus `cli/skills/graphit/references/`. Maintained platforms are Claude Code and Codex.
10
+
11
+ Do not edit these files to fix a CLI behavior. Fix it in `SKILL.md` / `references/` instead.
@@ -47,6 +47,9 @@ Config: `value`, `min` (default 0), `max` (default 100), `format`, `label`, `col
47
47
  Inline SVG polyline for KPI cards or table cells. No axes, no grid - just trend shape. Single data point renders as a dot.
48
48
  Config: `y`, `width` (default 120), `height` (default 32), `label`, `showValue` (default true), `valueFormat`.
49
49
 
50
+ ## Value formatting
51
+ `valueFormat` (charts), `format` (`kpi`/`gauge`), and `columnFormats` (`graphit.table`, mapping column name -> format) all take `"currency"`, `"percent"`, or `"number"`. **`"percent"` only appends `%` - it does NOT multiply by 100.** A 0-1 ratio renders as `0.42%`, not `42%`; multiply ratios/rates by 100 in SQL (`* 100.0 ... AS x_pct`) so the value is already 0-100. Table columns without a `columnFormats` entry render raw.
52
+
50
53
  ## Color tokens
51
54
 
52
55
  | Token | Usage |
@@ -39,6 +39,8 @@ All KB assets use UPPER_SNAKE_CASE. The names are auto-sanitized:
39
39
  | `COUNT_*` | `COUNT_ACTIVE_USERS` | Count metrics |
40
40
  | `*_RATE` | `CONVERSION_RATE`, `CHURN_RATE` | Ratios/percentages |
41
41
 
42
+ > `*_RATE`/ratio columns are usually 0-1 fractions. To chart them as a percent, multiply by 100 in SQL (`* 100.0`) - `"percent"` format appends `%` without scaling.
43
+
42
44
  ## When to Suggest KB Asset Creation
43
45
 
44
46
  | Signal | Propose |
@@ -133,7 +133,7 @@ Canvas `graphit.resolve()` queries that follow these shapes serve from a semanti
133
133
  **Shapes that skip the cache** (fall back to normal execution, still correct):
134
134
  - `COUNT(DISTINCT x)`, window functions, HAVING, QUALIFY
135
135
  - OR / NOT in WHERE
136
- - Ratio metrics (`SUM(a)/NULLIF(SUM(b),0)`) - compute client-side or use two resolves
136
+ - Ratio metrics (`SUM(a)/NULLIF(SUM(b),0)`) - compute client-side or use two resolves. To display as a percent, multiply by 100 (`* 100.0 ... AS x_pct`): the `"percent"` format only appends `%`, it does not scale (a 0-1 ratio would show as `0.42%`, not `42%`).
137
137
  - `CURRENT_DATE`-relative predicates
138
138
  - Top-N with aggregate only in ORDER BY (`SELECT dim FROM t GROUP BY dim ORDER BY SUM(metric) DESC LIMIT N` - no decomposable aggregate in SELECT)
139
139
 
@@ -1,10 +1,12 @@
1
1
  ---
2
- skill_version: "0.1.49"
2
+ skill_version: "0.1.54"
3
3
  alwaysApply: false
4
4
  description: "Build Graphit HTML dashboards with KB-aware queries, entity wrapping, and cached data sources"
5
5
  globs: []
6
6
  ---
7
7
 
8
+ > FROZEN - not maintained for Claude Code + Codex; may be stale. Source of truth is SKILL.md + references/.
9
+
8
10
  # Graphit CLI
9
11
 
10
12
  Build custom HTML dashboards from real data using the Graphit CLI.
@@ -305,7 +307,7 @@ Add the overlay inside EVERY element you pass as `target:` - and ONLY those elem
305
307
  | Helper | Usage |
306
308
  |---|---|
307
309
  | `graphit.chart(el, {type, data, x, y, ...})` | Bar, horizontal-bar, line, area, donut, scatter, stacked-bar, heatmap, funnel, gauge, sparkline |
308
- | `graphit.table(el, {data, columns?, maxRows?})` | Styled HTML table. `columns` is a `string[]` of data keys (NOT objects) |
310
+ | `graphit.table(el, {data, columns?, maxRows?, columnFormats?})` | Styled HTML table. `columns` is a `string[]` of data keys (NOT objects). `columnFormats` maps column name -> `"currency"`/`"percent"`/`"number"` (same formats and no-scaling percent rule as chart `valueFormat`); unlisted columns render raw |
309
311
  | `graphit.kpi(el, {value, label?, format?})` | KPI card with optional delta (`compareValue`, `compareLabel`) |
310
312
  | `graphit.presentation(el)` | Full-screen slide deck. Returns builder: `.slide({bg, layout, html})` then `.start()`. See graphit-presentations rule. |
311
313
  | `graphit.filter(id, {label, field?, default?})` | Headless filter (zero DOM). Returns `{get, set, subscribe}` handle. See graphit-filters rule. |
@@ -318,6 +320,8 @@ Add the overlay inside EVERY element you pass as `target:` - and ONLY those elem
318
320
 
319
321
  `graphit.chart` types: `"bar"`, `"horizontal-bar"` (alias `"hbar"`), `"line"`, `"area"`, `"donut"` (alias `"pie"`), `"scatter"` (alias `"bubble"`), `"stacked-bar"` (alias `"stacked"`), `"heatmap"`, `"funnel"`, `"gauge"`, `"sparkline"`. Config: `x`, `y`, `series`, `title`, `height` (140-900px), `valueFormat` (`"currency"` | `"percent"` | `"number"`), `colors`. Dual axis (bar/line/area): `y2`, `y2Format`, `y2Label`; `y2` and `series` are mutually exclusive. Scatter adds: `size`, `label`. Gauge: `min`, `max`, `format`. Sparkline: `width`, `showValue`.
320
322
 
323
+ **Percent does NOT scale.** `"percent"` only appends `%` - it does not multiply by 100. A 0-1 ratio (retention, conversion, share, churn) renders as `0.42%`, not `42%`. Multiply ratios by 100 in SQL (`SUM(retained) * 100.0 / NULLIF(SUM(cohort), 0) AS retention_pct`) then use `valueFormat: "percent"`. Charts use `valueFormat`; `kpi`/`gauge`/`filter` use `format`.
324
+
321
325
  ### Canonical pattern - entity with live data
322
326
 
323
327
  ```html
@@ -44,6 +44,9 @@ Config: `value`, `min` (default 0), `max` (default 100), `format`, `label`, `col
44
44
  Inline SVG polyline for KPI cards or table cells. No axes, no grid - just trend shape. Single data point renders as a dot.
45
45
  Config: `y`, `width` (default 120), `height` (default 32), `label`, `showValue` (default true), `valueFormat`.
46
46
 
47
+ ## Value formatting
48
+ `valueFormat` (charts), `format` (`kpi`/`gauge`), and `columnFormats` (`graphit.table`, mapping column name -> format) all take `"currency"`, `"percent"`, or `"number"`. **`"percent"` only appends `%` - it does NOT multiply by 100.** A 0-1 ratio renders as `0.42%`, not `42%`; multiply ratios/rates by 100 in SQL (`* 100.0 ... AS x_pct`) so the value is already 0-100. Table columns without a `columnFormats` entry render raw.
49
+
47
50
  ## Saved templates
48
51
 
49
52
  Templates are reusable chart components saved to the org's KB. At dashboard load, the SDK fetches the org's template bundle and registers them alongside built-in types.
@@ -84,13 +84,7 @@ Purpose before data. The first response should mirror the user's intent and ask
84
84
 
85
85
  ## Performance
86
86
 
87
- `graphit.resolve()` is rate-limited to 120 requests/min per user per dashboard. Each call counts as one request. Design for efficiency:
88
-
89
- - **Single refresh function.** All queries in ONE `Promise.all` inside one `refresh()` function. Never scatter `graphit.resolve()` across independent event handlers or timeouts - that turns one user action into multiple bursts.
90
- - **Count your queries per interaction.** 6 charts = 6 requests per filter change = 20 changes/min budget. 12 charts = 10 changes/min. If you have 10+ charts with 3+ filters, consider debouncing filter changes (300ms) so rapid clicks don't each trigger a full refresh.
91
- - **Reuse trend data for KPIs.** If you already fetch a weekly time series (`SELECT week, SUM(spend) ...`), derive the KPI total and sparkline from that result set in JS instead of running a separate aggregate query. One query serves both the chart and the KPI card.
92
- - **Avoid redundant refreshes.** If a filter only affects some charts, split into targeted refresh functions (`refreshKPIs()`, `refreshCharts()`) so unchanged sections don't re-query.
93
- - **No polling.** Never `setInterval(refresh, ...)`. Data sources update on their own schedule - a dashboard that polls wastes the entire rate budget.
87
+ Live data goes through `graphit.resolve()`, which is rate-limited per user per dashboard. Budget your queries per interaction - batch them, reuse trend data for KPIs, split targeted refreshes, and never poll. The full rate-limit budget and the efficient-refresh patterns live in `runtime.md`.
94
88
 
95
89
  ## Pre-Build Checklist
96
90
 
@@ -1,7 +1,18 @@
1
- # Data Sources: Building for Speed
1
+ # Data Sources: Routing and Building for Speed
2
2
 
3
3
  A data source's shape - set by its source SQL at creation - decides how fast every dashboard built on it will be, and whether filter changes feel instant. Get this right when you create the source; it can't be fixed later in the dashboard SQL.
4
4
 
5
+ ## Routing: which source to query
6
+
7
+ Always prefer a cached data source over the live warehouse. Check what exists with `graphit ds list` before writing any query.
8
+
9
+ | Situation | Command | Speed |
10
+ |---|---|---|
11
+ | The table has a cached data source | `graphit query "SQL" --ds <id>` | roughly 100ms, DuckDB |
12
+ | No data source covers the table | `graphit query "SQL" --warehouse --connection <id>` | roughly 10s, Snowflake |
13
+
14
+ If no data source covers the table the user needs, propose creating one for future speed rather than defaulting to repeated warehouse queries. Dialect differs by route (DuckDB for `--ds`, Snowflake for `--warehouse`); see `sql-reference.md`.
15
+
5
16
  ## Why source shape decides dashboard speed
6
17
 
7
18
  When you change a dashboard filter (e.g. switch country), Graphit answers from a cached, pre-aggregated result instead of re-scanning the whole source - but only when the source is small and already aggregated to the grain you query. Build the source as one row per the grain you'll chart (e.g. month x country x channel) with only the columns you use, and filter changes return in milliseconds. Pull in raw ungrouped rows, hundreds of columns, or thousands-of-values dimensions and the result is too big to cache - every filter change re-scans the entire source and is slow.
@@ -74,3 +85,21 @@ The CLI fires all refreshes in parallel and polls until every source is ready. L
74
85
  ## Deleting data sources
75
86
 
76
87
  There is no `graphit ds delete` command. Deleting a data source cascades to GCS storage and the KB table, which removes all metrics, dimensions, and rules defined on it. Direct the user to delete from the platform UI (Sources Hub) where the confirmation flow shows what will be affected.
88
+
89
+ ## Presenting data source results
90
+
91
+ The user cannot see the raw CLI output - you are the rendering layer. After `graphit ds list`, present a markdown table and end with a recommendation of which source to use (or note that none covers the needed table):
92
+
93
+ ~~~
94
+ **3 data sources:**
95
+
96
+ | Name | ID | Rows | Status | Governed |
97
+ |---|---|---:|---|---|
98
+ | **MARKETING_UA_DS** | ds_abc123 | 1,247,832 | active | yes |
99
+ | **PLAYER_QUALITY** | ds_def456 | 892,104 | active | no |
100
+ | **REVENUE_EVENTS** | ds_ghi789 | 3,412,006 | stale | yes |
101
+
102
+ Using **MARKETING_UA_DS** (ds_abc123), which covers spend, installs, and ROAS columns.
103
+ ~~~
104
+
105
+ Bold every data source name. If a source is stale, say so and offer to refresh it before querying.
@@ -0,0 +1,61 @@
1
+ # Advanced Filter Controls - Dependent Dropdowns and Date Presets
2
+
3
+ Load only when you need one of two optional controls on top of the core filters: a dependent dropdown ("only relevant values" that narrows as an upstream filter changes) or a date-preset picker. The base filter, param, bind, wiring, `:name` binding, and saved-view mechanics live in `filters.md`. Both controls below are headless logic - you own all the markup.
4
+
5
+ ## graphit.cascade(el, options) - Only Relevant Values
6
+
7
+ Dependent dropdowns: fetch a column's DISTINCT values constrained by other filters, and refetch when they change. For example, pick an org and the user list shows only that org's users. Logic only - you build the checkboxes or list in `render`.
8
+
9
+ ```js
10
+ const org = graphit.filter('org', { label: 'Org' })
11
+ const user = graphit.filter('user', { label: 'User', default: [] }) // multi-select
12
+
13
+ graphit.cascade('#user-list', {
14
+ column: 'USER_NAME', // distinct values of this column
15
+ source: 'users_table', // table name (or a subquery)
16
+ dataSourceId: 'ds_abc',
17
+ filters: () => ({ ORG: org.get() }), // upstream constraints; empty values skipped
18
+ deps: ['org'], // refetch when org changes
19
+ selection: user, // optional: prune selected users no longer in this org
20
+ render: (values, el, ctx) => {
21
+ // ctx = { loading, empty, error, hasUpstream } - build any markup you like
22
+ el.innerHTML = values.map(v => `<label><input type="checkbox" value="${v}"> ${v}</label>`).join('')
23
+ },
24
+ })
25
+ ```
26
+
27
+ - `filters()` returns `{ COLUMN: value }`. A scalar makes `COLUMN = :p`; an array makes `COLUMN IN :p`. Empty values (`null`, `''`, `[]`) are skipped (no constraint), so an empty upstream means "no filter", not "match nothing".
28
+ - `selection` (a filter handle) is auto-pruned to the surviving values when an upstream changes.
29
+ - Returns `{ destroy() }`. Keep the result set small (default `LIMIT 1001`); these parameterized queries skip the result cache, so they hit DuckDB directly.
30
+ - Faster for low-cardinality cascades: add `preload: true` to fetch the full distinct cross-product ONCE (cacheable, no params) and filter in-memory on every change - instant, zero per-change round-trips. Best when the column-by-upstream combinations are small (cap = `limit`, default 1001 tuples); above the cap it auto-falls-back to per-change server queries.
31
+
32
+ ## graphit.dateRange(id, options) - Date Presets
33
+
34
+ A headless date filter with the standard presets built in (logic only - you render the buttons or inputs). `default` is a preset id or `{ start, end }`.
35
+
36
+ ```js
37
+ const dr = graphit.dateRange('date_range', { label: 'Date Range', default: 'last_30_days' })
38
+ ```
39
+
40
+ Handle:
41
+ - `dr.get()` returns `{ preset, start, end }` (ISO `YYYY-MM-DD`)
42
+ - `dr.set(presetId)` (e.g. `dr.set('this_month')`); `dr.setRange(start, end)` for a custom range
43
+ - `dr.start` / `dr.end` / `dr.preset` convenience getters; `dr.deps` (pass as `deps: dr.deps`); `dr.subscribe(cb)` (cb gets `{ preset, start, end }`)
44
+
45
+ A `dateRange` registration persists to saved views exactly like a `filter`/`param`, and its `subscribe` callback restores the picker's visual state on view apply or reload (same rule as the core controls in `filters.md`).
46
+
47
+ Relative presets auto-recompute on reload (a saved "last_7_days" always means the last 7 days from today). The 11 preset ids - also available via `graphit.datePresets` (`[{id,label}]`) and `graphit.datePreset(id)` (`{start,end}`): today, yesterday, last_7_days, last_30_days, this_month, last_month, this_quarter, last_quarter, ytd, last_90_days, last_12_months.
48
+
49
+ Bind a chart to the range with two scalar params and a `BETWEEN`:
50
+
51
+ ```js
52
+ graphit.bind('#rev', {
53
+ sql: 'SELECT day, SUM(rev) AS rev FROM orders WHERE day BETWEEN :start_date AND :end_date GROUP BY 1',
54
+ dataSourceId: 'ds_abc',
55
+ params: () => ({ start_date: dr.start, end_date: dr.end }),
56
+ deps: dr.deps,
57
+ render: (r, el) => graphit.chart(el, { type: 'area', data: r.data, x: 'day', y: 'rev' }),
58
+ })
59
+ ```
60
+
61
+ Name the placeholders `:start_date` / `:end_date`, never `:from` / `:to` - reserved-word placeholders fail SQL validation (see the `:name` rules in `filters.md`).
@@ -1,10 +1,18 @@
1
1
  # Headless Filters, Parameters, and Saved Views
2
2
 
3
+ Load when the dashboard needs interactivity: a control the user changes that re-resolves a chart, plus saved views. A static dashboard skips this file. Dependent dropdowns and date presets live in `filters-advanced.md`.
4
+
5
+ ## Contents
6
+
7
+ Core Concept | `graphit.filter()` / `graphit.param()` | Wiring a Control | `graphit.bind()` | Safe Parameter Binding (`:name`) | Saved Views | Complete Example
8
+
3
9
  ## Core Concept
4
10
 
5
11
  `graphit.filter()` and `graphit.param()` are headless JS registrations. They create NO DOM elements. You build 100% of the filter control's markup (any HTML, SVG, or CSS). The registration manages state so values can be snapshotted into saved views and survive page reloads.
6
12
 
7
- **Logic vs style.** `filter`, `param`, `bind`, `dateRange`, `cascade` are headless logic - zero imposed styling, you own the markup. `chart`, `table`, `kpi`, `presentation`, `dropdown` render styled output you can use or hand-roll past.
13
+ **Logic vs style.** `filter`, `param`, `bind` are headless logic - zero imposed styling, you own the markup. `chart`, `table`, `kpi`, `presentation`, `dropdown` render styled output you can use or hand-roll past.
14
+
15
+ These four pieces always work together: register a control, register the binding that reads it, wire your markup to the handle, and let saved views snapshot it. A filter with no `bind` does nothing - plan to include all four when you add interactivity.
8
16
 
9
17
  ## graphit.filter(id, options)
10
18
 
@@ -25,56 +33,15 @@ Handle API:
25
33
 
26
34
  ## graphit.param(id, options)
27
35
 
28
- Same API as filter but semantically a parameter (not tied to a KB field). Use for user-controlled inputs like date range pickers, threshold sliders, or toggle switches.
36
+ Same API as filter but semantically a parameter (not tied to a KB field). Use for user-controlled inputs like threshold sliders or toggle switches.
29
37
 
30
38
  ```js
31
39
  const topN = graphit.param('top_n', { label: 'Top N', default: 10, options: [5, 10, 20, 50] })
32
40
  ```
33
41
 
34
- ## graphit.dateRange(id, options)
35
-
36
- A headless date filter with the standard presets built in (logic only - you render the buttons/inputs). `default` is a preset id or `{ start, end }`.
37
-
38
- ```js
39
- const dr = graphit.dateRange('date_range', { label: 'Date Range', default: 'last_30_days' })
40
- ```
41
-
42
- Handle:
43
- - `dr.get()` -> `{ preset, start, end }` (ISO `YYYY-MM-DD`)
44
- - `dr.set(presetId)` (e.g. `dr.set('this_month')`); `dr.setRange(start, end)` for a custom range
45
- - `dr.start` / `dr.end` / `dr.preset` convenience getters; `dr.deps` (pass as `deps: dr.deps`); `dr.subscribe(cb)` (cb gets `{ preset, start, end }`)
46
-
47
- Relative presets auto-recompute on reload (a saved "last_7_days" always means the last 7 days from today). The 11 preset ids - also via `graphit.datePresets` (`[{id,label}]`) and `graphit.datePreset(id)` (`{start,end}`): today, yesterday, last_7_days, last_30_days, this_month, last_month, this_quarter, last_quarter, ytd, last_90_days, last_12_months.
48
-
49
- Bind a chart to the range with two scalar params and a `BETWEEN`:
50
-
51
- ```js
52
- graphit.bind('#rev', {
53
- sql: 'SELECT day, SUM(rev) AS rev FROM orders WHERE day BETWEEN :start_date AND :end_date GROUP BY 1',
54
- dataSourceId: 'ds_abc',
55
- params: () => ({ start_date: dr.start, end_date: dr.end }),
56
- deps: dr.deps,
57
- render: (r, el) => graphit.chart(el, { type: 'area', data: r.data, x: 'day', y: 'rev' }),
58
- })
59
- ```
60
-
61
42
  ## Wiring a Control
62
43
 
63
- The registration renders nothing. Wire any HTML element you build:
64
-
65
- ```html
66
- <select id="country-picker">
67
- <option value="US">US</option>
68
- <option value="IL">Israel</option>
69
- </select>
70
- <script>
71
- const country = graphit.filter('country', { label: 'Country', default: 'US' });
72
- const picker = document.getElementById('country-picker');
73
- picker.value = country.get();
74
- picker.onchange = () => country.set(picker.value);
75
- country.subscribe(v => { picker.value = v; }); // restores on view apply or page reload
76
- </script>
77
- ```
44
+ The registration renders nothing, so connect your own markup to the handle in three steps: set the element's initial value from `handle.get()`, call `handle.set(newValue)` in the change event, and pass `handle.subscribe(v => updateElement(v))` so the control restores its visual state on saved-view apply or page reload. The Complete Example below shows this end to end for a `<select>`.
78
45
 
79
46
  ## graphit.bind(el, options) - Reactive Data Binding
80
47
 
@@ -96,36 +63,9 @@ graphit.bind(document.getElementById('revenue-chart'), {
96
63
  - Multi-key changes (e.g. a saved view applying 3 filters) debounce into one re-resolve per element
97
64
  - `render` is your code - call `graphit.chart`, `graphit.table`, or hand-roll SVG/CSS
98
65
 
99
- ## graphit.cascade(el, options) - Only Relevant Values
100
-
101
- Dependent dropdowns: fetch a column's DISTINCT values constrained by other filters, and refetch when they change (e.g. pick an org, the user list shows only that org's users). Logic only - you build the checkboxes/list in `render`.
102
-
103
- ```js
104
- const org = graphit.filter('org', { label: 'Org' })
105
- const user = graphit.filter('user', { label: 'User', default: [] }) // multi-select
106
-
107
- graphit.cascade('#user-list', {
108
- column: 'USER_NAME', // distinct values of this column
109
- source: 'users_table', // table name (or a subquery)
110
- dataSourceId: 'ds_abc',
111
- filters: () => ({ ORG: org.get() }), // upstream constraints; empty values skipped
112
- deps: ['org'], // refetch when org changes
113
- selection: user, // optional: prune selected users no longer in this org
114
- render: (values, el, ctx) => {
115
- // ctx = { loading, empty, error, hasUpstream } - build any markup you like
116
- el.innerHTML = values.map(v => `<label><input type="checkbox" value="${v}"> ${v}</label>`).join('')
117
- },
118
- })
119
- ```
120
-
121
- - `filters()` returns `{ COLUMN: value }`. A scalar makes `COLUMN = :p`; an array makes `COLUMN IN :p`. Empty values (`null`, `''`, `[]`) are skipped (no constraint), so an empty upstream means "no filter", not "match nothing".
122
- - `selection` (a filter handle) is auto-pruned to the surviving values when an upstream changes.
123
- - Returns `{ destroy() }`. Keep the result set small (default `LIMIT 1001`); these parameterized queries skip the result cache, so they hit DuckDB directly.
124
- - **Faster for low-cardinality cascades:** add `preload: true` to fetch the full distinct cross-product ONCE (cacheable, no params) and filter in-memory on every change - instant, zero per-change round-trips. Best when the column x upstream combinations are small (cap = `limit`, default 1001 tuples); above the cap it auto-falls-back to per-change server queries.
125
-
126
66
  ## Safe Parameter Binding (`:name` syntax)
127
67
 
128
- Use `:name` placeholders in SQL for safe server-side parameter binding. Never string-concatenate user values into SQL.
68
+ Use `:name` placeholders in SQL for safe server-side parameter binding. NEVER string-concatenate user values into SQL.
129
69
 
130
70
  | Shape | SQL | Params |
131
71
  |-------|-----|--------|
@@ -133,7 +73,7 @@ Use `:name` placeholders in SQL for safe server-side parameter binding. Never st
133
73
  | Multi-select | `WHERE country IN :countries` | `{countries: ['US', 'IL']}` (expands to safe `IN ($0, $1)`) |
134
74
  | Date range | `WHERE date BETWEEN :start_date AND :end_date` | `{start_date: '2026-01-01', end_date: '2026-06-01'}` |
135
75
 
136
- **Don't name a param after a SQL keyword** (`from`, `to`, `select`, `order`, `group`, ...). The SQL template is parsed before values bind, so a reserved-word placeholder like `:from` fails with "SQL validation failed". Use names like `:start_date`, `:end_date`.
76
+ Do NOT name a param after a SQL keyword (`from`, `to`, `select`, `order`, `group`, and similar). The SQL template is parsed before values bind, so a reserved-word placeholder like `:from` fails with "SQL validation failed". Use names like `:start_date`, `:end_date`.
137
77
 
138
78
  Array length capped at 200 elements, max 50 param keys per resolve call.
139
79
 
@@ -141,26 +81,20 @@ Array length capped at 200 elements, max 50 param keys per resolve call.
141
81
 
142
82
  Users can save the current filter/parameter state as a named view and restore it later. The platform snapshots all registered `graphit.filter` and `graphit.param` values automatically. Views survive page reloads (state is baked into the iframe on every render). A default view auto-applies on dashboard open with no flash.
143
83
 
144
- The subscribe callback on each handle restores the control's visual state when a view is applied - this is why every control must include a `subscribe(v => ...)` that updates its appearance.
84
+ The subscribe callback on each handle restores the control's visual state when a view is applied. This is why every control MUST include a `subscribe(v => ...)` that updates its appearance. A control registered without `graphit.filter`/`param` (a hand-rolled `<select>` on its own) will NOT persist to a view.
145
85
 
146
86
  ## Complete Example
147
87
 
88
+ One control, wired to one reactive chart. The `<select>` is your own markup; `bind()` re-resolves the chart whenever the filter changes.
89
+
148
90
  ```html
149
- <div style="margin-bottom: 16px;">
150
- <label style="font-size: 12px; color: var(--graphit-fg-muted);">Region</label>
151
- <select id="region-select" style="padding: 6px 12px; border: 1px solid var(--graphit-border); border-radius: 6px; background: var(--graphit-surface-raised); color: var(--graphit-fg);">
152
- <option value="ALL">All Regions</option>
153
- <option value="NA">North America</option>
154
- <option value="EU">Europe</option>
155
- <option value="APAC">Asia Pacific</option>
156
- </select>
157
- </div>
158
-
159
- <div id="revenue-by-region" data-graphit-id="revenue-by-region"
160
- data-graphit-label="Revenue by Region"
161
- data-graphit-sql="SELECT month, SUM(revenue) AS revenue FROM sales WHERE region = :region GROUP BY 1 ORDER BY 1"
162
- data-graphit-ds="ds_sales">
163
- </div>
91
+ <label>Region</label>
92
+ <select id="region-select">
93
+ <option value="ALL">All Regions</option>
94
+ <option value="NA">North America</option>
95
+ <option value="EU">Europe</option>
96
+ </select>
97
+ <div id="revenue-by-region"></div>
164
98
 
165
99
  <script>
166
100
  const region = graphit.filter('region', { label: 'Region', field: 'REGION', default: 'ALL' });
@@ -171,11 +105,9 @@ The subscribe callback on each handle restores the control's visual state when a
171
105
  region.subscribe(v => { sel.value = v; });
172
106
 
173
107
  graphit.bind(document.getElementById('revenue-by-region'), {
174
- sql: `SELECT month, SUM(revenue) AS revenue FROM sales
175
- ${region.get() !== 'ALL' ? 'WHERE region = :region' : ''}
176
- GROUP BY 1 ORDER BY 1`,
108
+ sql: 'SELECT month, SUM(revenue) AS revenue FROM sales WHERE (:region = \'ALL\' OR region = :region) GROUP BY 1 ORDER BY 1',
177
109
  dataSourceId: 'ds_sales',
178
- params: () => region.get() !== 'ALL' ? { region: region.get() } : {},
110
+ params: () => ({ region: region.get() }),
179
111
  deps: ['region'],
180
112
  render: (result, el) => {
181
113
  graphit.chart(el, { type: 'area', data: result.data, x: 'month', y: 'revenue', valueFormat: 'currency' });
@@ -183,3 +115,5 @@ The subscribe callback on each handle restores the control's visual state when a
183
115
  });
184
116
  </script>
185
117
  ```
118
+
119
+ For dependent dropdowns (an "only relevant values" list that narrows as an upstream filter changes) and date-preset pickers, see `filters-advanced.md`.
@@ -1,113 +1,96 @@
1
1
  # Query Governance
2
2
 
3
- Server-side governance ensures consistent, auditable queries across all channels.
3
+ Load this when writing or validating a governed query, working trust tiers, or working the ad-hoc frontier (deciding whether a raw measure run is allowed on a governed source).
4
4
 
5
- ## Reference Syntax
5
+ Governance is enforced server-side in the QueryGateway, the same across every channel. You CANNOT weaken it from the CLI; you can only write queries that pass it or, where the mode allows, run a raw measure with explicit approval.
6
6
 
7
- Use KB references instead of inline formulas for governed queries:
7
+ ## Reference syntax
8
8
 
9
- ```sql
10
- -- Governed: server expands to KB-defined formulas
11
- SELECT {{dim:INSTALL_MONTH}}, {{metric:CPI}} as cpi
12
- FROM MARKETING_UA_DS
13
- GROUP BY 1
9
+ Write governed queries with KB references, not inline formulas. The server compiles each to its KB expression, injects rule constraints, stamps the trust tier, and caches the result. The syntax is identical in `graphit query` and `graphit.resolve()`.
14
10
 
15
- -- Parameterized metric (ARPU requires DAY parameter)
16
- SELECT {{metric:ARPU(DAY=7)}} as arpu FROM MARKETING_UA_DS
11
+ | Syntax | Expands to | Example |
12
+ |--------|-----------|---------|
13
+ | `{{metric:NAME}}` | Metric calculation (aggregation) | `{{metric:CPI}}` |
14
+ | `{{metric:NAME(K=V)}}` | Parameterized metric | `{{metric:ARPU(DAY=7)}}` |
15
+ | `{{metric_raw:NAME}}` | Raw expression, no outer aggregate | `{{metric_raw:REVENUE}}` |
16
+ | `{{dim:NAME}}` | Dimension expression | `{{dim:INSTALL_MONTH}}` |
17
17
 
18
- -- Raw expression (no outer aggregate)
19
- SELECT {{metric_raw:REVENUE}} FROM orders
18
+ ```bash
19
+ graphit query "SELECT {{dim:INSTALL_MONTH}}, {{metric:CPI}} AS cpi FROM MARKETING_UA_DS GROUP BY 1" --ds ds_abc123 --verbose
20
20
  ```
21
21
 
22
- The server compiles references to actual expressions, injects rule constraints, stamps trust tier, and caches results. Reference syntax works in both `graphit query` and `graphit.resolve()`.
22
+ `--verbose` prints the expanded SQL and trust tier, so you can confirm the reference resolved before presenting the result.
23
+
24
+ ## Parameterized metrics
25
+
26
+ Some metrics (for example ARPU, ROAS, RETENTION) carry required parameters and cannot resolve without a value. Run `graphit kb list metric` and read the `params` column for the names a metric requires, then supply them inline as `{{metric:ARPU(DAY=7)}}`. Pre-baked variants such as `ARPU_D7` or `ROAS_D30` have the value fixed and need none. Omitting a required parameter returns a clear error naming the exact syntax, so read `params` first.
23
27
 
24
- **Parameterized metrics:** Some metrics (ARPU, ROAS, RETENTION) require parameters. Check `graphit kb list metric` - the `params` column shows required names. Pre-baked variants (ARPU_D7, ROAS_D30) have values hardcoded and need no parameters. Omitting required parameters returns a clear error with the exact syntax to use.
28
+ ## Trust tiers
25
29
 
26
- ## Trust Tiers
30
+ Every result is stamped with a tier the platform shows the user as a badge on dashboard graphs and canvas entities - your honest signal of how trustworthy the number is.
27
31
 
28
32
  | Tier | Meaning | Badge |
29
33
  |------|---------|-------|
30
- | **governed** | Used `{{metric:X}}` / `{{dim:X}}` references | Teal dot |
31
- | **verified** | Raw SQL matches KB definitions (AST matching) | Amber dot |
32
- | **ad_hoc** | Inline formulas, no KB match | Gray dot |
34
+ | `governed` | Query used `{{metric:X}}` / `{{dim:X}}` references | Teal dot |
35
+ | `verified` | Raw SQL whose expressions match KB definitions | Amber dot |
36
+ | `ad_hoc` | Inline formulas with no KB match | Gray dot |
33
37
 
34
- ## Governance Modes
38
+ Prefer the governed tier. The server may upgrade matching raw SQL to verified, but reach for references first so the result is governed by intent.
35
39
 
36
- | Mode | Behavior |
37
- |------|----------|
38
- | `observe` | All queries pass, tiers are tracked |
39
- | `warn` | Ad-hoc **measure** queries on governed DSes ask for approval; exploration warns |
40
- | `strict` | All ad-hoc queries on governed DSes are blocked |
40
+ ## The ad-hoc measure gate
41
41
 
42
- ```bash
43
- graphit governance status # View mode and conformance stats
44
- graphit governance set warn # Change mode (admin only)
45
- graphit governance audit # View audit log
46
- ```
42
+ This is the hard frontier, enforced server-side. The rules below are what the QueryGateway does, not a suggestion you can soften.
47
43
 
48
- ## Ad-hoc Measure Gate
44
+ An **ad-hoc measure** computes a business measure (an aggregate or `GROUP BY` that produces a metric) on a governed data source WITHOUT `{{metric:NAME}}` references, so it lands at the `ad_hoc` tier. Plain exploration is not a measure: `SELECT *`, raw column selects, `COUNT(*)` row-count peeks, `DISTINCT` value lists.
49
45
 
50
- On a governed data source, an ad-hoc query that computes a business measure (an aggregate or `GROUP BY` producing a metric) is gated. Exploration is exempt and runs free: `SELECT *`, raw column selects, `COUNT(*)` row-count peeks, `DISTINCT` value lists.
46
+ - **warn.** The ad-hoc measure is rejected and asks for approval. First rewrite it with `{{metric:NAME}}` / `{{dim:NAME}}` references (search the KB for a match). Only if the user genuinely needs the raw run, ask them, then re-run the exact command with `--approve-adhoc`. Plain exploration is **exempt**: it runs free, with only a non-blocking warning.
47
+ - **strict.** CRITICAL: every `ad_hoc`-tier query is a HARD BLOCK, including plain exploration that does not match the KB. Rewrite with references or stop. `--approve-adhoc` NEVER bypasses strict and does not weaken it; do not offer it in strict mode.
48
+ - **observe.** All queries run; tiers are tracked, nothing is gated.
51
49
 
52
- - **warn mode:** the measure query is rejected. Rewrite using `{{metric:NAME}}` / `{{dim:NAME}}` references, or - if the user needs the raw run - ask them and re-run with `--approve-adhoc`.
53
- - **strict mode:** all ad-hoc queries are hard-blocked. Rewrite with references or stop; `--approve-adhoc` does not bypass strict.
50
+ When the gate fires, do not narrate around it or pretend the query ran. Report truthfully: it was blocked or needs approval, name the governed rewrite, and let the user decide.
54
51
 
55
- ## Enforceable Rules
52
+ ## Enforceable rules and overrides
56
53
 
57
- Rules with typed constraints are enforced automatically via SQL injection:
54
+ Rules with typed constraints are enforced automatically, rewriting the SQL before it runs:
58
55
 
59
56
  | Type | What it does |
60
57
  |------|-------------|
61
58
  | `required_where` | Injects a WHERE predicate |
62
59
  | `forbidden_column` | NULLifies a column in SELECT |
63
- | `value_restriction` | Restricts column to allowed values |
64
- | `required_filter` | Validates column appears in WHERE |
65
- | `required_aggregation` | Validates GROUP BY includes column |
66
-
67
- User-context variables (`${user.team_id}`, `${user.email}`) are resolved server-side for row-level security.
60
+ | `value_restriction` | Restricts a column to allowed values |
61
+ | `required_filter` | Validates a column appears in WHERE |
62
+ | `required_aggregation` | Validates GROUP BY includes a column |
68
63
 
69
- ## Override Flow
64
+ User-context variables (`${user.team_id}`, `${user.email}`) resolve server-side for row-level security. Override a rule only when the user explicitly asks and the rule's `override_policy` (anyone / analyst_only / admin_only / never) and the user's role allow it; a `never` policy can never be overridden, and every override is logged. Pass several names to override more than one.
70
65
 
71
66
  ```bash
72
- # Override a specific rule
73
67
  graphit query "SELECT * FROM events" --ds ds_123 --override-rules EXCLUDE_RETARGETING
74
-
75
- # Multiple overrides
76
- graphit query "SELECT * FROM events" --ds ds_123 --override-rules RULE1 RULE2
77
68
  ```
78
69
 
79
- Override is checked against the rule's `override_policy` (anyone/analyst_only/admin_only/never) and the user's role. Overrides are always logged to the audit trail.
70
+ ## Per-DS settings and governance mode
80
71
 
81
- ## Per-DS Settings
72
+ Governed mode and the row cap are set per data source; enabling governed mode activates the ad-hoc gate for that DS. The governance mode is org-wide and admin-only, set with a `--mode` flag, not a positional argument (values `observe`, `warn`, `strict`). Run `graphit ds update --help` and `graphit governance set --help` for exact flag spelling.
82
73
 
83
74
  ```bash
84
- graphit ds update <id> --governed-mode on # Enable governed mode
85
- graphit ds update <id> --governed-mode off # Disable
86
- graphit ds update <id> --max-rows 10000 # Set row cap
75
+ graphit governance set --mode warn
87
76
  ```
88
77
 
89
- ## Presenting Governance Results
90
-
91
- **After `graphit governance status`:**
92
-
93
- ~~~
94
- **Governance mode:** strict
78
+ ## Presenting governance results
95
79
 
96
- **7-day conformance:**
80
+ The user CANNOT see raw CLI output. Render every result as markdown.
97
81
 
98
- | Tier | Queries | Share |
99
- |---|---:|---:|
100
- | Governed | 847 | 72% |
101
- | Ad-hoc | 328 | 28% |
102
- | Blocked | 12 | 1% |
82
+ **After a governed query**, append a provenance footer so the trust signal travels with the number: tier, governed DS, KB references used, rules enforced by name, row cap if applied. For an ad-hoc result, state the tier honestly and offer the governed `{{metric:NAME}}` / `{{dim:NAME}}` rewrite.
103
83
 
104
- 5 active rules across 3 governed data sources.
105
84
  ~~~
85
+ **Trust tier:** governed - governed DS, 2 KB refs, 1 rule enforced (**EXCLUDE_INTERNAL**), max rows 10000
86
+ ~~~
87
+
88
+ **After `graphit governance status`**, show the mode plus the 7-day conformance counts (governed / verified / ad-hoc / total) as a small markdown table headed by `**Governance mode:** <mode>`.
106
89
 
107
- **After governance errors:**
90
+ **When a gate or rule blocks a query**, explain it and the path forward, no raw dump:
108
91
 
109
92
  ~~~
110
- **Error:** Query blocked by governance.
93
+ **Blocked by governance.**
111
94
 
112
- Rule **EXCLUDE_ORGANIC** (override policy: `never`) cannot be overridden. Contact your admin to change the policy, or rewrite the query to include the required filter.
95
+ Rule **EXCLUDE_ORGANIC** has override policy `never` and cannot be overridden. Ask your admin to change the policy, or rewrite the query to include the required filter.
113
96
  ~~~
@@ -1,6 +1,6 @@
1
1
  # Graphit Visual Style
2
2
 
3
- Consult when building the HTML dashboard. Covers design principles, the Graphit aesthetic, typography, color system, layout patterns, and all inline chart implementations.
3
+ Consult when authoring the dashboard HTML. This is the design system: principles, the Graphit aesthetic, typography, the color-token system, and layout CSS. Data wiring (resolve, entity wrapping, the first-paint loading overlay) lives in `runtime.md`; per-chart implementations live in `chart-patterns.md`.
4
4
 
5
5
  ## Design Principles
6
6
 
@@ -84,12 +84,6 @@ Use the system font stack everywhere: `-apple-system, BlinkMacSystemFont, 'Segoe
84
84
  box-shadow:0 1px 3px rgba(0,0,0,0.08); }
85
85
  .card h3 { font-size:12px; font-weight:600; color:var(--graphit-fg-subtle); text-transform:uppercase;
86
86
  letter-spacing:0.05em; margin-bottom:16px; }
87
- @keyframes gh-spin{to{transform:rotate(360deg)}}
88
- .gh-loading { position:relative; min-height:120px; }
89
- .gh-loading-overlay { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
90
- z-index:9998; backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px);
91
- background:color-mix(in srgb,var(--graphit-surface-raised,#fff) 50%,transparent); border-radius:inherit; }
92
- .gh-loading-spin { animation:gh-spin .7s linear infinite; }
93
87
  @media(max-width:900px) {
94
88
  .kpi-grid { grid-template-columns:repeat(2,1fr); }
95
89
  .charts-grid { grid-template-columns:1fr; }
@@ -97,6 +91,8 @@ Use the system font stack everywhere: `-apple-system, BlinkMacSystemFont, 'Segoe
97
91
  </style>
98
92
  ```
99
93
 
94
+ Also add the first-paint loading-overlay CSS (the `gh-loading` family) to the page `<style>` and wrap every resolve target with it. That overlay is the SDK contract - the canonical CSS and rules are in `runtime.md`.
95
+
100
96
  ### Dashboard Composition (top to bottom)
101
97
  1. **Title row** - dashboard name, optional subtitle with date range
102
98
  2. **KPI row** - 3-4 cards in a grid. Each: big number, label, delta badge
@@ -129,16 +125,8 @@ Use the system font stack everywhere: `-apple-system, BlinkMacSystemFont, 'Segoe
129
125
  </style>
130
126
  ```
131
127
 
132
- ### Loading State
133
-
134
- Bake this overlay inside every element passed as `target:` to `graphit.resolve()` - and only those (static text/title cards would spin forever). It shows from the first paint, before the SDK connects; the SDK removes it when the resolve settles. Class names are the SDK contract - keep them exactly:
135
-
136
- ```html
137
- <div id="spend-chart" class="gh-loading">
138
- <div class="gh-loading-overlay"><svg class="gh-loading-spin" width="24" height="24" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="var(--graphit-border,#e5e5e5)" stroke-width="2.5"/><path d="M12 2a10 10 0 0 1 10 10" stroke="var(--graphit-accent,#4DB6AC)" stroke-width="2.5" stroke-linecap="round"/></svg></div>
139
- </div>
140
- ```
128
+ ### Loading state
141
129
 
142
- Never write "Loading..." text placeholders - they don't animate and make slow loads look stuck.
130
+ The first-paint loading overlay (the `gh-loading` overlay baked into every resolve target) is part of the data-wiring contract with the SDK. Its canonical CSS, the markup, and the rule about which elements get it live in `runtime.md`.
143
131
 
144
- For inline chart implementations (bar, line, donut, heatmap, funnel, sparkline, gauge, stacked bar, multi-series), see `chart-patterns.md`.
132
+ For per-chart implementations (bar, line, donut, heatmap, funnel, sparkline, gauge, stacked bar, multi-series), see `chart-patterns.md`.