Build custom HTML dashboards from real data using the Graphit CLI. KB-aware queries, entity wrapping, cached data sources.

AI-native business intelligence for your terminal and your AI coding assistant.<br/> Turn natural language into governed SQL and bespoke, hand-authored HTML dashboards.
@graphit/cli exposes the Graphit platform as commands your AI coding assistant - Claude Code, Codex or Cursor - or you can drive directly: explore the governed semantic layer (the "knowledge base"), run governed queries against cached data sources, and build custom HTML/SVG/CSS dashboards on a bespoke canvas.
It installs as a plugin/skill, so your assistant knows how to reach for Graphit on any question about your business data - even when you never say "Graphit."
1. Install the CLI - the graphit binary, needed either way (requires Node.js 18+):
npm install -g @graphit/cli
graphit auth login
2. Connect it to your AI coding assistant by installing the Graphit plugin.
Plugin marketplace - Claude Code:
/plugin marketplace add omry-graphit/graphit-plugin
/plugin install graphit@graphit-plugin
Plugin marketplace - Codex:
codex plugin marketplace add omry-graphit/graphit-plugin
Cursor - install the Graphit plugin into Cursor's local plugins, then reload Cursor (Developer: Reload Window):
npx -y @graphit/cli setup --editor cursor
Cursor also loads Graphit's Claude Code plugin. If that plugin is on for all projects, setup says so and installs nothing. If it is on for one project only, setup still installs and warns that Graphit loads twice in that project. Update later with npx -y @graphit/cli setup --editor cursor --update.
VS Code - graphit setup --editor vscode --project writes the skill into the repo's AGENTS.md.
Each path installs the Graphit skills so the assistant can drive the CLI on your behalf.
Start a new session after installing the plugin. Claude Code (and Codex) load plugin skills and commands at session start, so the Graphit skill,
/graphit, and thegraphitcommands are not available in the session you installed from. Open a new session first - otherwise the assistant will not have the skill loaded and will improvise instead of following the guided workflow.
graphit auth login # Open the browser login flow
graphit auth status # Show the current account and org
graphit auth logout # Clear stored credentials
The CLI talks to https://api.graphit-app.com by default. Set GRAPHIT_API_URL to point at a different backend.
TLS connections are verified against Node's bundled CA certificates, which already cover the public Graphit API. If you are behind a corporate TLS-inspecting proxy whose root certificate lives only in your operating system's trust store, set GRAPHIT_USE_SYSTEM_CA=1 to also trust the system store. It is off by default because reading the system store is unnecessary for normal use and can crash under sandboxed agent runtimes (e.g. an editor's command sandbox on macOS, where keychain access is blocked).
| Group | What it does |
|---|---|
graphit auth | Log in, check status, log out |
graphit kb | Knowledge base: list, get, search, explore, and full CRUD for metrics, dimensions, rules, synonyms, domains, relationships, topics, tables, and templates |
graphit query | Run a governed SQL query against your data |
graphit ds | Data sources: list, create, refresh, verify, update cached sources |
graphit dashboard | Custom dashboards: list, create, get, update HTML or a single entity, export to PNG/PDF, delete |
graphit connector | Connections: list, add, and test. Removal is admin-only in the Sources Hub; the CLI verb explains the handoff but does not delete. |
graphit governance | Query governance: inspect and set the enforcement mode |
graphit setup | Install the Graphit plugin for Cursor, or remove legacy copied skill files |
Run any command with --help for its exact flags and subcommands:
graphit kb create metric --help
graphit dashboard export --help
Global --output <json|table|styled> controls how results print (defaults to json, the machine-readable form agents rely on).
{{metric:NAME}}, {{dim:NAME}}), and the enforcement mode (observe / warn / strict) is set per org. Ad-hoc measures on a governed source require explicit approval and can be hard-blocked under strict mode.Full docs, the complete CLI reference, and the dashboard build workflow live at graphit-app.com.
UNLICENSED. Copyright Graphit.
hooks/query-view/register.tsx 375 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { KB_PANE, LINEAGE_PANE, RESULTS_PANE, SQL_PANE, capped } from './atoms'
5import { buildModel, drawTooLarge } from './card'
6import type { CardActions } from './card'
7import { drawDesktopCard } from './card-desktop'
8import { drawTerminalCard } from './card-terminal'
9import { READ_LIMIT, openedFor, parsedFor, rememberOpened, rememberParsed, rememberSaved, savedFor, withReadSlot } from './loaders'
10import type { Loaded } from './loaders'
11import { drawExplorer, drawKbPane, drawSqlPane } from './panes'
12import { RESULTS_STEP, drawResultsPane, resultsWidth } from './results-pane'
13import { clean, isGraphitQuery, parseMetricDef, parseQueryResult, queryReachesStdout } from './result'
14import type { QueryResult } from './result'
15import type { Expansion } from './sql'
16import type { CommandByCall, Explore, KbDef, KbSelection, ResultsView, SqlPane, TabByCall, Upstream } from './state'
17import { assembleUpstream, errorOf, jsonOf } from './upstream'
18
19// Feature #1062: the selected tab of each result, keyed by tool_use_id (by the
20// group's requestId in a carousel); the KB reference the sidebar shows; the
21// definitions it fetched; each Bash call's command.
22const tabs = atom({ plugin: 'graphit', key: 'queryViewTab' } as const, {} as TabByCall)
23const selected = atom({ plugin: 'graphit', key: 'queryViewSelected' } as const, null as KbSelection | null)
24const defs = atom({ plugin: 'graphit', key: 'queryViewDefs' } as const, {} as Record<string, KbDef>)
25const commands = atom({ plugin: 'graphit', key: 'queryViewCommands' } as const, {} as CommandByCall)
26// Feature #1067: the query a folded group's carousel shows (by requestId); the
27// row and column page of each result; the upstream read for each query; the
28// lineage explorer's and full-SQL pane's content.
29const slides = atom({ plugin: 'graphit', key: 'queryViewSlide' } as const, {} as Record<string, number>)
30const pages = atom({ plugin: 'graphit', key: 'queryViewPage' } as const, {} as Record<string, number>)
31const upstreams = atom({ plugin: 'graphit', key: 'queryViewUpstream' } as const, {} as Record<string, Upstream>)
32const explore = atom({ plugin: 'graphit', key: 'queryViewExplore' } as const, null as Explore | null)
33const sqlPane = atom({ plugin: 'graphit', key: 'queryViewSqlPane' } as const, null as SqlPane | null)
34const resultsView = atom({ plugin: 'graphit', key: 'queryViewResults' } as const, null as ResultsView | null)
35
36// Feature #1077: tell scripts/show-query-result.mjs that this view draws the
37// session's query results, so it says "already displayed" instead of asking the
38// agent to repeat the table (plugin-status/query-view-marker.mjs reads it). The
39// hook's context cannot be rewritten from here: in the desktop app the classic
40// chain carried none of it (Feature #1067). Stamped before every query, keyed by
41// the current id - `/clear` and a resume continue under a new id without a
42// reload, and SessionStart prunes markers older than 48h. A failed stamp only
43// costs the old directive, so it never blocks the query.
44// eslint-disable-next-line @typescript-eslint/no-explicit-any
45async function stampDrawsMarker($: any): Promise<void> {
46 try {
47 const id = String(await $.session.id())
48 if (!/^[A-Za-z0-9._-]{1,128}$/.test(id)) return
49 // An empty variable counts as unset, as in plugin-status/query-view-marker.mjs.
50 const home = await $.env.get('HOME')
51 const data = (await $.env.get('GRAPHIT_PLUGIN_DATA')) || (home ? `${home}/.graphit` : null)
52 if (data) await $.fs.write(`${data}/sessions/${id}.query-view`, '{}')
53 } catch {
54 // An engine without these calls, or an unwritable directory.
55 }
56}
57
58// The command as shown on a card: from the graphit invocation on, so a long
59// path or a `cd ... &&` prefix does not hide the SQL.
60function commandLine(command: string): string {
61 const at = command.search(/(?:graphit|index\.js)\s+query\b/)
62 const shown = at > 0 ? command.slice(at) : command
63 return clean(shown).replace(/\s+/g, ' ').slice(0, 160)
64}
65
66// The chalk G's inner markup, read once per load from the plugin bundle.
67let logoInner: string | null = null
68
69// Feature #1067: the call's result - its output when that parses, else the
70// file Claude Code saved it to (read once, at draw time, never on tool.call).
71// eslint-disable-next-line @typescript-eslint/no-explicit-any
72async function loadResult($: any, id: string | undefined, output: unknown): Promise<Loaded> {
73 const direct = parseQueryResult(output)
74 if (direct) return { result: direct }
75 const file = savedFor(id)
76 if (!file) return null
77 if (file.size > READ_LIMIT) return { tooLarge: file.size }
78 if (parsedFor(file.path) === undefined) {
79 // A read that fails (past `$.fs.read`'s limit when the engine gave no size)
80 // is reported as too large rather than leaving the plain row.
81 let text: string
82 try {
83 text = await $.fs.read(file.path)
84 } catch {
85 return { tooLarge: file.size }
86 }
87 rememberParsed(file.path, parseQueryResult(text))
88 }
89 const result = parsedFor(file.path)
90 return result ? { result } : null
91}
92
93// Feature #1067: `$.process.run` resolves argv[0] on the app's own PATH, and
94// Claude Code puts the plugin's bin/ on the PATH of Bash calls only - so a bare
95// `graphit` never starts for a plugin-only install. Run the plugin's own
96// wrapper; a global install on PATH is the fallback (and covers Windows, where
97// the bash wrapper cannot start without a shell).
98// eslint-disable-next-line @typescript-eslint/no-explicit-any
99async function runGraphit($: any, args: string[], timeoutMs: number): Promise<{ exitCode?: number; stdout?: string; stderr?: string }> {
100 try {
101 return await $.process.run([`${$.plugin.root}/bin/graphit`, ...args], { timeoutMs })
102 } catch {
103 return await $.process.run(['graphit', ...args], { timeoutMs })
104 }
105}
106
107// Feature #1067: one query's upstream lineage, read through plain `graphit`
108// with the caller's own permissions - the source (its warehouse SQL and last
109// refresh), the semantic models, each referenced metric - at most
110// a shared cap of reads at a time (withReadSlot), cached per query.
111// eslint-disable-next-line @typescript-eslint/no-explicit-any
112async function loadUpstream($: any, key: string, sourceName: string | undefined, refs: Array<{ kind: string; name: string }>): Promise<void> {
113 if ((await read($, upstreams))[key]?.status === 'loading') return
114 await update($, upstreams, m => capped(m, key, { status: 'loading' } as Upstream))
115 let reason = ''
116 const run = async (args: string[]): Promise<unknown> => {
117 try {
118 const ran = await withReadSlot(() => runGraphit($, args, 30000))
119 const answer = jsonOf(String(ran.stdout ?? ''))
120 // The CLI prints its failure as `{"error": ...}` on stderr, after any warning.
121 if (!answer && !reason) reason = errorOf(String(ran.stderr ?? ''))
122 return answer
123 } catch {
124 return null
125 }
126 }
127 let up: Upstream
128 try {
129 const metricRefs = refs.filter(r => r.kind === 'Metric')
130 const [dsList, models, ...metrics] = await Promise.all([
131 run(['ds', 'list', '--limit', '200']),
132 run(['kb', 'list', 'semantic-model']),
133 ...metricRefs.map(r => run(['kb', 'get', 'metric', '--', r.name])),
134 ])
135 const ds = ((dsList as { data_sources?: Array<{ id?: string; name?: string }> } | null)?.data_sources ?? []).find(
136 d => !!sourceName && (d.name ?? '').toLowerCase() === sourceName.toLowerCase(),
137 )
138 const history = ds?.id ? await run(['ds', 'refresh-history', '--', ds.id]) : null
139 up = assembleUpstream({ sourceName, dsList, history, models, metrics, refs })
140 // Feature #1067: a source the signed-in org does not have is said plainly,
141 // never reported as a warehouse-to-result read.
142 if (dsList && sourceName && !ds) up.notFound = sourceName
143 up.loadedAt = new Date().toLocaleTimeString('en-GB', { timeZone: 'Asia/Jerusalem', hour: '2-digit', minute: '2-digit' })
144 if (!dsList && !models) {
145 up.status = 'error'
146 up.error = reason ? `the graphit CLI said: ${reason}` : 'the graphit CLI did not answer'
147 }
148 } catch {
149 // A malformed answer must never leave the lineage stuck on loading.
150 up = { status: 'error', error: 'the graphit CLI answered in an unexpected shape' }
151 }
152 await update($, upstreams, m => capped(m, key, up))
153}
154
155// Feature #1062: open the KB sidebar on a reference and read its definition.
156// eslint-disable-next-line @typescript-eslint/no-explicit-any
157async function openKb($: any, x: Expansion): Promise<void> {
158 const k = `${x.kind}:${x.name}`
159 await update($, selected, () => ({ kind: x.kind, name: x.name, alias: x.alias, expansion: x.expr }))
160 await $.ui.open({ id: KB_PANE, title: 'Knowledge Base', closeOnEscape: true, focus: true })
161 if (x.kind !== 'Metric') {
162 await update($, defs, d => capped(d, k, { status: 'unsupported' }))
163 return
164 }
165 if ((await read($, defs))[k]?.status === 'ok') return
166 await update($, defs, d => capped(d, k, { status: 'loading' }))
167 try {
168 const ran = await withReadSlot(() => runGraphit($, ['kb', 'get', 'metric', '--', x.name], 20000))
169 const def = parseMetricDef(ran.stdout || ran.stderr)
170 await update($, defs, d => capped(d, k, def))
171 } catch {
172 await update($, defs, d => capped(d, k, { status: 'error', error: 'Could not run graphit kb get.' }))
173 }
174}
175
176// One query's card: its state read here, drawn by the surface's renderer.
177// eslint-disable-next-line @typescript-eslint/no-explicit-any
178async function drawLoaded($: any, e: any, loaded: Loaded, id: string, command: string, header: string | null, nav: unknown = null, rowKey: string = id): Promise<unknown> {
179 const ui = $.ui.resolve(e)
180 if (loaded && 'tooLarge' in loaded) return drawTooLarge(ui, loaded.tooLarge)
181 const result = (loaded as { result: QueryResult }).result
182 if (logoInner === null) {
183 const logo: string = await $.fs.read(`${$.plugin.root}/hooks/query-view/graphit-logo.svg`).catch(() => '')
184 logoInner = logo.replace(/^[\s\S]*?<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '').replace(/<title>[\s\S]*?<\/title>/, '')
185 }
186 const paged = await read($, pages)
187 const colKey = `${rowKey}:cols`
188 let model: ReturnType<typeof buildModel> | null = null
189 const act: CardActions = {
190 setTab: tab => update($, tabs, m => capped(m, id, tab)),
191 goTo: n => update($, pages, m => capped(m, rowKey, Math.min(Math.max(n, 0), (model?.pageCount ?? 1) - 1))),
192 goCols: (n, count) => update($, pages, m => capped(m, colKey, Math.min(Math.max(n, 0), count - 1))),
193 openKb: x => openKb($, x),
194 loadUpstream: () => loadUpstream($, rowKey, model?.sourceName, model?.lineageRefs ?? []),
195 openExplorer: async (title, layers) => {
196 await update($, explore, () => ({ title, layers: layers.layers, edges: layers.edges, trail: [] }))
197 await $.ui.open({ id: LINEAGE_PANE, title: 'Lineage', closeOnEscape: true, focus: true })
198 },
199 openSql: async (title, sql) => {
200 await update($, sqlPane, () => ({ title, sql }))
201 await $.ui.open({ id: SQL_PANE, title, closeOnEscape: true, focus: true })
202 },
203 openResults: async () => {
204 rememberOpened(rowKey, result)
205 const title = `Results · ${model?.sourceName ?? 'query'}`
206 await update($, resultsView, () => ({ id: rowKey, title, query: '', sort: '', desc: false, limit: RESULTS_STEP }))
207 // Wide enough for every column; a width the person dragged to still wins.
208 await $.ui.open({ id: RESULTS_PANE, title, closeOnEscape: true, focus: true, columns: resultsWidth(result) })
209 },
210 copy: text => $.ui.copy({ text, surface: e.surface }),
211 }
212 model = buildModel({
213 id,
214 rowKey,
215 command,
216 result,
217 header,
218 nav,
219 state: {
220 tab: (await read($, tabs))[id],
221 page: paged[rowKey],
222 colPage: paged[colKey],
223 upstream: (await read($, upstreams))[rowKey],
224 logoInner,
225 },
226 act,
227 })
228 return e.surface === 'desktop' && ui.Svg ? drawDesktopCard(ui, model) : drawTerminalCard(ui, model)
229}
230
231export const register: Register = on => {
232 // The terminal's result row carries no command, so record each graphit query
233 // here for the render gate below. Feature #1067: a large result is moved to
234 // a file and drawn with an empty output; the engine names that file in
235 // `persistedOutputPath`, kept in module memory - nothing is read or parsed
236 // here, so a query is never slower for the view (Feature #1077's marker is
237 // one small write).
238 on('tool.call', { tool: 'Bash' }, async ($, e, proceed) => {
239 const command = (e as { command?: unknown }).command
240 if (typeof command !== 'string' || !e.tool_use_id || !isGraphitQuery(command)) return proceed(e)
241 const id = e.tool_use_id
242 await stampDrawsMarker($)
243 await update($, commands, m => capped(m, id, command))
244 const out = await proceed(e)
245 if (queryReachesStdout(command)) rememberSaved(id, (out as { result?: unknown } | null)?.result)
246 return out
247 })
248
249 // The terminal draws a standalone Bash result as its own ToolResult; the
250 // desktop draws command and output as one ToolUse card. Hook both.
251 for (const component of ['ToolResult', 'ToolUse'] as const) {
252 on('ui.render', { component }, async ($, e, proceed) => {
253 if (e.props.tool !== 'Bash' || e.props.isErrored) return proceed(e)
254 if ('isRunning' in e.props && e.props.isRunning) return proceed(e)
255 const command =
256 'input' in e.props ? (e.props.input as { command?: unknown } | null)?.command : (await read($, commands))[e.props.tool_use_id]
257 if (typeof command !== 'string' || !isGraphitQuery(command) || !queryReachesStdout(command)) {
258 return proceed(e)
259 }
260 const loaded = await loadResult($, e.props.tool_use_id, e.props.output)
261 if (!loaded) return proceed(e)
262 return drawLoaded($, e, loaded, e.props.tool_use_id, command, 'input' in e.props ? commandLine(command) : null)
263 })
264 }
265
266 // Feature #1067: the desktop folds a run of calls into one "Ran N commands"
267 // row. Keep that row as the app draws it and show the query card under it;
268 // several queries in one fold share a carousel. Unfolded, each row draws its
269 // own card.
270 on('ui.render', { component: 'ToolGroup' }, async ($, e, proceed) => {
271 if (e.props.isExpanded) return proceed(e)
272 const known = await read($, commands)
273 const groupKey = String(e.requestId)
274 const queries: Array<{ id: string; command: string; loaded: NonNullable<Loaded> }> = []
275 for (const [i, c] of e.props.calls.entries()) {
276 if (c.tool !== 'Bash' || c.isRunning || c.isErrored) continue
277 const input = (c.input as { command?: unknown } | null)?.command
278 const command = typeof input === 'string' ? input : c.tool_use_id ? known[c.tool_use_id] : undefined
279 if (typeof command !== 'string' || !isGraphitQuery(command) || !queryReachesStdout(command)) continue
280 const loaded = await loadResult($, c.tool_use_id, c.output)
281 if (loaded) queries.push({ id: c.tool_use_id ?? `${groupKey}:${i}`, command, loaded })
282 }
283 if (queries.length === 0) return proceed(e)
284 const engine = await proceed(e)
285 const stored = (await read($, slides))[groupKey]
286 const pos = Math.min(Math.max(stored ?? queries.length - 1, 0), queries.length - 1)
287 const q = queries[pos]
288 const { Box, Text, Button } = $.ui.resolve(e)
289 const nav =
290 queries.length > 1 ? (
291 <Box alignItems="center" gap={1} flexShrink={0}>
292 <Button
293 key={`slide-prev-${groupKey}`}
294 label="❮"
295 variant="secondary"
296 dimColor={pos === 0}
297 onPress={() => update($, slides, m => capped(m, groupKey, Math.max(pos - 1, 0)))}
298 />
299 <Button
300 key={`slide-next-${groupKey}`}
301 label="❯"
302 variant="secondary"
303 dimColor={pos === queries.length - 1}
304 onPress={() => update($, slides, m => capped(m, groupKey, Math.min(pos + 1, queries.length - 1)))}
305 />
306 <Text wrap="truncate">{`Query ${pos + 1} of ${queries.length}`}</Text>
307 </Box>
308 ) : null
309 // In a carousel the selected tab belongs to the group, so paging keeps it.
310 const card = await drawLoaded($, e, q.loaded, queries.length > 1 ? groupKey : q.id, q.command, commandLine(q.command), nav, q.id)
311 return (
312 <Box flexDirection="column">
313 {engine}
314 {card}
315 </Box>
316 )
317 })
318
319 on('ui.render', { component: 'Pane', requestId: KB_PANE }, async ($, e) => {
320 const sel = await read($, selected)
321 const def = sel ? (await read($, defs))[`${sel.kind}:${sel.name}`] : undefined
322 return drawKbPane($.ui.resolve(e), sel, def, text => $.ui.copy({ text, surface: e.surface }))
323 })
324 on('ui.render', { component: 'Pane', requestId: SQL_PANE }, async ($, e) =>
325 drawSqlPane($.ui.resolve(e), await read($, sqlPane), text => $.ui.copy({ text, surface: e.surface })),
326 )
327 // Feature #1067: Full results. A search starts the rows over at the first
328 // thousand; a header press sorts low to high, then high to low, then off.
329 on('ui.render', { component: 'Pane', requestId: RESULTS_PANE }, async ($, e) => {
330 const view = await read($, resultsView)
331 const set = (fn: (v: ResultsView) => ResultsView) => update($, resultsView, v => (v ? fn(v) : v))
332 return drawResultsPane($.ui.resolve(e), openedFor(view?.id), view, {
333 search: query => set(v => ({ ...v, query, limit: RESULTS_STEP })),
334 sortBy: column =>
335 set(v =>
336 v.sort !== column ? { ...v, sort: column, desc: false } : v.desc ? { ...v, sort: '', desc: false } : { ...v, desc: true },
337 ),
338 more: () => set(v => ({ ...v, limit: v.limit + RESULTS_STEP })),
339 copy: text => $.ui.copy({ text, surface: e.surface }),
340 })
341 })
342 // Feature #1067: the lineage explorer. The trail keeps '' for the layer list,
343 // so Back returns to it too. A pane that throws draws nothing; say why.
344 on('ui.render', { component: 'Pane', requestId: LINEAGE_PANE }, async ($, e) => {
345 try {
346 // A press redraws the pane and the pressed Button goes with it, which
347 // left the pane without the focus: the next press only took it back.
348 // Each move hands the focus back to the pane.
349 const refocus = () => $.ui.open({ id: LINEAGE_PANE, title: 'Lineage', closeOnEscape: true, focus: true })
350 return drawExplorer($.ui.resolve(e), await read($, explore), {
351 go: async to => {
352 await update($, explore, x => (x ? { ...x, selected: to, trail: [...x.trail, x.selected ?? ''].slice(-30) } : x))
353 await refocus()
354 },
355 back: async () => {
356 await update($, explore, x => (x && x.trail.length ? { ...x, selected: x.trail[x.trail.length - 1] || undefined, trail: x.trail.slice(0, -1) } : x))
357 await refocus()
358 },
359 all: async () => {
360 await update($, explore, x => (x ? { ...x, selected: undefined, trail: [...x.trail, x.selected ?? ''].slice(-30) } : x))
361 await refocus()
362 },
363 openSql: async (title, sql) => {
364 await update($, sqlPane, () => ({ title, sql }))
365 await $.ui.open({ id: SQL_PANE, title, closeOnEscape: true, focus: true })
366 },
367 copy: text => $.ui.copy({ text, surface: e.surface }),
368 })
369 } catch (err) {
370 const { Text } = $.ui.resolve(e)
371 return <Text color="red">{`The lineage explorer failed: ${String(err instanceof Error ? err.message : err).slice(0, 300)}`}</Text>
372 }
373 })
374}
375hooks/query-view/atoms.ts 19 lines1// The query view's pane ids and the cap on its state maps: every map keyed per
2// call or per group is capped, so a long session never grows it without bound.
3// The atoms themselves live in register.tsx - the state library reads only an
4// atom declared in the file that reads it.
5
6export const KB_PANE = 'graphit-kb'
7export const LINEAGE_PANE = 'graphit-lineage'
8export const SQL_PANE = 'graphit-sql'
9export const RESULTS_PANE = 'graphit-results'
10
11// Entries kept per map; the oldest drop past this.
12const MAX_ENTRIES = 200
13
14// `{ ...map, [key]: value }`, keeping only the newest MAX_ENTRIES keys.
15export function capped<T>(map: Record<string, T>, key: string, value: T): Record<string, T> {
16 const { [key]: _old, ...rest } = map
17 return Object.fromEntries([...Object.entries(rest), [key, value]].slice(-MAX_ENTRIES))
18}
19hooks/query-view/card.tsx 176 lines1// One query's card. The model below is what both surfaces draw: the desktop as
2// one frosted SVG with native controls laid over it (card-desktop.tsx), the
3// terminal as native rows (card-terminal.tsx). Pure: register.tsx reads the
4// state and supplies the actions (a mod's `$` never crosses an import).
5
6import { MAX_ROWS, OUTCOME_BADGE, REF_KIND, cell, clean, sqlFromCommand } from './result'
7import type { QueryResult } from './result'
8import { templateExpansions } from './sql'
9import type { Expansion } from './sql'
10import type { TabId, Upstream } from './state'
11import { lineageLayers, warehouseTables } from './upstream'
12import type { Layers } from './upstream'
13import { chartSpec } from './viz'
14import type { ChartSpec } from './viz'
15
16export type CardModel = {
17 // The tab state key (a carousel's group) and the per-query key (pages, upstream).
18 id: string
19 rowKey: string
20 command: string
21 header: string | null
22 nav: unknown
23 result: QueryResult
24 baseSql: string | undefined
25 active: TabId
26 tier: string
27 injections: NonNullable<NonNullable<NonNullable<QueryResult['provenance']>['injection_summary']>['injections']>
28 denied: NonNullable<NonNullable<NonNullable<QueryResult['provenance']>['injection_summary']>['override_denied']>
29 kbRefs: NonNullable<NonNullable<QueryResult['provenance']>['kb_references']>
30 kbCount: number
31 rowCount: number
32 hidden: Set<string>
33 meta: string[]
34 sourceName: string | undefined
35 cols: string[]
36 page: number
37 pageCount: number
38 first: number
39 shown: Array<Record<string, unknown>>
40 expansions: Expansion[]
41 chart: ChartSpec | null
42 upstream: Upstream | undefined
43 lineageRefs: Array<{ kind: string; name: string }>
44 layers: Layers
45 // The stored column page (the desktop clamps it to its own column sets) and
46 // the chalk G's inner markup ('' when the logo could not be read).
47 colPage: number
48 logoInner: string
49 expansionFor: (kind: string, name: string) => Expansion
50 act: CardActions
51}
52
53// What a control on the card does; each runs in register.tsx with `$`.
54export type CardActions = {
55 setTab: (tab: TabId) => Promise<unknown>
56 goTo: (page: number) => Promise<unknown>
57 goCols: (page: number, count: number) => Promise<unknown>
58 openKb: (x: Expansion) => Promise<unknown>
59 loadUpstream: () => Promise<unknown>
60 openExplorer: (title: string, layers: Layers) => Promise<unknown>
61 openSql: (title: string, sql: string) => Promise<unknown>
62 openResults: () => Promise<unknown>
63 copy: (text: string) => unknown
64}
65
66// The state a card reads, by its keys.
67export type CardState = { tab?: TabId; page?: number; colPage?: number; upstream?: Upstream; logoInner: string }
68
69// eslint-disable-next-line @typescript-eslint/no-explicit-any
70export type Ui = Record<string, any>
71
72// The source the result came from: the response's own name unless it is the
73// generic "data_source", else --ds, else the table the SQL as run reads.
74function sourceOf(result: QueryResult, command: string, baseSql: string | undefined): string | undefined {
75 if (result.source && result.source !== 'data_source') return result.source
76 const ds = /--ds(?:=|\s+)(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(command)
77 if (ds) return ds[1] ?? ds[2] ?? ds[3]
78 return warehouseTables(clean(result.governed_sql ?? baseSql ?? ''))[0]
79}
80
81export function buildModel(input: {
82 id: string
83 rowKey: string
84 command: string
85 result: QueryResult
86 header: string | null
87 nav: unknown
88 state: CardState
89 act: CardActions
90}): CardModel {
91 const { id, rowKey, command, result, header, nav, state, act } = input
92 const baseSql = result.sql ?? sqlFromCommand(command)
93 const prov = result.provenance ?? {}
94 const injections = prov.injection_summary?.injections ?? []
95 const kbRefs = prov.kb_references ?? []
96 const rowCount = result.row_count ?? result.rows.length
97 const hidden = new Set((result.hidden_columns ?? []).map(String))
98
99 const meta: string[] = [`${rowCount.toLocaleString('en-US')} rows`]
100 if (result.truncated) meta.push('truncated by --limit')
101 if (typeof result.query_ms === 'number') meta.push(`${result.query_ms.toFixed(0)}ms`)
102
103 const cols = (result.columns?.length ? result.columns : Object.keys(result.rows[0] ?? {})).map(String)
104 // Feature #1067: the table pages through every returned row.
105 const pageCount = Math.max(1, Math.ceil(result.rows.length / MAX_ROWS))
106 const page = Math.min(Math.max(state.page ?? 0, 0), pageCount - 1)
107 const first = page * MAX_ROWS
108
109 // What each KB reference compiled to here, paired by select alias against
110 // the returned governed_sql - the masked copy, so the view never shows
111 // definition text the receipt hides.
112 const expansions = baseSql && result.governed_sql ? templateExpansions(clean(baseSql), clean(result.governed_sql)) : []
113 const sourceName = sourceOf(result, command, baseSql)
114 const upstream = state.upstream
115 const lineageRefs = kbRefs.map(ref => ({ kind: REF_KIND[ref.kind ?? ''] ?? 'Metric', name: clean(ref.name ?? '') }))
116
117 return {
118 id,
119 rowKey,
120 command,
121 header,
122 nav,
123 result,
124 baseSql,
125 active: state.tab ?? 'results',
126 tier: clean(prov.tier ?? 'ad_hoc'),
127 injections,
128 denied: prov.injection_summary?.override_denied ?? [],
129 kbRefs,
130 kbCount: kbRefs.length || prov.kb_refs || 0,
131 rowCount,
132 hidden,
133 meta,
134 sourceName,
135 cols,
136 page,
137 pageCount,
138 first,
139 shown: result.rows.slice(first, first + MAX_ROWS),
140 expansions,
141 chart: chartSpec(cols, result.rows, hidden),
142 upstream,
143 lineageRefs,
144 layers: lineageLayers({
145 up: upstream,
146 sourceName,
147 rowCount,
148 columns: cols,
149 hidden,
150 refs: lineageRefs,
151 expansions,
152 rules: injections.map(inj => ({
153 name: cell(inj.rule_name ?? inj.source_id ?? 'rule'),
154 clauses: (inj.added_clauses ?? []).map(clean),
155 masks: (inj.transformations ?? []).map(t => cell(t.column)).filter(Boolean),
156 color: (OUTCOME_BADGE[inj.outcome ?? ''] ?? { color: '#6b7280' }).color,
157 why: inj.why ? clean(inj.why) : undefined,
158 })),
159 }),
160 colPage: state.colPage ?? 0,
161 logoInner: state.logoInner,
162 expansionFor: (kind, name) => expansions.find(x => x.kind === kind && x.name === name) ?? { kind, name, alias: '', expr: '' },
163 act,
164 }
165}
166
167// A result too large for `$.fs.read`: said plainly instead of a vanished card.
168export function drawTooLarge(ui: Ui, size: number): unknown {
169 const { Box, Text } = ui
170 return (
171 <Box marginTop={1} borderStyle="round" borderDimColor paddingX={1}>
172 <Text dimColor>{`Graphit query result too large to draw${size > 0 ? ` (${(size / 1024 / 1024).toFixed(1)} MB)` : ''} - narrow it with --limit or a WHERE clause.`}</Text>
173 </Box>
174 )
175}
176hooks/query-view/card-desktop.tsx 220 lines1// The card on the desktop: one frosted SVG - header row, tab band, the panel,
2// a status bar - with native Buttons laid over its bands. An SVG draws as an
3// image (no clicks reach the mod), so every control is a Button placed with
4// `position: "absolute"`, in character cells (about 8 x 18 px).
5
6import type { CardModel, Ui } from './card'
7import { OUTCOME_BADGE, REF_KIND, TIER_COLOR, TIER_LABEL, cell, clean, fmtNumber, marksFor } from './result'
8import { TEMPLATE_COLOR } from './spans'
9import { formatLines, lineText } from './sql'
10import type { TabId } from './state'
11import { columnWidths, fitColumns, renderCardSvg } from './svgcard'
12import type { SvgPanel } from './svgcard'
13
14// Bands the SVG leaves empty for the native tabs and the footer controls,
15// sized so a row laid 4 cells down sits on the tab band's center line.
16const TOOLBAR_PX = 46
17const FOOTER_PX = 56
18// The card shows this many SQL lines; the sidebar shows them all.
19const CARD_SQL_LINES = 12
20
21export function drawDesktopCard(ui: Ui, m: CardModel): unknown {
22 const { Box, Text, Button, Svg } = ui
23 const { active, result, injections, kbRefs, kbCount, hidden, cols, shown, upstream, layers } = m
24
25 // Right-aligned only when the page holds a number; an all-null (masked) column stays left.
26 const numeric = cols.map(c => !hidden.has(c) && shown.some(r => typeof r[c] === 'number') && shown.every(r => typeof r[c] === 'number' || r[c] === null))
27 // Columns page like rows: the ones that fit the card, then the next set.
28 const cellRows = shown.map(r => cols.map(c => (typeof r[c] === 'number' ? fmtNumber(r[c] as number) : cell(r[c]))))
29 const widths = columnWidths(cols, cellRows, hidden)
30 const colStarts: number[] = []
31 for (let at = 0; at < cols.length; at += fitColumns(widths, at)) colStarts.push(at)
32 const colPage = Math.min(Math.max(m.colPage, 0), Math.max(colStarts.length - 1, 0))
33 const cStart = colStarts[colPage] ?? 0
34 const cEnd = cStart + fitColumns(widths, cStart)
35 const goCols = (n: number) => m.act.goCols(n, colStarts.length)
36
37 const sqlPanel = (kind: 'base' | 'runtime', text: string | undefined): SvgPanel => ({
38 kind: 'code',
39 lines: formatLines(clean(text ?? '')).map(line => {
40 const marks = marksFor(line, kind, injections, m.expansions)
41 const notes = [...new Set(marks.map(mk => mk.note).filter(Boolean))]
42 return { tokens: line, mark: marks[0] ? { glyph: marks[0].marker, color: marks[0].color, note: notes.join(', ') } : undefined }
43 }),
44 maxLines: CARD_SQL_LINES,
45 })
46 const panels: Record<TabId, () => SvgPanel> = {
47 results: () => ({
48 kind: 'table',
49 columns: cols.slice(cStart, cEnd),
50 rows: cellRows.map(r => r.slice(cStart, cEnd)),
51 hidden,
52 numeric: numeric.slice(cStart, cEnd),
53 note: hidden.size > 0 ? `⊘ hidden by policy: ${[...hidden].join(', ')}` : undefined,
54 }),
55 chart: () => ({ kind: 'chart', spec: m.chart }),
56 lineage: () => ({ kind: 'layers', spec: layers }),
57 base: () => sqlPanel('base', m.baseSql),
58 runtime: () => sqlPanel('runtime', result.governed_sql),
59 kb: () => ({
60 kind: 'list',
61 items: kbRefs.map(ref => {
62 const kindName = REF_KIND[ref.kind ?? ''] ?? 'Metric'
63 const x = m.expansionFor(kindName, ref.name ?? '')
64 return {
65 title: cell(ref.name),
66 badge: { text: kindName.toUpperCase(), color: TEMPLATE_COLOR[kindName] ?? TEMPLATE_COLOR.Metric },
67 lines: [`{{ ${kindName}('${cell(ref.name)}') }}`, ...(x.expr ? [`${x.expr === x.alias ? 'Column' : 'Compiles to'} ${x.expr}`] : [])],
68 }
69 }),
70 }),
71 governance: () => ({
72 kind: 'list',
73 items: injections.map(inj => {
74 const outcome = OUTCOME_BADGE[inj.outcome ?? ''] ?? { label: clean(inj.outcome ?? 'applied').toUpperCase(), color: '#6b7280' }
75 return {
76 title: cell(inj.rule_name ?? inj.source_id ?? 'rule'),
77 badge: { text: outcome.label, color: outcome.color },
78 lines: [
79 ...(inj.why ? [clean(inj.why)] : []),
80 ...(inj.added_clauses ?? []).map(cl => `+ ${clean(cl)}`),
81 ...(inj.transformations ?? []).filter(t => t.column).map(t => `~ ${cell(t.column)} returned as NULL`),
82 ],
83 }
84 }),
85 }),
86 }
87
88 const isSql = active === 'base' || active === 'runtime'
89 const segments: Array<{ id: TabId; label: string; on: boolean; go: TabId }> = [
90 { id: 'results', label: 'Results', on: active === 'results', go: 'results' },
91 { id: 'runtime', label: 'SQL', on: isSql, go: isSql ? active : 'runtime' },
92 { id: 'chart', label: 'Graph', on: active === 'chart', go: 'chart' },
93 { id: 'lineage', label: 'Lineage', on: active === 'lineage', go: 'lineage' },
94 { id: 'kb', label: `KB (${kbCount})`, on: active === 'kb', go: 'kb' },
95 { id: 'governance', label: `Rules (${injections.length})`, on: active === 'governance', go: 'governance' },
96 ]
97 const segment = (key: string, label: string, on: boolean, press: () => unknown) => (
98 <Button key={key} label={label} variant={on ? 'primary' : undefined} plain={on ? undefined : true} dimColor={!on} onPress={press} />
99 )
100 const sqlText = clean((active === 'base' ? m.baseSql : result.governed_sql) ?? '')
101 // Feature #1067: every footer reads from the left - the desktop sizes the
102 // laid-over controls a little wider than the card's own cells, so a row
103 // anchored to the right edge runs past it.
104 const n = (x: number) => x.toLocaleString('en-US')
105 // The lineage status reads in the native footer, after its buttons.
106 const status =
107 active === 'lineage'
108 ? upstream?.status === 'ok'
109 ? upstream.notFound
110 ? `${upstream.notFound} isn't in your Graphit org - showing the query only`
111 : `Warehouse to result, read from Graphit at ${upstream.loadedAt ?? ''}`
112 : upstream?.status === 'loading'
113 ? 'Reading the warehouse, data source and KB definitions…'
114 : upstream?.status === 'error'
115 ? `Upstream not read: ${upstream.error ?? 'unknown error'}`
116 : 'Showing the query only; load the upstream for the full chain'
117 : undefined
118 const footer = active === 'results' && shown.length > 0 ? (
119 <Box justifyContent="flex-start" alignItems="center" gap={3}>
120 <Button key={`results-full-${m.rowKey}`} label="Full results ↗" variant="secondary" onPress={() => m.act.openResults()} />
121 <Box alignItems="center" gap={2}>
122 <Box alignItems="center" gap={1}>
123 {m.pageCount > 2 ? <Button key={`pg-first-${m.rowKey}`} label="«" plain dimColor={m.page === 0} onPress={() => m.act.goTo(0)} /> : null}
124 {m.pageCount > 1 ? <Button key={`pg-prev-${m.rowKey}`} label="❮" variant="secondary" dimColor={m.page === 0} onPress={() => m.act.goTo(m.page - 1)} /> : null}
125 <Text dimColor>{`Rows ${n(m.first + 1)}-${n(m.first + shown.length)} of ${n(result.rows.length)}`}</Text>
126 {m.pageCount > 1 ? <Button key={`pg-next-${m.rowKey}`} label="❯" variant="secondary" dimColor={m.page === m.pageCount - 1} onPress={() => m.act.goTo(m.page + 1)} /> : null}
127 {m.pageCount > 2 ? <Button key={`pg-last-${m.rowKey}`} label="»" plain dimColor={m.page === m.pageCount - 1} onPress={() => m.act.goTo(m.pageCount - 1)} /> : null}
128 </Box>
129 {colStarts.length > 1 ? (
130 <Box alignItems="center" gap={1}>
131 <Button key={`col-prev-${m.rowKey}`} label="❮" variant="secondary" dimColor={colPage === 0} onPress={() => goCols(colPage - 1)} />
132 <Text dimColor>{`Columns ${cStart + 1}-${cEnd} of ${cols.length}`}</Text>
133 <Button key={`col-next-${m.rowKey}`} label="❯" variant="secondary" dimColor={colPage === colStarts.length - 1} onPress={() => goCols(colPage + 1)} />
134 </Box>
135 ) : null}
136 </Box>
137 </Box>
138 ) : active === 'lineage' ? (
139 <Box justifyContent="flex-start" alignItems="center" gap={1}>
140 <Button
141 key={`up-explore-${m.rowKey}`}
142 label="Explore lineage"
143 variant="secondary"
144 onPress={() => m.act.openExplorer(`${m.sourceName ?? 'Query'} lineage`, layers)}
145 />
146 <Button
147 key={`up-load-${m.rowKey}`}
148 label={upstream?.status === 'ok' ? 'Reload' : upstream?.status === 'loading' ? 'Loading…' : 'Load upstream'}
149 variant="secondary"
150 dimColor={upstream?.status === 'loading'}
151 onPress={() => m.act.loadUpstream()}
152 />
153 {status ? <Text dimColor wrap="truncate-end">{status}</Text> : null}
154 </Box>
155 ) : isSql ? (
156 <Box justifyContent="flex-start" alignItems="center" gap={3}>
157 {/* Which SQL the panel shows, as a highlighted toggle on the left. */}
158 <Box alignItems="center" gap={1}>
159 {(['base', 'runtime'] as const).map(kind => (
160 <Button
161 key={`sql-${kind === 'base' ? 'base' : 'run'}-${m.id}`}
162 label={kind === 'base' ? 'As written' : 'As run'}
163 variant={active === kind ? 'primary' : 'secondary'}
164 onPress={() => m.act.setTab(kind)}
165 />
166 ))}
167 </Box>
168 <Box alignItems="center" gap={1}>
169 {sqlText ? (
170 <Button key={`sql-copy-${m.id}`} label="Copy" plain onPress={() => m.act.copy(formatLines(sqlText).map(lineText).join('\n'))} />
171 ) : null}
172 {sqlText ? (
173 <Button
174 key={`sql-open-${m.id}`}
175 label="Open full SQL"
176 variant="secondary"
177 onPress={() => m.act.openSql(active === 'base' ? 'SQL as written' : 'SQL as run', sqlText)}
178 />
179 ) : null}
180 </Box>
181 </Box>
182 ) : null
183
184
185 const card = renderCardSvg({
186 logoInner: m.logoInner,
187 tierLabel: TIER_LABEL[m.tier] ?? clean(m.tier),
188 tierColor: TIER_COLOR[m.tier] ?? TIER_COLOR.ad_hoc,
189 meta: m.meta.join(' · '),
190 source: m.sourceName ? cell(m.sourceName) : undefined,
191 panel: panels[active](),
192 toolbar: TOOLBAR_PX,
193 // Feature #1067: every tab keeps the footer band, so the card's height never changes.
194 footer: FOOTER_PX,
195 })
196 return (
197 <Box flexDirection="column" marginTop={1} alignSelf="flex-start">
198 {/* Feature #1067: the lineage tab draws interactive, so a node's <title> shows its full name on hover. */}
199 <Svg key="card" source={card.svg} alt={`Graphit query result: ${m.meta.join(', ')}`} width={card.width} height={card.height} isInteractive={active === 'lineage' || undefined} />
200 {/* The tab band: tabs on the left, the query switcher on the right. */}
201 <Box position="absolute" top={4} left={2} right={2} alignItems="center" justifyContent="space-between">
202 <Box alignItems="center" gap={1}>
203 {segments.map(t =>
204 segment(`tab-${t.id}`, t.label, t.on, async () => {
205 await m.act.setTab(t.go)
206 if (t.go === 'lineage' && !upstream) await m.act.loadUpstream()
207 }),
208 )}
209 </Box>
210 {m.nav}
211 </Box>
212 {footer ? (
213 <Box position="absolute" bottom={2} left={2} right={2}>
214 {footer}
215 </Box>
216 ) : null}
217 </Box>
218 )
219}
220hooks/query-view/card-terminal.tsx 342 lines1// The card on the terminal: native rows - a tier badge, tabs, the selected
2// panel - with clickable KB references that open the KB sidebar.
3
4import type { CardModel, Ui } from './card'
5import { MARK, OUTCOME_BADGE, REF_KIND, TIER_COLOR, cell, clean, fmtNumber, marksFor } from './result'
6import { TEMPLATE_COLOR, tokenSpans } from './spans'
7import { formatLines, lineText, tokenize } from './sql'
8import type { Expansion, Token } from './sql'
9import type { TabId } from './state'
10import { SERIES_COLORS } from './viz'
11
12export function drawTerminalCard(ui: Ui, m: CardModel): unknown {
13 const { Box, Text, Button } = ui
14 const { active, result, injections, denied, kbRefs, kbCount, hidden, cols, shown, chart, layers } = m
15
16 const tabList: Array<{ id: TabId; label: string }> = [
17 { id: 'results', label: `Results (${m.rowCount})` },
18 { id: 'base', label: 'Base query' },
19 { id: 'runtime', label: 'Runtime SQL' },
20 { id: 'chart', label: 'Graph' },
21 { id: 'lineage', label: 'Lineage' },
22 { id: 'kb', label: `KB assets (${kbCount})` },
23 { id: 'governance', label: `Governance (${injections.length})` },
24 ]
25
26 const openKb = (x: Expansion) => m.act.openKb(x)
27
28 const pager = (
29 <Box key={`pager-${m.rowKey}`} gap={1} alignItems="center">
30 {m.pageCount > 1
31 ? [
32 <Button key={`pg-prev-${m.rowKey}`} label="‹ Prev" dimColor={m.page === 0} onPress={() => m.act.goTo(m.page - 1)} />,
33 <Text key={`pg-at-${m.rowKey}`} dimColor>{`Rows ${(m.first + 1).toLocaleString('en-US')}-${(m.first + shown.length).toLocaleString('en-US')} of ${result.rows.length.toLocaleString('en-US')} · page ${m.page + 1} / ${m.pageCount.toLocaleString('en-US')}`}</Text>,
34 <Button key={`pg-next-${m.rowKey}`} label="Next ›" dimColor={m.page === m.pageCount - 1} onPress={() => m.act.goTo(m.page + 1)} />,
35 ]
36 : null}
37 <Button key={`results-full-${m.rowKey}`} label="Full results ›" onPress={() => m.act.openResults()} />
38 </Box>
39 )
40
41 // One Box per column, so the table aligns on a proportional font too.
42 const resultsTab =
43 cols.length === 0 || shown.length === 0 ? (
44 <Text dimColor>Query returned 0 rows.</Text>
45 ) : (
46 <Box flexDirection="column">
47 <Box gap={3}>
48 {cols.map((c, i) => (
49 <Box key={`col${i}`} flexDirection="column">
50 <Text bold color={hidden.has(c) ? 'gray' : undefined}>
51 {hidden.has(c) ? `${cell(c)} ⊘` : cell(c)}
52 </Text>
53 {shown.map((r, ri) => (
54 <Text key={`c${i}-${ri}`} dimColor={hidden.has(c)}>
55 {hidden.has(c) ? '•••' : (typeof r[c] === 'number' ? fmtNumber(r[c] as number) : cell(r[c])) || ' '}
56 </Text>
57 ))}
58 </Box>
59 ))}
60 </Box>
61 {pager}
62 {hidden.size > 0 ? <Text color="gray">⊘ hidden by policy: {[...hidden].map(cell).join(', ')}</Text> : null}
63 </Box>
64 )
65
66 const sqlTab = (kind: 'base' | 'runtime', text: string | undefined, missing: string) => {
67 if (!text) return <Text dimColor>{missing}</Text>
68 const lines = formatLines(clean(text))
69 const formatted = lines.map(lineText).join('\n')
70 const width = String(lines.length).length
71 return (
72 <Box flexDirection="column">
73 <Box justifyContent="space-between">
74 <Box gap={1}>
75 <Text bold>{kind === 'base' ? 'SQL as written' : 'SQL as run'}</Text>
76 <Text dimColor>{`${lines.length} lines`}</Text>
77 {kind === 'runtime' ? (
78 <Text>
79 <Text color={MARK.added.color}>{' + rule added'}</Text>
80 <Text color={MARK.masked.color}>{' ~ rule masked'}</Text>
81 <Text color={MARK.kb.color}>{' ◆ KB expansion'}</Text>
82 </Text>
83 ) : (
84 <Text color={MARK.kb.color}>{' ◆ KB reference - click it for the definition'}</Text>
85 )}
86 </Box>
87 <Button key={`copy-${kind}`} label="Copy" onPress={() => m.act.copy(formatted)} />
88 </Box>
89 {lines.map((line, i) => {
90 const marks = marksFor(line, kind, injections, m.expansions)
91 const lead = marks[0]
92 const notes = [...new Set(marks.map(mk => mk.note).filter(Boolean))]
93 // Split the line at each template so the reference itself is the button.
94 const segments: Array<{ tokens: Token[]; x?: Expansion }> = [{ tokens: [] }]
95 for (const t of line) {
96 segments[segments.length - 1].tokens.push(t)
97 if (t.kind === 'template') {
98 segments[segments.length - 1].x = m.expansionFor(t.templateKind ?? 'Metric', t.templateName ?? '')
99 segments.push({ tokens: [] })
100 }
101 }
102 return (
103 <Box key={`ln-${kind}-${i}`} gap={1}>
104 <Text dimColor>{String(i + 1).padStart(width)}</Text>
105 <Text color={lead?.color} bold>
106 {lead?.marker ?? ' '}
107 </Text>
108 <Box>
109 {segments.map((seg, si) => {
110 const plain = seg.x ? seg.tokens.slice(0, -1) : seg.tokens
111 const tpl = seg.x ? seg.tokens[seg.tokens.length - 1] : undefined
112 return (
113 <Box key={`sg${si}`}>
114 <Text>{tokenSpans(Text, plain, `t${si}-`)}</Text>
115 {seg.x && tpl ? (
116 <Button
117 key={`kb-${kind}-${i}-${si}`}
118 label={`{{ ${cell(tpl.templateKind)}('${cell(tpl.templateName)}') }}`}
119 plain
120 hover={{ color: TEMPLATE_COLOR[tpl.templateKind ?? ''] ?? TEMPLATE_COLOR.Metric }}
121 onPress={() => openKb(seg.x as Expansion)}
122 />
123 ) : null}
124 </Box>
125 )
126 })}
127 </Box>
128 {notes.length > 0 ? <Text color={lead.color}>{` ← ${notes.join(', ')}`}</Text> : null}
129 </Box>
130 )
131 })}
132 </Box>
133 )
134 }
135
136 const label = (text: string) => (
137 <Box minWidth={12}>
138 <Text dimColor>{text}</Text>
139 </Box>
140 )
141 const badge = (text: string, color: string) => (
142 <Text backgroundColor={color} color="#ffffff" bold>
143 {` ${text} `}
144 </Text>
145 )
146
147 const kbTab =
148 kbRefs.length > 0 ? (
149 <Box flexDirection="column" gap={1}>
150 {kbRefs.map((ref, i) => {
151 const kindName = REF_KIND[ref.kind ?? ''] ?? 'Metric'
152 const color = TEMPLATE_COLOR[kindName] ?? TEMPLATE_COLOR.Metric
153 const x = m.expansionFor(kindName, ref.name ?? '')
154 return (
155 <Box key={`kb${i}`} flexDirection="column">
156 <Box gap={1}>
157 {badge(kindName.toUpperCase(), color)}
158 <Button key={`kb-tab-${i}`} label={cell(ref.name)} plain hover={{ color }} onPress={() => openKb(x)} />
159 {ref.deprecated ? badge('DEPRECATED', '#dc2626') : null}
160 </Box>
161 <Box gap={2}>
162 {label('Reference')}
163 <Text>{tokenSpans(Text, tokenize(`{{ ${kindName}('${cell(ref.name)}') }}`), `kbr${i}-`)}</Text>
164 </Box>
165 {x.expr ? (
166 <Box gap={2}>
167 {label(x.expr === x.alias ? 'Column' : 'Compiles to')}
168 <Text>{tokenSpans(Text, tokenize(x.expr), `kbe${i}-`)}</Text>
169 </Box>
170 ) : null}
171 {x.alias ? (
172 <Box gap={2}>
173 {label('Used as')}
174 <Text bold>{cell(x.alias)}</Text>
175 </Box>
176 ) : null}
177 </Box>
178 )
179 })}
180 </Box>
181 ) : (
182 <Text dimColor>{kbCount > 0 ? `${kbCount} KB assets referenced; this CLI version does not send their names.` : 'No KB assets referenced.'}</Text>
183 )
184
185 const filtersAdded = injections.reduce((n, inj) => n + (inj.added_clauses?.length ?? 0), 0)
186 const columnsMasked = injections.reduce((n, inj) => n + (inj.transformations ?? []).filter(t => t.column).length, 0)
187 const summary = [
188 `${injections.length} rule${injections.length === 1 ? '' : 's'} applied`,
189 filtersAdded ? `${filtersAdded} filter${filtersAdded === 1 ? '' : 's'} added` : '',
190 columnsMasked ? `${columnsMasked} column${columnsMasked === 1 ? '' : 's'} masked` : '',
191 denied.length ? `${denied.length} override${denied.length === 1 ? '' : 's'} denied` : '',
192 ].filter(Boolean)
193
194 const governanceTab =
195 injections.length === 0 && denied.length === 0 ? (
196 <Text dimColor>No governance rules applied.</Text>
197 ) : (
198 <Box flexDirection="column" gap={1}>
199 <Text dimColor>{summary.join(' · ')}</Text>
200 {injections.map((inj, i) => {
201 const name = cell(inj.source === 'deprecated_asset' ? inj.source_id : inj.rule_name ?? inj.source_id ?? 'rule')
202 const outcome = OUTCOME_BADGE[inj.outcome ?? ''] ?? { label: clean(inj.outcome ?? 'applied').toUpperCase(), color: '#6b7280' }
203 return (
204 <Box key={`inj${i}`} flexDirection="column">
205 <Box gap={1}>
206 {badge(outcome.label, outcome.color)}
207 <Text bold>{name}</Text>
208 </Box>
209 {inj.why ? <Text>{clean(inj.why)}</Text> : null}
210 {(inj.added_clauses ?? []).map((cl, j) => (
211 <Box key={`cl${j}`} gap={2}>
212 {label(j === 0 ? 'Adds filter' : '')}
213 <Text>
214 <Text color={MARK.added.color} bold>
215 {'+ '}
216 </Text>
217 {tokenSpans(Text, tokenize(clean(cl)), `cl${i}-${j}-`)}
218 </Text>
219 </Box>
220 ))}
221 {(inj.transformations ?? [])
222 .filter(t => t.column)
223 .map((t, j) => (
224 <Box key={`tr${j}`} gap={2}>
225 {label(j === 0 ? 'Masks column' : '')}
226 <Text>
227 <Text color={MARK.masked.color} bold>
228 {'~ '}
229 </Text>
230 <Text bold>{cell(t.column)}</Text>
231 <Text dimColor>{' returned as NULL'}</Text>
232 </Text>
233 </Box>
234 ))}
235 {inj.reason ? (
236 <Box gap={2}>
237 {label('Note')}
238 <Text dimColor>{clean(inj.reason)}</Text>
239 </Box>
240 ) : null}
241 </Box>
242 )
243 })}
244 {denied.map((d, i) => (
245 <Box key={`den${i}`} flexDirection="column">
246 <Box gap={1}>
247 {badge('OVERRIDE DENIED', '#dc2626')}
248 <Text bold>{cell(d.rule_name ?? 'rule')}</Text>
249 </Box>
250 {d.reason ? <Text>{clean(d.reason)}</Text> : null}
251 </Box>
252 ))}
253 </Box>
254 )
255
256 // Feature #1067: the graph as bars of block characters, one row per value.
257 const peak = chart ? Math.max(1, ...chart.series.flatMap(s => s.values.map(v => Math.abs(v ?? 0)))) : 1
258 const chartTab = !chart ? (
259 <Text dimColor>Nothing to graph: the result needs a category or date column and a number column.</Text>
260 ) : (
261 <Box flexDirection="column">
262 <Text bold>{`${chart.measure} by ${chart.x}`}</Text>
263 {chart.categories.map((c, i) => (
264 <Box key={`bar${i}`} flexDirection="column">
265 {chart.series.map((s, si) => (
266 <Box key={`bar${i}-${si}`} gap={1}>
267 <Box minWidth={12}>
268 <Text dimColor>{si === 0 ? cell(c) : ''}</Text>
269 </Box>
270 <Text color={SERIES_COLORS[si]}>{'█'.repeat(Math.max(1, Math.round((Math.abs(s.values[i] ?? 0) / peak) * 40)))}</Text>
271 <Text>{`${s.values[i] === null ? '-' : fmtNumber(s.values[i] as number)}${chart.series.length > 1 ? ` ${cell(s.name)}` : ''}`}</Text>
272 </Box>
273 ))}
274 </Box>
275 ))}
276 {chart.note ? <Text dimColor>{chart.note}</Text> : null}
277 </Box>
278 )
279
280 // Feature #1067: the lineage as its layers, top to bottom.
281 const lineageTab = (
282 <Box flexDirection="column">
283 {layers.layers.map((layer, li) => (
284 <Box key={`lay${li}`} gap={1}>
285 <Box minWidth={20}>
286 <Text dimColor bold>
287 {layer.label}
288 </Text>
289 </Box>
290 <Text>{layer.nodes.map(n => cell(n.title)).join(' · ')}</Text>
291 </Box>
292 ))}
293 </Box>
294 )
295
296 const body: Record<TabId, unknown> = {
297 results: resultsTab,
298 chart: chartTab,
299 lineage: lineageTab,
300 base: sqlTab('base', m.baseSql, 'The base query is not in this result.'),
301 runtime: sqlTab('runtime', result.governed_sql, 'No runtime SQL in this result.'),
302 kb: kbTab,
303 governance: governanceTab,
304 }
305
306 return (
307 <Box flexDirection="column">
308 {m.header ? (
309 <Text key="command" dimColor wrap="truncate-end">
310 {`Bash $ ${m.header}`}
311 </Text>
312 ) : null}
313 <Box gap={1} justifyContent="space-between">
314 <Box gap={1}>
315 <Text key="badge" backgroundColor={TIER_COLOR[m.tier] ?? 'gray'} color="black" bold>
316 {` ${m.tier.replace('_', '-')} `}
317 </Text>
318 <Text dimColor>
319 {m.meta.join(' · ')}
320 {m.sourceName ? <Text bold dimColor={false}>{` · ${cell(m.sourceName)}`}</Text> : null}
321 </Text>
322 </Box>
323 {m.nav}
324 </Box>
325 <Box gap={1} marginTop={1} flexWrap="wrap">
326 {tabList.map(t => (
327 <Button
328 key={`tab-${t.id}`}
329 label={t.id === active ? `▸ ${t.label}` : t.label}
330 variant={t.id === active ? 'primary' : 'secondary'}
331 dimColor={t.id !== active}
332 onPress={() => m.act.setTab(t.id)}
333 />
334 ))}
335 </Box>
336 <Box key={`panel-${active}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
337 {body[active]}
338 </Box>
339 </Box>
340 )
341}
342hooks/query-view/loaders.ts 73 lines1// Feature #1067: bookkeeping for what the view reads beyond the Bash output - a
2// large result Claude Code saved to a file, and the lineage upstream. Pure: the
3// reads themselves run in register.tsx (a mod's `$` never crosses an import).
4// None of it runs on a query's path: tool.call only remembers a path.
5
6import type { QueryResult } from './result'
7
8// `$.fs.read` refuses a file past this; such a result is reported, not read.
9export const READ_LIMIT = 4 * 1024 * 1024
10const MAX_SAVED = 200
11const MAX_PARSED = 4
12export const MAX_PARALLEL_READS = 3
13
14export type Loaded = { result: QueryResult } | { tooLarge: number } | null
15
16// tool_use_id -> the file Claude Code moved a large output to, from the
17// engine's own `persistedOutputPath` (never from text a command printed).
18// Module memory, set synchronously: no state write sits on the query path.
19const saved = new Map<string, { path: string; size: number }>()
20// Parsed saved results by path, so redraws never re-read megabytes.
21const parsed = new Map<string, QueryResult | null>()
22
23export function rememberSaved(id: string, result: unknown): void {
24 const r = result as { persistedOutputPath?: unknown; persistedOutputSize?: unknown } | null
25 if (typeof r?.persistedOutputPath !== 'string') return
26 saved.set(id, { path: r.persistedOutputPath, size: typeof r.persistedOutputSize === 'number' ? r.persistedOutputSize : 0 })
27 if (saved.size > MAX_SAVED) saved.delete(saved.keys().next().value as string)
28}
29
30export function savedFor(id: string | undefined): { path: string; size: number } | undefined {
31 return id ? saved.get(id) : undefined
32}
33
34export function parsedFor(path: string): QueryResult | null | undefined {
35 return parsed.get(path)
36}
37
38export function rememberParsed(path: string, result: QueryResult | null): void {
39 parsed.set(path, result)
40 if (parsed.size > MAX_PARSED) parsed.delete(parsed.keys().next().value as string)
41}
42
43// Feature #1067: the results a Full results sidebar shows, by row key - kept
44// here, not in session state, so ten thousand rows never cross into it. A
45// module reload forgets them; the sidebar then asks to open it again.
46const opened = new Map<string, QueryResult>()
47
48export function rememberOpened(id: string, result: QueryResult): void {
49 opened.delete(id)
50 opened.set(id, result)
51 if (opened.size > MAX_PARSED) opened.delete(opened.keys().next().value as string)
52}
53
54export function openedFor(id: string | undefined): QueryResult | undefined {
55 return id ? opened.get(id) : undefined
56}
57
58// Every `graphit` read the view makes - each card's lineage and the KB
59// sidebar - shares one cap, so open cards never crowd the user's own queries.
60let reading = 0
61const waiting: Array<() => void> = []
62
63export async function withReadSlot<T>(task: () => Promise<T>): Promise<T> {
64 if (reading >= MAX_PARALLEL_READS) await new Promise<void>(resolve => waiting.push(resolve))
65 reading += 1
66 try {
67 return await task()
68 } finally {
69 reading -= 1
70 waiting.shift()?.()
71 }
72}
73hooks/query-view/panes.tsx 333 lines1// The query view's sidebars: the KB definition (Feature #1062), and the
2// lineage explorer and full SQL the card opens (Feature #1067). Panes are
3// native, so they scroll and every row is clickable. Pure: register.tsx reads
4// the state and supplies the actions (a mod's `$` never crosses an import).
5
6import type { Ui } from './card'
7import { KIND_LABEL, capitalize, cell, clean } from './result'
8import { TEMPLATE_COLOR, tokenSpans } from './spans'
9import { codeChunks, formatLines, prettySql, tokenize } from './sql'
10import type { Explore, KbDef, KbSelection, SqlPane } from './state'
11import { renderFlowSvg } from './flow'
12import { renderHeroSvg } from './svgcard'
13import { chainOf } from './upstream'
14import type { Layers } from './upstream'
15
16// The inspector's inset grouped lists.
17const INSET_FILL = '#FFFFFF'
18const INSET_BORDER = '#E5E5EA'
19
20export function drawKbPane(ui: Ui, sel: KbSelection | null, def: KbDef | undefined, copy: (text: string) => unknown): unknown {
21 const { Box, Text, Button } = ui
22 if (!sel) return <Text dimColor>Click a KB reference in a query result to see its definition.</Text>
23 const color = TEMPLATE_COLOR[sel.kind] ?? TEMPLATE_COLOR.Metric
24 const reference = `{{ ${cell(sel.kind)}('${cell(sel.name)}') }}`
25 const compiled = clean(sel.expansion)
26 const alias = sel.alias ? ` AS ${sel.alias}` : ''
27
28 const rows: Array<{ label: string; value: string; isCode?: boolean }> = []
29 rows.push({ label: 'Kind', value: KIND_LABEL[def?.type ?? ''] ?? sel.kind })
30 if (sel.kind === 'Dimension' && compiled === sel.name) rows.push({ label: 'Column', value: compiled, isCode: true })
31 for (const part of def?.parts ?? []) rows.push({ label: capitalize(part.label), value: part.value, isCode: true })
32 if (def?.group) rows.push({ label: 'Group', value: def.group })
33 if (def?.lifecycle) rows.push({ label: 'Lifecycle', value: def.lifecycle })
34 if (def?.domains?.length) rows.push({ label: 'Access', value: def.domains.join(', ') })
35
36 const heading = (text: string, key: string) => (
37 <Text key={key} dimColor bold>
38 {text}
39 </Text>
40 )
41
42 return (
43 <Box flexDirection="column" gap={1} paddingX={1}>
44 <Box flexDirection="column">
45 <Box gap={1}>
46 <Text backgroundColor={color} color="#ffffff" bold>
47 {` ${sel.kind.toUpperCase()} `}
48 </Text>
49 <Text bold>{cell(sel.name)}</Text>
50 </Box>
51 {def?.status === 'loading' ? <Text dimColor>Loading the definition…</Text> : null}
52 {def?.status === 'error' ? <Text color="red">{clean(def.error)}</Text> : null}
53 {def?.description ? <Text>{clean(def.description)}</Text> : null}
54 {def?.status === 'unsupported' ? (
55 <Text dimColor>{`No ${sel.kind.toLowerCase()} definition read in this CLI version.`}</Text>
56 ) : null}
57 </Box>
58
59 <Box flexDirection="column">
60 {heading('DEFINITION', 'h-def')}
61 {rows.map((row, i) => (
62 <Box key={`row${i}`} gap={2}>
63 <Box minWidth={10}>
64 <Text dimColor>{row.label}</Text>
65 </Box>
66 {row.isCode ? (
67 <Text color={color} bold>{cell(row.value)}</Text>
68 ) : (
69 <Text>{cell(row.value)}</Text>
70 )}
71 </Box>
72 ))}
73 </Box>
74
75 <Box flexDirection="column">
76 {heading('IN THIS QUERY', 'h-query')}
77 <Box gap={2}>
78 <Box minWidth={10}>
79 <Text dimColor>Written</Text>
80 </Box>
81 <Text>{tokenSpans(Text, tokenize(`${reference}${alias}`), 'w-')}</Text>
82 </Box>
83 {compiled ? (
84 formatLines(`${compiled}${alias}`).map((line, i) => (
85 <Box key={`c${i}`} gap={2}>
86 <Box minWidth={10}>
87 <Text dimColor>{i === 0 ? 'Compiled' : ''}</Text>
88 </Box>
89 <Text>{tokenSpans(Text, line, `c${i}-`)}</Text>
90 </Box>
91 ))
92 ) : (
93 <Text dimColor>Used outside the SELECT list; see Runtime SQL.</Text>
94 )}
95 </Box>
96
97 <Box gap={1}>
98 <Button key="kb-copy-ref" label="Copy reference" onPress={() => copy(reference)} />
99 {compiled ? (
100 <Button key="kb-copy-sql" label="Copy SQL" onPress={() => copy(compiled)} />
101 ) : null}
102 </Box>
103 </Box>
104 )
105}
106
107// The full SQL, native and scrollable: the card shows only its first lines.
108export function drawSqlPane(ui: Ui, pane: SqlPane | null, copy: (text: string) => unknown): unknown {
109 const { Box, Text, Button, Code } = ui
110 if (!pane) return <Text dimColor>Open the SQL tab on a query result, then Open full SQL.</Text>
111 const pretty = prettySql(pane.sql)
112 const lines = formatLines(pretty)
113 const width = String(lines.length).length
114 return (
115 <Box flexDirection="column" gap={1} paddingX={1}>
116 <Box justifyContent="space-between">
117 <Text bold>{`${pane.title} · ${lines.length} lines`}</Text>
118 <Button key="sqlp-copy" label="Copy" onPress={() => copy(pretty)} />
119 </Box>
120 {Code ? (
121 codeChunks(pretty).map((c, ci) => <Code key={`sqlp-c${ci}`} source={c.source} language="sql" startLine={c.startLine} />)
122 ) : (
123 <Box flexDirection="column">
124 {lines.map((line, i) => (
125 <Box key={`sp${i}`} gap={1}>
126 <Text dimColor>{String(i + 1).padStart(width)}</Text>
127 <Text>{tokenSpans(Text, line, `sp${i}-`)}</Text>
128 </Box>
129 ))}
130 </Box>
131 )}
132 </Box>
133 )
134}
135
136// The lineage explorer: with no node chosen it lists every layer; a node shows
137// its facts and the nodes it comes from and feeds, each one a click away.
138// What the explorer's controls do; each runs in register.tsx with `$`.
139export type ExploreActions = {
140 go: (id: string) => unknown
141 back: () => unknown
142 all: () => unknown
143 openSql: (title: string, sql: string) => unknown
144 copy: (text: string) => unknown
145}
146
147// A node's SQL shows this many lines in the explorer; Open full SQL has the rest.
148const SQL_PREVIEW_LINES = 12
149
150export function drawExplorer(ui: Ui, ex: Explore | null, act: ExploreActions): unknown {
151 const { Box, Text, Button, Code, Svg } = ui
152 if (!ex) return <Text dimColor>Open Lineage on a query result, then Explore lineage.</Text>
153 const nodes = ex.layers.flatMap(l => l.nodes.map(n => ({ ...n, layer: l.label })))
154 const byId = new Map(nodes.map(n => [n.id, n]))
155 const byTitle = new Map(nodes.map(n => [n.title, n]))
156 const sel = ex.selected ? byId.get(ex.selected) : undefined
157 const go = act.go
158 const titleCase = (t: string) => t.charAt(0) + t.slice(1).toLowerCase()
159
160 // An inset grouped list: rounded, white, on the pane's own background.
161 const group = (key: string, header: string, children: unknown) => (
162 <Box key={key} flexDirection="column">
163 <Text dimColor bold>{` ${header}`}</Text>
164 <Box flexDirection="column" borderStyle="round" borderColor={INSET_BORDER} backgroundColor={INSET_FILL} paddingX={1}>
165 {children}
166 </Box>
167 </Box>
168 )
169 // A navigation row: colored dot, title, muted subtitle, chevron.
170 const navRow = (n: { id: string; title: string; color: string; subtitle?: string }, key: string) => (
171 <Box key={key} justifyContent="space-between" alignItems="center">
172 <Box gap={1} alignItems="center" flexShrink={1}>
173 <Text color={n.color}>{'●'}</Text>
174 <Button key={`${key}-b`} label={n.title} plain onPress={() => go(n.id)} />
175 {n.subtitle ? (
176 <Text dimColor wrap="truncate-end">
177 {n.subtitle}
178 </Text>
179 ) : null}
180 </Box>
181 <Text dimColor>{'›'}</Text>
182 </Box>
183 )
184 const hero = (kind: string, title: string, subtitle: string | undefined, color: string) => {
185 // Never name a local `h`: the JSX factory is `h`.
186 const heroSvg = renderHeroSvg({ kind, title, subtitle, color })
187 return Svg ? (
188 <Svg key="lx-hero" source={heroSvg.svg} alt={`${titleCase(kind)}: ${title}`} width={heroSvg.width} height={heroSvg.height} />
189 ) : (
190 <Text bold>{`${titleCase(kind)}: ${title}`}</Text>
191 )
192 }
193 const navBar = (
194 <Box justifyContent="space-between" alignItems="center">
195 <Box gap={1} alignItems="center">
196 <Button
197 key="lx-back"
198 label="‹ Back"
199 plain
200 dimColor={ex.trail.length === 0}
201 onPress={() => act.back()}
202 />
203 <Text dimColor>{sel ? `Lineage › ${titleCase(sel.layer)}` : 'Lineage'}</Text>
204 </Box>
205 {sel ? <Button key="lx-all" label="All layers" plain onPress={() => act.all()} /> : null}
206 </Box>
207 )
208
209 // The chain drawn top to bottom: all of it on the overview, one node's own
210 // upstream and downstream on its page. Each node is a native box with its
211 // name as a Button, laid out in flow; only the links between layers are
212 // drawn (flow.ts). Pressing a node opens it here.
213 const flow = (key: string, spec: Layers, focus?: string) => {
214 if (!Svg) return null
215 const drawn = renderFlowSvg(spec, { focus })
216 const alt = focus ? `Lineage of ${sel?.title ?? ''}` : `${ex.title}, top to bottom`
217 return (
218 <Box key={key} flexDirection="column" alignSelf="flex-start">
219 {drawn.strips.map(strip =>
220 strip.kind === 'link' ? (
221 <Svg key={`${key}-${strip.key}`} source={strip.svg} alt={alt} width={drawn.width} height={strip.height} />
222 ) : (
223 // A node is native, so its name takes the first press and no
224 // drawing has to line up with it.
225 <Box key={`${key}-${strip.key}`} flexDirection="row" marginLeft={strip.lead} marginTop={strip.first ? 0 : 1}>
226 {strip.nodes.map(n => (
227 <Box
228 key={`${key}-box-${n.id}`}
229 width={n.cols}
230 marginLeft={n.gap}
231 flexDirection="column"
232 borderStyle="round"
233 borderColor={n.color}
234 backgroundColor={n.focus ? '#F2F8F7' : '#FFFFFF'}
235 paddingX={1}
236 >
237 <Button key={`${key}-n-${n.id}`} label={n.label} plain dimColor={n.muted || undefined} onPress={() => (n.focus ? undefined : go(n.id))} />
238 {n.subtitle ? (
239 <Text dimColor wrap="truncate-end">
240 {n.subtitle}
241 </Text>
242 ) : null}
243 </Box>
244 ))}
245 </Box>
246 ),
247 )}
248 </Box>
249 )
250 }
251 const spec: Layers = { layers: ex.layers, edges: ex.edges }
252
253 if (!sel) {
254 return (
255 <Box flexDirection="column" gap={1} paddingX={1}>
256 {navBar}
257 {hero('LINEAGE', ex.title, `${ex.layers.length} layers · ${nodes.length} nodes`, '#4DB6AC')}
258 {/* The drawing is the navigation; without one (the terminal), the layers as rows. */}
259 {Svg ? flow('lx-flow', spec) : ex.layers.map((layer, li) => group(`ly${li}`, layer.label, layer.nodes.map((n, ni) => navRow(n, `ln${li}-${ni}`))))}
260 </Box>
261 )
262 }
263
264 const uniq = <T extends { id: string }>(list: T[]) => list.filter((n, i) => list.findIndex(m => m.id === n.id) === i)
265 const from = uniq(ex.edges.filter(x => x.to === sel.id).map(x => byId.get(x.from)).filter((n): n is NonNullable<typeof n> => !!n))
266 const into = uniq(ex.edges.filter(x => x.from === sel.id).map(x => byId.get(x.to)).filter((n): n is NonNullable<typeof n> => !!n))
267 const facts = sel.facts ?? []
268 const plainFacts = facts.filter(f => !f.code)
269 const codeFacts = facts.filter(f => f.code)
270 // A value naming other nodes ("ua_installs, cpi_num") becomes chips that open them.
271 const factValue = (value: string, key: string) => {
272 const parts = value.split(/,\s*/)
273 const linked = parts.length > 0 && parts.every(p => byTitle.has(p))
274 if (!linked) return <Text key={key}>{value}</Text>
275 return (
276 <Box key={key} gap={1} flexWrap="wrap" justifyContent="flex-end">
277 {parts.map((p, pi) => (
278 <Button key={`${key}-${pi}`} label={p} variant="secondary" onPress={() => go(byTitle.get(p)!.id)} />
279 ))}
280 </Box>
281 )
282 }
283 return (
284 <Box flexDirection="column" gap={1} paddingX={1}>
285 {navBar}
286 {hero(sel.layer, sel.title, sel.subtitle, sel.color)}
287 {plainFacts.length > 0
288 ? group(
289 'lx-facts',
290 'DETAILS',
291 plainFacts.map((f, i) => (
292 <Box key={`lf${i}`} justifyContent="space-between" gap={2}>
293 <Text dimColor>{f.label}</Text>
294 {factValue(f.value, `lfv${i}`)}
295 </Box>
296 )),
297 )
298 : null}
299 {flow('lx-chain', chainOf(spec, sel.id), sel.id)}
300 {codeFacts.map((f, i) => {
301 // A long query (a source's SQL runs to hundreds of lines) shows its
302 // head here; the full SQL sidebar is wider and holds every line.
303 const pretty = prettySql(f.value)
304 const lines = pretty.split('\n')
305 const label = f.label || 'SQL'
306 const cut = lines.length > SQL_PREVIEW_LINES
307 const shown = cut ? lines.slice(0, SQL_PREVIEW_LINES).join('\n') : pretty
308 return (
309 <Box key={`lc${i}`} flexDirection="column">
310 <Box justifyContent="space-between" alignItems="center">
311 <Text dimColor bold>{` ${label.toUpperCase()}${lines.length > 1 ? ` · ${lines.length} lines` : ''}`}</Text>
312 <Box gap={1} alignItems="center">
313 <Button key={`lc${i}-copy`} label="Copy" plain onPress={() => act.copy(pretty)} />
314 {cut ? <Button key={`lc${i}-open`} label="Open full SQL" variant="secondary" onPress={() => act.openSql(`${sel.title} · ${label}`, f.value)} /> : null}
315 </Box>
316 </Box>
317 {Code ? (
318 codeChunks(shown).map((c, ci) => (
319 <Code key={`lc${i}-${ci}`} source={c.source} language="sql" startLine={lines.length > 1 ? c.startLine : undefined} />
320 ))
321 ) : (
322 <Text>{shown}</Text>
323 )}
324 {cut ? <Text dimColor>{` ${lines.length - SQL_PREVIEW_LINES} more lines - Open full SQL`}</Text> : null}
325 </Box>
326 )
327 })}
328 {!Svg && from.length > 0 ? group('lx-from', 'COMES FROM', from.map((n, i) => navRow(n, `lfrom${i}`))) : null}
329 {!Svg && into.length > 0 ? group('lx-into', 'FEEDS', into.map((n, i) => navRow(n, `linto${i}`))) : null}
330 </Box>
331 )
332}
333hooks/query-view/results-pane.tsx 154 lines1// Feature #1067: Full results - every row and every column of one query in a
2// sidebar that opens as wide as the table: search across the columns, sort by
3// a column header, totals for the numeric columns, a thousand rows at a time,
4// and copy as TSV or CSV. Pure: register.tsx keeps the view state and supplies
5// the actions.
6
7import { cell, fmtNumber } from './result'
8import type { QueryResult } from './result'
9import type { ResultsView } from './state'
10
11export const RESULTS_STEP = 1000
12const MAX_COL_CHARS = 28
13// The widest sidebar the view asks for; the person can drag it wider.
14const MAX_PANE_COLUMNS = 240
15const COL_GAP = 3
16
17type Row = Record<string, unknown>
18// eslint-disable-next-line @typescript-eslint/no-explicit-any
19type Ui = Record<string, any>
20
21export type ResultsActions = {
22 search: (query: string) => unknown
23 sortBy: (column: string) => unknown
24 more: () => unknown
25 copy: (text: string) => unknown
26}
27
28// The rows the view shows: matching the search (case-insensitive, any column
29// the policy did not mask), then sorted - numbers as numbers, empties last.
30export function viewRows(rows: Row[], cols: string[], hidden: Set<string>, view: Pick<ResultsView, 'query' | 'sort' | 'desc'>): Row[] {
31 const q = view.query.trim().toLowerCase()
32 const seen = cols.filter(c => !hidden.has(c))
33 const matched = q ? rows.filter(r => seen.some(c => r[c] != null && String(r[c]).toLowerCase().includes(q))) : rows
34 if (!view.sort || !cols.includes(view.sort)) return matched
35 const dir = view.desc ? -1 : 1
36 const key = view.sort
37 return [...matched].sort((a, b) => {
38 const x = a[key]
39 const y = b[key]
40 if (x == null || y == null) return x == null ? (y == null ? 0 : 1) : -1
41 if (typeof x === 'number' && typeof y === 'number') return (x - y) * dir
42 return String(x).localeCompare(String(y), 'en', { numeric: true }) * dir
43 })
44}
45
46// The rows as text a spreadsheet pastes: masked columns stay empty.
47export function delimited(cols: string[], rows: Row[], hidden: Set<string>, sep: '\t' | ','): string {
48 const field = (v: unknown): string => {
49 const s = v == null ? '' : String(v)
50 if (sep === '\t') return s.replace(/[\t\n\r]/g, ' ')
51 return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
52 }
53 return [cols.map(field).join(sep), ...rows.map(r => cols.map(c => (hidden.has(c) ? '' : field(r[c]))).join(sep))].join('\n')
54}
55
56const shownText = (v: unknown): string => (typeof v === 'number' ? fmtNumber(v) : cell(v))
57
58// Sum and average of each numeric column over every matching row.
59function totals(cols: string[], rows: Row[], hidden: Set<string>): Map<string, { sum: number; avg: number }> {
60 const out = new Map<string, { sum: number; avg: number }>()
61 for (const c of cols) {
62 if (hidden.has(c)) continue
63 let sum = 0
64 let n = 0
65 for (const r of rows) {
66 const v = r[c]
67 if (typeof v === 'number') {
68 sum += v
69 n += 1
70 } else if (v != null) {
71 n = -1
72 break
73 }
74 }
75 if (n > 0) out.set(c, { sum, avg: sum / n })
76 }
77 return out
78}
79
80const columnsOf = (result: QueryResult): string[] => (result.columns?.length ? result.columns : Object.keys(result.rows[0] ?? {})).map(String)
81
82// The sidebar width that shows every column side by side, sized on the first
83// rows: the row number, each column at its widest (capped), the gaps, padding.
84export function resultsWidth(result: QueryResult): number {
85 const sample = result.rows.slice(0, 200)
86 const widths = columnsOf(result).map(c => Math.min(MAX_COL_CHARS, Math.max(c.length + 4, ...sample.map(r => shownText(r[c]).length))))
87 const total = widths.reduce((sum, w) => sum + w + COL_GAP, String(result.rows.length).length + COL_GAP + 4)
88 return Math.min(MAX_PANE_COLUMNS, Math.max(60, total))
89}
90
91export function drawResultsPane(ui: Ui, result: QueryResult | undefined, view: ResultsView | null, act: ResultsActions): unknown {
92 const { Box, Text, Button, Input } = ui
93 if (!result || !view) return <Text dimColor>Open Full results on a query result.</Text>
94 const cols = columnsOf(result)
95 const hidden = new Set((result.hidden_columns ?? []).map(String))
96 const rows = viewRows(result.rows, cols, hidden, view)
97 const shown = rows.slice(0, view.limit)
98 const sums = totals(cols, rows, hidden)
99 const n = (x: number) => x.toLocaleString('en-US')
100
101 const parts = [view.query ? `${n(rows.length)} of ${n(result.rows.length)} rows match` : `${n(result.rows.length)} rows`]
102 if (view.sort) parts.push(`sorted by ${view.sort} ${view.desc ? 'high to low' : 'low to high'}`)
103 if (rows.length > shown.length) parts.push(`showing the first ${n(shown.length)}`)
104 const numWidth = Math.max(3, String(shown.length).length)
105
106 return (
107 <Box flexDirection="column" gap={1} paddingX={1}>
108 {Input ? (
109 <Input key="rs-search" label="Search" placeholder="Filter rows by any value" value={view.query} autoFocus onInput={(v: string) => act.search(v)} onSubmit={(v: string) => act.search(v)} />
110 ) : null}
111 <Box justifyContent="space-between" alignItems="center">
112 <Text key="rs-status" dimColor>{parts.join(' · ')}</Text>
113 <Box gap={1}>
114 <Button key="rs-copy-tsv" label="Copy for Sheets" variant="secondary" onPress={() => act.copy(delimited(cols, rows, hidden, '\t'))} />
115 <Button key="rs-copy-csv" label="Copy CSV" variant="secondary" onPress={() => act.copy(delimited(cols, rows, hidden, ','))} />
116 </Box>
117 </Box>
118 {rows.length === 0 ? (
119 <Text dimColor>{view.query ? `No row matches "${view.query}".` : 'Query returned 0 rows.'}</Text>
120 ) : (
121 <Box gap={COL_GAP}>
122 <Box key="rs-col-#" flexDirection="column">
123 <Text dimColor>{'#'.padStart(numWidth)}</Text>
124 {sums.size > 0 ? <Text dimColor>{'Sum'.padStart(numWidth)}</Text> : null}
125 {sums.size > 0 ? <Text dimColor>{'Avg'.padStart(numWidth)}</Text> : null}
126 {shown.map((_, i) => (
127 <Text key={`rs-n${i}`} dimColor>{String(i + 1).padStart(numWidth)}</Text>
128 ))}
129 </Box>
130 {cols.map(c => {
131 const masked = hidden.has(c)
132 const total = sums.get(c)
133 const arrow = view.sort === c ? (view.desc ? ' ↓' : ' ↑') : ''
134 return (
135 <Box key={`rs-col-${c}`} flexDirection="column">
136 <Button key={`rs-sort-${c}`} label={`${cell(c)}${masked ? ' ⊘' : ''}${arrow}`} plain bold={view.sort === c ? true : undefined} onPress={() => act.sortBy(c)} />
137 {sums.size > 0 ? <Text key={`rs-sum-${c}`} bold>{total ? fmtNumber(total.sum) : ' '}</Text> : null}
138 {sums.size > 0 ? <Text key={`rs-avg-${c}`} dimColor>{total ? fmtNumber(total.avg) : ' '}</Text> : null}
139 {shown.map((r, i) => (
140 <Text key={`rs-${c}-${i}`} dimColor={masked}>{masked ? '•••' : shownText(r[c]) || ' '}</Text>
141 ))}
142 </Box>
143 )
144 })}
145 </Box>
146 )}
147 {rows.length > shown.length ? (
148 <Button key="rs-more" label={`Show ${n(Math.min(RESULTS_STEP, rows.length - shown.length))} more`} variant="secondary" onPress={() => act.more()} />
149 ) : null}
150 {hidden.size > 0 ? <Text color="gray">⊘ hidden by policy: {[...hidden].map(cell).join(', ')}</Text> : null}
151 </Box>
152 )
153}
154hooks/query-view/result.ts 332 lines1// Pure parts of the query view: the payload's shape, its parsing and the
2// per-line marks. No `$` here - every engine call stays in register.tsx.
3
4import type { KbDef } from './state'
5import { lineText } from './sql'
6import type { Expansion, Token } from './sql'
7
8export const MAX_ROWS = 10
9const MAX_COL = 24
10
11type Injection = {
12 outcome?: string
13 source?: string
14 rule_name?: string
15 source_id?: string
16 why?: string
17 added_clauses?: string[]
18 transformations?: Array<{ column?: string }>
19 reason?: string
20}
21
22// Feature #1062: the CLI's allowlist - see serialize_kb_references (backend).
23export type KbReference = { kind?: string; name?: string; deprecated?: boolean }
24
25// The gateway's reference kinds, as template kinds.
26export const REF_KIND: Record<string, string> = { metric: 'Metric', metric_raw: 'Measure', dim: 'Dimension' }
27
28export type QueryResult = {
29 rows: Array<Record<string, unknown>>
30 columns?: string[]
31 row_count?: number
32 truncated?: boolean
33 query_ms?: number
34 source?: string
35 sql?: string
36 governed_sql?: string
37 hidden_columns?: string[]
38 provenance?: {
39 tier?: string
40 kb_refs?: number
41 kb_references?: KbReference[]
42 rules_enforced?: number
43 injection_summary?: {
44 injections?: Injection[]
45 override_denied?: Array<{ rule_name?: string; reason?: string }>
46 }
47 }
48}
49
50export const OUTCOME_BADGE: Record<string, { label: string; color: string }> = {
51 rewritten: { label: 'REWROTE', color: '#2e9e5b' },
52 conditional_applied: { label: 'APPLIED', color: '#2e9e5b' },
53 conditional_default_fire: { label: 'APPLIED BY DEFAULT', color: '#2e9e5b' },
54 validated: { label: 'VALIDATED', color: '#3b82f6' },
55 blocked: { label: 'BLOCKED', color: '#dc2626' },
56 overridden: { label: 'OVERRIDDEN', color: '#6b7280' },
57 conditional_skipped: { label: 'SKIPPED', color: '#6b7280' },
58}
59
60// Feature #1067: tier colors - Energy Teal for governed, a calm blue for
61// verified, system gray for ad hoc - and their labels.
62export const TIER_COLOR: Record<string, string> = {
63 governed: '#4DB6AC',
64 verified: '#5B8DEF',
65 ad_hoc: '#8E8E93',
66}
67
68export const TIER_LABEL: Record<string, string> = {
69 governed: 'Governed',
70 verified: 'Verified',
71 ad_hoc: 'Ad hoc',
72}
73
74// Query data is untrusted: strip ANSI, C0/C1 controls and bidi overrides.
75export function clean(value: unknown): string {
76 const s = value === null || value === undefined ? '' : String(value)
77 return s
78 .replace(/\u001b\[[0-9;:?]*[ -/]*[@-~]/g, '')
79 .replace(/\u001b[@-_]/g, '')
80 .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f--]/g, '')
81}
82
83export function cell(value: unknown): string {
84 const s = clean(value).replace(/\n/g, ' ')
85 return s.length > MAX_COL ? `${s.slice(0, MAX_COL - 1)}…` : s
86}
87
88export function fmtNumber(n: number): string {
89 return n.toLocaleString('en-US', { maximumFractionDigits: Math.abs(n) >= 100 ? 2 : 4 })
90}
91
92function firstJsonObject(text: string): string | null {
93 const start = text.indexOf('{')
94 if (start < 0) return null
95 let depth = 0
96 let inStr = false
97 let esc = false
98 for (let i = start; i < text.length; i += 1) {
99 const ch = text[i]
100 if (inStr) {
101 if (esc) esc = false
102 else if (ch === '\\') esc = true
103 else if (ch === '"') inStr = false
104 continue
105 }
106 if (ch === '"') inStr = true
107 else if (ch === '{') depth += 1
108 else if (ch === '}') {
109 depth -= 1
110 if (depth === 0) return text.slice(start, i + 1)
111 }
112 }
113 return null
114}
115
116export function parseQueryResult(output: unknown): QueryResult | null {
117 // The terminal passes Bash's record ({ stdout, stderr }); the desktop's
118 // ToolUse card passes the output as one string.
119 const stdout =
120 typeof output === 'string'
121 ? output
122 : output && typeof output === 'object'
123 ? (output as { stdout?: unknown }).stdout
124 : undefined
125 if (typeof stdout !== 'string') return null
126 const json = firstJsonObject(stdout)
127 if (!json) return null
128 try {
129 const parsed = JSON.parse(json) as Partial<QueryResult>
130 const isQuery = Array.isArray(parsed.rows) && ('columns' in parsed || 'provenance' in parsed)
131 return isQuery ? (parsed as QueryResult) : null
132 } catch {
133 return null
134 }
135}
136
137export const MARK = {
138 added: { marker: '+', color: '#2e9e5b' },
139 masked: { marker: '~', color: '#c2409a' },
140 kb: { marker: '◆', color: '#2a9d8f' },
141}
142
143type LineMark = { marker: string; color: string; note: string }
144
145function escapeRe(s: string): string {
146 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
147}
148
149// An added clause arrives parameterized (MEDIA_SOURCE <> '?'): each ? stands
150// for the literal the runtime SQL bound in its place.
151function clauseMatcher(clause: string): RegExp {
152 const pattern = escapeRe(clause.trim())
153 .replace(/'\\\?'/g, "'(?:[^']|'')*'")
154 .replace(/\\\?/g, "(?:'(?:[^']|'')*'|[\\w.-]+)")
155 .replace(/\s+/g, '\\s+')
156 return new RegExp(pattern, 'i')
157}
158
159// What each SQL line owes to the KB and to governance, for its gutter and note.
160export function marksFor(
161 line: Token[],
162 mode: 'base' | 'runtime',
163 injections: Injection[],
164 expansions: Expansion[],
165): LineMark[] {
166 const text = lineText(line)
167 const marks: LineMark[] = []
168 if (mode === 'base') {
169 // The reference itself opens the sidebar; the gutter flags the line.
170 if (line.some(t => t.kind === 'template')) marks.push({ ...MARK.kb, note: '' })
171 return marks
172 }
173 for (const inj of injections) {
174 const rule = cell(inj.rule_name ?? inj.source_id ?? 'rule')
175 if ((inj.added_clauses ?? []).some(cl => cl && clauseMatcher(cl).test(text))) {
176 marks.push({ ...MARK.added, note: rule })
177 }
178 for (const t of inj.transformations ?? []) {
179 if (t.column && new RegExp(`NULL\\s+AS\\s+"?${escapeRe(t.column)}\\b`, 'i').test(text)) {
180 marks.push({ ...MARK.masked, note: `${rule} hid ${cell(t.column)}` })
181 }
182 }
183 }
184 for (const x of runtimeExpansions(line, expansions)) {
185 marks.push({ ...MARK.kb, note: `${x.kind} ${cell(x.name)}` })
186 }
187 return marks
188}
189
190// The KB expansions a runtime SQL line holds: the select item whose alias a
191// base-query template compiled into.
192function runtimeExpansions(line: Token[], expansions: Expansion[]): Expansion[] {
193 const text = lineText(line)
194 return expansions.filter(x => new RegExp(`\\bAS\\s+"?${escapeRe(x.alias)}"?\\s*,?\\s*$`, 'i').test(text))
195}
196
197// A `graphit kb get metric` answer, cut to what the sidebar shows.
198export function parseMetricDef(stdout: string): KbDef {
199 try {
200 const entity = (JSON.parse(stdout) as { entity?: Record<string, unknown> }).entity ?? {}
201 const tp = (entity.type_params ?? {}) as Record<string, unknown>
202 const parts: Array<{ label: string; value: string }> = []
203 const named = (v: unknown) => (v && typeof v === 'object' ? (v as { name?: unknown }).name : v)
204 for (const key of ['measure', 'numerator', 'denominator', 'expr']) {
205 const v = named(tp[key])
206 if (typeof v === 'string' && v) parts.push({ label: key, value: v })
207 }
208 if (Array.isArray(tp.metrics)) {
209 parts.push({ label: 'metrics', value: tp.metrics.map(named).filter(Boolean).join(', ') })
210 }
211 if (typeof entity.filter === 'string' && entity.filter) parts.push({ label: 'filter', value: entity.filter })
212 const meta = ((entity.meta as { graphit?: Record<string, unknown> } | undefined)?.graphit ?? {}) as Record<string, unknown>
213 return {
214 status: 'ok',
215 type: typeof entity.type === 'string' ? entity.type : undefined,
216 description: typeof entity.description === 'string' ? entity.description : undefined,
217 group: typeof entity.group === 'string' ? entity.group : undefined,
218 lifecycle: typeof meta.lifecycle === 'string' ? meta.lifecycle : undefined,
219 domains: Array.isArray(entity.domain_keys) ? entity.domain_keys.map(String) : undefined,
220 parts,
221 }
222 } catch {
223 const err = (() => {
224 try {
225 return String((JSON.parse(stdout) as { error?: unknown }).error ?? '')
226 } catch {
227 return ''
228 }
229 })()
230 return { status: 'error', error: err || 'Could not read the definition.' }
231 }
232}
233
234// The query's own SQL from `graphit query "<sql>"` in the Bash command, for a
235// CLI too old to echo `sql` in its JSON.
236export function sqlFromCommand(command: string): string | undefined {
237 const dq = /\bquery\s+"((?:[^"\\]|\\.)*)"/.exec(command)
238 if (dq) return dq[1].replace(/\\(["\\$`])/g, '$1')
239 const sq = /\bquery\s+'([^']*)'/.exec(command)
240 return sq ? sq[1] : undefined
241}
242
243export function commandOf(input: unknown): string | null {
244 const command = (input as { command?: unknown } | null)?.command
245 return typeof command === 'string' ? cell(command) : null
246}
247
248export const KIND_LABEL: Record<string, string> = {
249 dim: 'Dimension',
250 metric: 'Metric',
251 metric_raw: 'Measure',
252 simple: 'Simple metric',
253 ratio: 'Ratio metric',
254 derived: 'Derived metric',
255 cumulative: 'Cumulative metric',
256 conversion: 'Conversion metric',
257}
258
259export function capitalize(s: string): string {
260 return s ? s[0].toUpperCase() + s.slice(1) : s
261}
262
263// Feature #1062: Issue #1025's rule, the same one scripts/show-query-result.mjs
264// applies - a graphit query whose stdout reaches this Bash call's output. A
265// piped or redirected query, or another command's JSON, is left to the engine.
266// The two copies must agree; gate.test.tsx and show-query-result.test.mjs pin
267// the same cases.
268// Project #305: a verbatim copy of graphitInvocationSource("query") in
269// scripts/plugin-status/host.mjs (npx `@graphit/cli[@<v>] query` included);
270// test/cursor-hooks.test.mjs fails if the two literals differ.
271const QUERY_INVOCATION = /(?:graphit|index\.js|@graphit\/cli(?:@\S+)?)\s+query\b/
272
273// Feature #1062 (Gate 2): blank out quoted strings and `#` comments, keeping
274// every position, so an operator inside the SQL (`x <> 'y'`, `n > 2`) is not read
275// as a redirect, and `graphit query` inside another command's quotes or a
276// comment is not read as a query.
277function maskQuotesAndComments(command: string): string {
278 let out = ''
279 let quote: string | null = null
280 for (let i = 0; i < command.length; i += 1) {
281 const ch = command[i]
282 if (quote) {
283 if (ch === '\\' && quote === '"' && i + 1 < command.length) {
284 out += ' '
285 i += 1
286 } else if (ch === quote) {
287 quote = null
288 out += ch
289 } else {
290 out += ' '
291 }
292 continue
293 }
294 if (ch === "'" || ch === '"') {
295 quote = ch
296 out += ch
297 } else if (ch === '#' && (i === 0 || /\s/.test(command[i - 1]))) {
298 while (i < command.length && command[i] !== '\n') {
299 out += ' '
300 i += 1
301 }
302 if (i < command.length) out += '\n'
303 } else {
304 out += ch
305 }
306 }
307 return out
308}
309
310export function isGraphitQuery(command: string): boolean {
311 return QUERY_INVOCATION.test(maskQuotesAndComments(command))
312}
313
314export function queryReachesStdout(command: string): boolean {
315 const masked = maskQuotesAndComments(command)
316 for (const match of masked.matchAll(new RegExp(QUERY_INVOCATION.source, 'g'))) {
317 const tail = masked.slice(match.index).split(/\n|;|&&|\|\||(?<![|&>])&(?![&>])/)[0]
318 const withoutStderr = tail.replace(/2>&\d|2>>?\s*\S+/g, '')
319 if (!/(?<!\|)\|(?!\|)|>/.test(withoutStderr)) return true
320 // Feature #1067: a single pipe into head/tail (optional -n N / -N) still
321 // shows the document when it fits; a cut-short one fails to parse and
322 // stays raw. Anything after it, or any other pipe, does not count.
323 const pipeAt = withoutStderr.search(/(?<!\|)\|(?!\|)/)
324 if (pipeAt >= 0 && !/>/.test(withoutStderr.slice(0, pipeAt)) && HEAD_OR_TAIL.test(withoutStderr.slice(pipeAt + 1))) {
325 return true
326 }
327 }
328 return false
329}
330
331const HEAD_OR_TAIL = /^\s*(?:head|tail)(?:\s+(?:-n\s*\d+|-\d+))?\s*$/
332hooks/query-view/sql.ts 266 lines1// SQL tokenizer and formatter for the query view: pure functions, no `$`.
2
3export type TokenKind =
4 | 'kw'
5 | 'fn'
6 | 'str'
7 | 'num'
8 | 'param'
9 | 'comment'
10 | 'template'
11 | 'op'
12 | 'ident'
13 | 'punct'
14 | 'ws'
15
16export type Token = {
17 kind: TokenKind
18 text: string
19 // A KB template's kind and name: {{ Metric('revenue') }} -> Metric, revenue.
20 templateKind?: string
21 templateName?: string
22}
23
24const KEYWORDS = new Set(
25 (
26 'select from where and or not in is null as on join left right inner outer full cross group by order ' +
27 'having limit offset union all distinct case when then else end with over partition between like ilike ' +
28 'asc desc true false exists qualify interval cast using lateral window rows range preceding following ' +
29 'current row unbounded filter within except intersect values insert update delete set into'
30 ).split(' '),
31)
32
33const CLAUSE_STARTS = new Set(['select', 'from', 'where', 'having', 'limit', 'qualify', 'union', 'with', 'window', 'offset'])
34const TWO_WORD_CLAUSES = new Set(['group', 'order'])
35const JOIN_PREFIXES = new Set(['left', 'right', 'inner', 'full', 'cross', 'outer'])
36
37const RULES: Array<[TokenKind, RegExp]> = [
38 ['template', /^\{\{\s*([A-Za-z_]+)\(\s*['"]([^'"]+)['"]\s*\)\s*\}\}/],
39 ['comment', /^--[^\n]*/],
40 ['str', /^'(?:[^']|'')*'/],
41 ['ident', /^"[^"]*"/],
42 ['num', /^\d+(?:\.\d+)?\b/],
43 ['param', /^:[A-Za-z_]\w*/],
44 ['ident', /^[A-Za-z_][\w$]*/],
45 ['ws', /^\s+/],
46 ['op', /^(?:<>|!=|>=|<=|\|\||::|[=<>+\-*/%])/],
47]
48
49export function tokenize(sql: string): Token[] {
50 const tokens: Token[] = []
51 let rest = sql
52 while (rest.length > 0) {
53 let matched = false
54 for (const [kind, re] of RULES) {
55 const m = re.exec(rest)
56 if (!m) continue
57 const text = m[0]
58 if (kind === 'template') {
59 tokens.push({ kind, text, templateKind: m[1], templateName: m[2] })
60 } else if (kind === 'ident' && /^[A-Za-z_]/.test(text)) {
61 const after = rest.slice(text.length).trimStart()
62 const lower = text.toLowerCase()
63 tokens.push({ kind: KEYWORDS.has(lower) ? 'kw' : after.startsWith('(') ? 'fn' : 'ident', text })
64 } else {
65 tokens.push({ kind, text })
66 }
67 rest = rest.slice(text.length)
68 matched = true
69 break
70 }
71 if (!matched) {
72 tokens.push({ kind: 'punct', text: rest[0] })
73 rest = rest.slice(1)
74 }
75 }
76 return tokens
77}
78
79function trimEnd(line: Token[]): Token[] {
80 while (line.length > 0 && line[line.length - 1].kind === 'ws') line.pop()
81 return line
82}
83
84// A multi-line query keeps the author's layout; a one-line query gets one
85// clause per line, one select item per line, and AND/OR on their own lines.
86export function formatLines(sql: string): Token[][] {
87 const tokens = tokenize(sql.trim())
88 if (sql.trim().includes('\n')) {
89 const lines: Token[][] = [[]]
90 for (const t of tokens) {
91 if (t.kind === 'ws' && t.text.includes('\n')) {
92 const parts = t.text.split('\n')
93 for (let i = 1; i < parts.length; i += 1) lines.push([])
94 const indent = parts[parts.length - 1]
95 if (indent) lines[lines.length - 1].push({ kind: 'ws', text: indent })
96 } else {
97 lines[lines.length - 1].push(t)
98 }
99 }
100 return lines.map(trimEnd)
101 }
102
103 // Feature #1067: a subquery or CTE body - a "(" that opens a
104 // SELECT or WITH - is formatted like the top level, indented under its
105 // parenthesis; any other parenthesis (a call, an IN list) stays inline.
106 const lines: Token[][] = []
107 let line: Token[] = []
108 type Frame = { sub: boolean; indent: number; clause: string }
109 const frames: Frame[] = [{ sub: true, indent: 0, clause: '' }]
110 const top = () => frames[frames.length - 1]
111 let prevWord = ''
112 const newline = (indent: number) => {
113 if (trimEnd(line).length > 0) lines.push(line)
114 line = indent > 0 ? [{ kind: 'ws', text: ' '.repeat(indent) }] : []
115 }
116 const atLineStart = () => line.every(t => t.kind === 'ws')
117 const lineIndent = () => (line[0]?.kind === 'ws' ? line[0].text.length : 0)
118 const nextWord = (from: number) => {
119 for (let k = from; k < tokens.length; k += 1) if (tokens[k].kind !== 'ws') return tokens[k].text.toLowerCase()
120 return ''
121 }
122
123 tokens.forEach((t, i) => {
124 if (t.kind === 'ws') {
125 if (!atLineStart()) line.push({ kind: 'ws', text: ' ' })
126 return
127 }
128 if (t.text === '(') {
129 const opensQuery = ['select', 'with'].includes(nextWord(i + 1))
130 line.push(t)
131 if (opensQuery) {
132 const indent = lineIndent() + 4
133 frames.push({ sub: true, indent, clause: '' })
134 newline(indent)
135 } else {
136 frames.push({ sub: false, indent: top().indent, clause: top().clause })
137 }
138 return
139 }
140 if (t.text === ')') {
141 const frame = frames.length > 1 ? frames.pop()! : top()
142 if (frame.sub) newline(Math.max(frame.indent - 4, 0))
143 line.push(t)
144 return
145 }
146 const frame = top()
147 const word = t.kind === 'kw' || t.kind === 'ident' || t.kind === 'fn' ? t.text.toLowerCase() : ''
148 if (frame.sub && word) {
149 const startsJoin = word === 'join' && !JOIN_PREFIXES.has(prevWord)
150 if (CLAUSE_STARTS.has(word) || TWO_WORD_CLAUSES.has(word) || JOIN_PREFIXES.has(word) || startsJoin) {
151 if (!(JOIN_PREFIXES.has(prevWord) && (word === 'join' || word === 'outer')) && !(TWO_WORD_CLAUSES.has(prevWord) && word === 'by')) {
152 if (!atLineStart()) newline(frame.indent)
153 frame.clause = word
154 }
155 } else if ((word === 'and' || word === 'or') && (frame.clause === 'where' || frame.clause === 'having')) {
156 newline(frame.indent + 2)
157 }
158 }
159 line.push(t)
160 if (word) prevWord = word
161 if (frame.sub && word === 'select') newline(frame.indent + 2)
162 if (frame.sub && t.text === ',' && frame.clause === 'select') newline(frame.indent + 2)
163 })
164 if (trimEnd(line).length > 0) lines.push(line)
165 return lines
166}
167
168export function lineText(line: Token[]): string {
169 return line.map(t => t.text).join('')
170}
171
172type SelectItem = { alias: string; expr: string; templates: Token[] }
173
174// The top-level SELECT list: each item's alias (AS name, or a bare column)
175// and its expression text, with the KB templates it holds.
176function selectItems(sql: string): SelectItem[] {
177 const tokens = tokenize(sql)
178 const items: SelectItem[] = []
179 let depth = 0
180 let inSelect = false
181 let current: Token[] = []
182 const flush = () => {
183 const parts = current.filter(t => t.kind !== 'ws')
184 if (parts.length > 0) {
185 const asAt = parts.length >= 3 && parts[parts.length - 2].text.toLowerCase() === 'as' ? parts.length - 2 : -1
186 const aliasTok = asAt >= 0 ? parts[parts.length - 1] : parts.length === 1 ? parts[0] : undefined
187 const exprTokens = asAt >= 0 ? current.slice(0, current.lastIndexOf(parts[asAt])) : current
188 if (aliasTok) {
189 items.push({
190 alias: aliasTok.text.replace(/"/g, '').toLowerCase(),
191 expr: exprTokens.map(t => t.text).join('').trim(),
192 templates: parts.filter(t => t.kind === 'template'),
193 })
194 }
195 }
196 current = []
197 }
198 for (const t of tokens) {
199 if (t.text === '(') depth += 1
200 if (t.text === ')') depth -= 1
201 const word = t.kind === 'kw' ? t.text.toLowerCase() : ''
202 if (depth === 0 && word === 'select' && !inSelect && items.length === 0) {
203 inSelect = true
204 continue
205 }
206 if (!inSelect) continue
207 if (depth === 0 && word === 'from') {
208 flush()
209 break
210 }
211 if (depth === 0 && word === 'distinct' && current.every(c => c.kind === 'ws')) continue
212 if (depth === 0 && t.text === ',') flush()
213 else current.push(t)
214 }
215 return items
216}
217
218export type Expansion = { kind: string; name: string; alias: string; expr: string }
219
220// What each KB template of the base query compiled to, matched to the runtime
221// SQL by select alias: {{ Metric('cpi') }} AS cpi <-> (...) AS cpi.
222export function templateExpansions(baseSql: string, runtimeSql: string): Expansion[] {
223 const runtime = new Map(selectItems(runtimeSql).map(i => [i.alias, i.expr]))
224 const out: Expansion[] = []
225 for (const item of selectItems(baseSql)) {
226 const expr = runtime.get(item.alias)
227 if (!expr) continue
228 for (const t of item.templates) {
229 if (t.templateKind && t.templateName) {
230 out.push({ kind: t.templateKind, name: t.templateName, alias: item.alias, expr })
231 }
232 }
233 }
234 return out
235}
236
237// Feature #1067: SQL as the sidebars show it - re-flowed by the
238// formatter (one clause, one select item per line, CTEs indented) unless it
239// holds a line comment, whose end the layout must keep.
240export function prettySql(sql: string): string {
241 const text = sql.trim()
242 if (/--/.test(text.replace(/'(?:[^']|'')*'/g, "''"))) return text
243 return formatLines(text.replace(/\s+/g, ' ')).map(lineText).join('\n')
244}
245
246// Text for native Code blocks: whole lines, each block under `max` characters.
247export function codeChunks(text: string, max = 9500): Array<{ source: string; startLine: number }> {
248 const out: Array<{ source: string; startLine: number }> = []
249 let lines: string[] = []
250 let size = 0
251 let start = 1
252 text.split('\n').forEach((line, i) => {
253 const piece = line.length > max ? line.slice(0, max - 1) + '…' : line
254 if (size + piece.length + 1 > max && lines.length) {
255 out.push({ source: lines.join('\n'), startLine: start })
256 lines = []
257 size = 0
258 start = i + 1
259 }
260 lines.push(piece)
261 size += piece.length + 1
262 })
263 if (lines.length) out.push({ source: lines.join('\n'), startLine: start })
264 return out
265}
266hooks/query-view/upstream.ts 431 lines1// Feature #1067: the full lineage of a query result, from the
2// warehouse down to the result columns. Pure: parsing of what the graphit CLI
3// returns and the layer graph the card draws; the reads themselves run in
4// register.tsx.
5
6import type { Expansion } from './sql'
7import type { Upstream } from './state'
8
9// A fact the sidebar shows for a node; `code` values are drawn as SQL.
10export type Fact = { label: string; value: string; code?: boolean }
11export type LayerNode = { id: string; title: string; subtitle?: string; color: string; muted?: boolean; facts?: Fact[] }
12export type LayerEdge = { from: string; to: string; color: string; dashed?: boolean }
13export type Layers = { layers: Array<{ label: string; nodes: LayerNode[] }>; edges: LayerEdge[] }
14
15const KEYWORDS = new Set(
16 (
17 'SELECT FROM WHERE AND OR NOT CASE WHEN THEN ELSE END AS CAST DATE TIMESTAMP TIMESTAMP_NTZ INT INTEGER NUMBER ' +
18 'NUMERIC DECIMAL FLOAT DOUBLE VARCHAR STRING TEXT BOOLEAN TRUE FALSE NULL IS IN LIKE ILIKE BETWEEN DISTINCT ' +
19 'DAY WEEK MONTH QUARTER YEAR HOUR MINUTE SECOND INTERVAL ON JOIN LEFT RIGHT INNER OUTER FULL GROUP BY ORDER ' +
20 'HAVING LIMIT OVER PARTITION ROWS UNBOUNDED PRECEDING FOLLOWING CURRENT ROW WITH ASC DESC'
21 ).split(' '),
22)
23
24// Blank quoted text so an identifier search never reads inside a literal.
25const unquote = (sql: string) => sql.replace(/'(?:[^']|'')*'/g, "''")
26// Feature #1092: blank literals and drop comments in one left-to-right pass, so
27// neither fools the other - an apostrophe in `-- it's read from Firestore`
28// opened a fake literal that swallowed the next real FROM, and a `--` inside a
29// literal must stay text. A comment is prose, never a table.
30const uncomment = (sql: string) =>
31 sql.replace(/'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g, m => (m.startsWith("'") ? "''" : ' '))
32// One name part: bare, "double quoted" (Snowflake) or `backticked` (BigQuery).
33const NAME_PART = '(?:"[^"]+"|`[^`]+`|[A-Za-z_][\\w$]*)'
34const FROM_TARGET = new RegExp(`\\b(?:FROM|JOIN)\\s+(${NAME_PART}(?:\\s*\\.\\s*${NAME_PART})*)`, 'gi')
35
36// FROM targets that are not tables: inline rows and table functions.
37const NOT_TABLES = new Set(['VALUES', 'LATERAL', 'TABLE', 'UNNEST', 'FLATTEN', 'GENERATOR', 'SELECT'])
38// A FROM inside EXTRACT(YEAR FROM col) or TRIM(x FROM col) names a column.
39const IN_FUNCTION = /\b(?:EXTRACT|TRIM|SUBSTRING|POSITION|OVERLAY)\s*\([^()]*$/i
40
41// Column identifiers an expression reads: bare names, not functions, keywords
42// or numbers; a qualified `o.AMOUNT` counts as AMOUNT.
43export function columnsOf(expr: string): string[] {
44 const out: string[] = []
45 for (const m of unquote(expr).matchAll(/(?:\b[A-Za-z_]\w*\.)?\b([A-Za-z_]\w*)\b(?!\s*\()/g)) {
46 const name = m[1].toUpperCase()
47 if (!KEYWORDS.has(name) && !out.includes(name)) out.push(name)
48 }
49 return out
50}
51
52// The warehouse tables a data source's SQL reads: FROM / JOIN targets that are
53// not its own CTE names, inline VALUES, table functions or comment text.
54export function warehouseTables(sql: string): string[] {
55 const text = uncomment(sql)
56 const ctes = new Set([...text.matchAll(/\b([A-Za-z_]\w*)\s+AS\s*\(/gi)].map(m => m[1].toUpperCase()))
57 const out: string[] = []
58 for (const m of text.matchAll(FROM_TARGET)) {
59 const name = m[1].replace(/\s*\.\s*/g, '.').replace(/["`]/g, '')
60 const upper = name.toUpperCase()
61 if (ctes.has(upper) || NOT_TABLES.has(upper) || IN_FUNCTION.test(text.slice(Math.max(0, (m.index ?? 0) - 80), m.index))) continue
62 if (!out.includes(name)) out.push(name)
63 }
64 return out
65}
66
67// snowflake -> Snowflake; the warehouse names the tables layer.
68export function warehouseLabel(kind: string): string {
69 return kind === 'snowflake' ? 'Snowflake' : kind === 'bigquery' ? 'BigQuery' : kind === 'file_upload' ? 'File upload' : kind.charAt(0).toUpperCase() + kind.slice(1)
70}
71
72// 2026-10-04T05:00:10Z -> "4 Oct 08:00" in Israel time.
73export function israelTime(iso?: string): string | undefined {
74 if (!iso) return undefined
75 const d = new Date(iso.includes('T') || iso.endsWith('Z') ? iso : iso.replace(' ', 'T'))
76 if (Number.isNaN(d.getTime())) return undefined
77 try {
78 // Built from parts: runtimes disagree on the joiner ("4 Oct, 08:00", "4 Oct at 08:00").
79 const parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Jerusalem', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(d)
80 const part = (type: string) => parts.find(p => p.type === type)?.value ?? ''
81 return `${part('day')} ${part('month')} ${part('hour')}:${part('minute')}`
82 } catch {
83 return d.toISOString().slice(0, 16).replace('T', ' ')
84 }
85}
86
87// The first JSON value in a CLI's output, or null.
88export function jsonOf(text: string): unknown {
89 const at = text.search(/[[{]/)
90 if (at < 0) return null
91 try {
92 return JSON.parse(text.slice(at))
93 } catch {
94 return null
95 }
96}
97
98// The CLI's own failure text: the last `{"error": ...}` line on stderr, else
99// its last line, kept short for a one-line status.
100export function errorOf(stderr: string): string {
101 const lines = stderr.split('\n').map(l => l.trim()).filter(Boolean)
102 for (const line of [...lines].reverse()) {
103 const parsed = line.startsWith('{') ? (jsonOf(line) as { error?: unknown } | null) : null
104 if (typeof parsed?.error === 'string') return parsed.error.slice(0, 120)
105 }
106 return (lines[lines.length - 1] ?? '').slice(0, 120)
107}
108
109type DsRow = { id?: string; name?: string; connector_type?: string; source_sql?: string; row_count?: number; last_refreshed?: string; domain?: string }
110type Model = {
111 name?: string
112 group?: string
113 primary_entity?: string
114 dimensions?: Array<{ name?: string; expr?: string | null }>
115 measures?: Array<{ name?: string; agg?: string; expr?: string | null }>
116}
117type MetricEntity = {
118 name?: string
119 type?: string
120 group?: string
121 type_params?: {
122 measure?: { name?: string } | null
123 numerator?: { name?: string } | null
124 denominator?: { name?: string } | null
125 metrics?: Array<{ name?: string }> | null
126 }
127}
128
129// Assemble the upstream from the CLI's answers. Every input is optional: a
130// read that failed leaves its layer out rather than failing the lineage.
131export function assembleUpstream(input: {
132 sourceName?: string
133 dsList: unknown
134 history: unknown
135 models: unknown
136 metrics: unknown[]
137 refs: Array<{ kind: string; name: string }>
138}): Upstream {
139 const up: Upstream = { status: 'ok' }
140 const rows = ((input.dsList as { data_sources?: DsRow[] } | null)?.data_sources ?? []) as DsRow[]
141 const ds = input.sourceName ? rows.find(r => (r.name ?? '').toLowerCase() === input.sourceName!.toLowerCase()) : undefined
142 if (ds) {
143 const kind = ds.connector_type ?? 'warehouse'
144 const file = /Uploaded from:\s*([^,\n]+)/.exec(ds.source_sql ?? '')?.[1]?.trim()
145 up.warehouse = { kind, file }
146 if (kind !== 'file_upload' && ds.source_sql) up.tables = warehouseTables(ds.source_sql)
147 const run = Array.isArray(input.history) ? (input.history[0] as { started_at?: string; status?: string; type?: string; rows?: number } | undefined) : undefined
148 up.ds = {
149 name: ds.name ?? input.sourceName ?? '',
150 rows: run?.rows ?? ds.row_count,
151 refreshedAt: israelTime(run?.started_at ?? ds.last_refreshed),
152 refreshType: run?.type,
153 refreshStatus: run?.status,
154 domain: ds.domain,
155 sql: kind !== 'file_upload' ? ds.source_sql : undefined,
156 }
157 }
158
159 const metricEntities = input.metrics
160 .map(m => (m as { entity?: MetricEntity } | null)?.entity)
161 .filter((m): m is MetricEntity => !!m && !!m.name)
162 up.metrics = metricEntities.map(m => {
163 const p = m.type_params ?? {}
164 const inputs = [p.measure?.name, p.numerator?.name, p.denominator?.name, ...(p.metrics ?? []).map(x => x.name)].filter(
165 (x): x is string => !!x,
166 )
167 return { name: m.name!, type: m.type ?? 'metric', group: m.group, inputs }
168 })
169
170 // The model: the one holding the referenced dimensions and metric inputs.
171 const models = ((input.models as { items?: Model[] } | null)?.items ?? (Array.isArray(input.models) ? input.models : [])) as Model[]
172 const dimNames = input.refs.filter(r => r.kind === 'Dimension').map(r => r.name.split('__').pop() as string)
173 const inputNames = up.metrics.flatMap(m => m.inputs)
174 const score = (m: Model) =>
175 dimNames.filter(d => (m.dimensions ?? []).some(x => x.name === d)).length +
176 inputNames.filter(n => (m.measures ?? []).some(x => x.name === n)).length +
177 (input.sourceName && (m.name ?? '').toLowerCase() === input.sourceName.toLowerCase() ? 1 : 0)
178 const model = models.reduce<Model | undefined>((best, m) => (score(m) > 0 && (!best || score(m) > score(best)) ? m : best), undefined)
179 if (model) {
180 up.model = { name: model.name ?? '', group: model.group }
181 up.measures = (model.measures ?? [])
182 .filter(x => x.name && inputNames.includes(x.name))
183 .map(x => ({ name: x.name!, agg: (x.agg ?? '').toUpperCase(), expr: x.expr ?? x.name!, columns: columnsOf(x.expr ?? x.name!) }))
184 up.dims = (model.dimensions ?? [])
185 .filter(x => x.name && dimNames.includes(x.name))
186 .map(x => ({ name: x.name!, expr: x.expr ?? x.name!, columns: columnsOf(x.expr ?? x.name!) }))
187 }
188 return up
189}
190
191// Drop a set of nodes and join what fed them straight to what they fed, so
192// the chain stays connected.
193function bypass(spec: Layers, drop: Set<string>): Layers {
194 const into = spec.edges.filter(e => drop.has(e.to) && !drop.has(e.from))
195 const out = spec.edges.filter(e => drop.has(e.from) && !drop.has(e.to))
196 const kept = spec.edges.filter(e => !drop.has(e.from) && !drop.has(e.to))
197 const joined = into.flatMap(a => out.filter(b => b.from === a.to).map(b => ({ ...b, from: a.from })))
198 const seen = new Set<string>()
199 const edges = [...kept, ...joined].filter(e => {
200 const k = `${e.from}>${e.to}`
201 return seen.has(k) ? false : (seen.add(k), true)
202 })
203 const layers = spec.layers.map(l => ({ ...l, nodes: l.nodes.filter(n => !drop.has(n.id)) })).filter(l => l.nodes.length > 0)
204 return { layers, edges }
205}
206
207// The card's overview of the chain: tables, source, model, KB, rules, result.
208// Physical columns and measures are the model's detail - drawn on the card
209// they made eight columns too narrow to read a name in. The explorer keeps
210// every layer.
211const DETAIL_LAYERS = new Set(['COLUMNS', 'MEASURES'])
212export function overviewLayers(spec: Layers): Layers {
213 const drop = new Set(spec.layers.filter(l => DETAIL_LAYERS.has(l.label)).flatMap(l => l.nodes.map(n => n.id)))
214 return drop.size ? bypass(spec, drop) : spec
215}
216
217// One node's own chain: everything it comes from and everything it feeds.
218export function chainOf(spec: Layers, id: string): Layers {
219 const walk = (upward: boolean) => {
220 const seen = new Set([id])
221 const queue = [id]
222 while (queue.length) {
223 const at = queue.shift() as string
224 for (const e of spec.edges) {
225 const next = upward ? (e.to === at ? e.from : undefined) : e.from === at ? e.to : undefined
226 if (next && !seen.has(next)) {
227 seen.add(next)
228 queue.push(next)
229 }
230 }
231 }
232 return seen
233 }
234 const up = walk(true)
235 const down = walk(false)
236 const keep = new Set([...up, ...down])
237 return {
238 layers: spec.layers.map(l => ({ ...l, nodes: l.nodes.filter(n => keep.has(n.id)) })).filter(l => l.nodes.length > 0),
239 edges: spec.edges.filter(e => keep.has(e.from) && keep.has(e.to) && (up.has(e.to) || down.has(e.from))),
240 }
241}
242
243const C = {
244 ink: '#222224',
245 wh: '#2F6FDB',
246 ds: '#4DB6AC',
247 model: '#7A5AF8',
248 column: '#6E6E73',
249 measure: '#2A9D8F',
250 metric: '#2A9D8F',
251 dim: '#5B8DEF',
252 red: '#dc2626',
253 gray: '#AEAEB2',
254}
255
256// The layers top to bottom; a layer with nothing to show is left out.
257export function lineageLayers(input: {
258 up?: Upstream
259 sourceName?: string
260 rowCount: number
261 columns: string[]
262 hidden: Set<string>
263 refs: Array<{ kind: string; name: string }>
264 expansions: Expansion[]
265 rules: Array<{ name: string; clauses: string[]; masks: string[]; color: string; why?: string }>
266}): Layers {
267 const up = input.up?.status === 'ok' ? input.up : undefined
268 const layers: Layers['layers'] = []
269 const edges: LayerEdge[] = []
270 const add = (label: string, nodes: LayerNode[]) => nodes.length && layers.push({ label, nodes })
271 const link = (from: string, to: string, color: string, dashed?: boolean) => edges.push({ from, to, color, dashed })
272
273 // The warehouse tables (or the uploaded file) the source reads. The
274 // warehouse itself is the layer's name, not a node of its own: one box
275 // saying "Snowflake" told nothing the tables do not.
276 const warehouse = up?.warehouse ? warehouseLabel(up.warehouse.kind) : undefined
277 if (up?.warehouse?.kind === 'file_upload') {
278 add('FILE', [{ id: 'file', title: up.warehouse.file ?? 'Uploaded file', subtitle: 'file upload', color: C.wh, facts: [{ label: 'Kind', value: 'File upload' }, ...(up.warehouse.file ? [{ label: 'File', value: up.warehouse.file }] : [])] }])
279 } else if (warehouse) {
280 add(
281 `${warehouse.toUpperCase()} TABLES`,
282 (up?.tables ?? []).map(t => {
283 const parts = t.split('.')
284 return {
285 id: `tb:${t}`,
286 title: parts[parts.length - 1],
287 subtitle: parts.length > 1 ? parts.slice(0, -1).join('.') : 'table',
288 color: C.wh,
289 facts: [{ label: 'Table', value: t }, { label: 'Warehouse', value: warehouse }],
290 }
291 }),
292 )
293 }
294
295 // The cached data source.
296 const dsName = up?.ds?.name ?? input.sourceName
297 if (dsName) {
298 // Rows first: a clipped subtitle still says how big the source is.
299 const bits = [up?.ds?.rows != null ? `${up.ds.rows.toLocaleString('en-US')} rows` : undefined, up?.ds?.refreshedAt].filter(Boolean)
300 add('DATA SOURCE', [
301 {
302 id: 'ds',
303 title: dsName,
304 subtitle: bits.join(' · ') || 'cached source',
305 color: C.ds,
306 facts: [
307 ...(up?.ds?.rows != null ? [{ label: 'Rows', value: up.ds.rows.toLocaleString('en-US') }] : []),
308 ...(up?.ds?.refreshedAt ? [{ label: 'Last refresh', value: [up.ds.refreshType, up.ds.refreshedAt, up.ds.refreshStatus].filter(Boolean).join(' · ') }] : []),
309 ...(up?.ds?.domain ? [{ label: 'Access domain', value: up.ds.domain }] : []),
310 ...(up?.ds?.sql ? [{ label: 'Source SQL', value: up.ds.sql, code: true }] : []),
311 ],
312 },
313 ])
314 if (up?.warehouse?.kind === 'file_upload') link('file', 'ds', C.wh)
315 else for (const t of up?.tables ?? []) link(`tb:${t}`, 'ds', C.wh)
316 }
317
318 // Semantic model, its physical columns, its measures.
319 const ruleColumns = input.rules.flatMap(r => r.clauses.flatMap(columnsOf))
320 if (up?.model) {
321 add('SEMANTIC MODEL', [
322 {
323 id: 'model',
324 title: up.model.name,
325 subtitle: up.model.group ? `group ${up.model.group}` : 'model',
326 color: C.model,
327 facts: [
328 ...(up.model.group ? [{ label: 'Group', value: up.model.group }] : []),
329 { label: 'Measures used', value: (up.measures ?? []).map(m => m.name).join(', ') || 'none' },
330 { label: 'Dimensions used', value: (up.dims ?? []).map(d => d.name).join(', ') || 'none' },
331 ],
332 },
333 ])
334 if (dsName) link('ds', 'model', C.model)
335 const physical = [...new Set([...(up.measures ?? []).flatMap(m => m.columns), ...(up.dims ?? []).flatMap(d => d.columns), ...ruleColumns])]
336 add('COLUMNS', physical.map(c => ({ id: `col:${c}`, title: c, color: C.column, facts: [{ label: 'Physical column', value: c }] })))
337 for (const c of physical) link('model', `col:${c}`, C.gray)
338 add('MEASURES', (up.measures ?? []).map(m => ({ id: `ms:${m.name}`, title: m.name, subtitle: `${m.agg}(${m.expr})`, color: C.measure, facts: [{ label: 'Aggregation', value: m.agg }, { label: 'Expression', value: `${m.agg}(${m.expr})`, code: true }] })))
339 for (const m of up.measures ?? []) {
340 for (const c of m.columns) link(`col:${c}`, `ms:${m.name}`, C.measure)
341 // COUNT(1) reads no column: it comes from the model itself.
342 if (m.columns.length === 0) link('model', `ms:${m.name}`, C.measure)
343 }
344 }
345
346 // Metrics and dimensions the query references.
347 const refNodes: LayerNode[] = input.refs.map(r => {
348 const short = r.name.split('__').pop() as string
349 const metric = up?.metrics?.find(m => m.name === r.name)
350 const dim = up?.dims?.find(d => d.name === short)
351 const compiled = input.expansions.find(x => x.kind === r.kind && x.name === r.name)?.expr
352 return {
353 id: `ref:${r.kind}:${r.name}`,
354 title: short,
355 subtitle: r.kind === 'Metric' ? [metric?.type ?? 'metric', metric?.group].filter(Boolean).join(' · ') : 'dimension',
356 color: r.kind === 'Metric' ? C.metric : C.dim,
357 facts: [
358 { label: 'Reference', value: `{{ ${r.kind}('${r.name}') }}`, code: true },
359 ...(metric ? [{ label: 'Type', value: metric.type }, ...(metric.group ? [{ label: 'Group', value: metric.group }] : []), ...(metric.inputs.length ? [{ label: 'Inputs', value: metric.inputs.join(', ') }] : [])] : []),
360 ...(dim ? [{ label: 'Expression', value: dim.expr, code: true }] : []),
361 ...(compiled ? [{ label: 'Compiles to', value: compiled, code: true }] : []),
362 ],
363 }
364 })
365 add('KB ASSETS', refNodes)
366 for (const r of input.refs) {
367 const id = `ref:${r.kind}:${r.name}`
368 const short = r.name.split('__').pop() as string
369 if (r.kind === 'Metric') {
370 const inputs = up?.metrics?.find(m => m.name === r.name)?.inputs ?? []
371 const fromMeasures = inputs.filter(i => up?.measures?.some(m => m.name === i))
372 for (const i of fromMeasures) link(`ms:${i}`, id, C.metric)
373 if (fromMeasures.length === 0 && up?.model) link('model', id, C.metric)
374 if (!up?.model && dsName) link('ds', id, C.metric)
375 } else {
376 const dim = up?.dims?.find(d => d.name === short)
377 if (dim) for (const c of dim.columns) link(`col:${c}`, id, C.dim)
378 else if (up?.model) link('model', id, C.dim)
379 else if (dsName) link('ds', id, C.dim)
380 }
381 }
382
383 // Governance: a filter reads columns and narrows the rows; a mask hides one.
384 add(
385 'GOVERNANCE',
386 input.rules.map(r => ({
387 id: `rule:${r.name}`,
388 title: r.name,
389 subtitle: r.masks.length ? 'mask' : 'row filter',
390 color: r.color,
391 facts: [
392 ...(r.why ? [{ label: 'Why', value: r.why }] : []),
393 ...r.clauses.map((c, i) => ({ label: i === 0 ? 'Adds filter' : '', value: c, code: true })),
394 ...(r.masks.length ? [{ label: 'Masks', value: r.masks.join(', ') }] : []),
395 ],
396 })),
397 )
398 for (const r of input.rules) {
399 const cols = r.clauses.flatMap(columnsOf).filter(c => layers.some(l => l.nodes.some(n => n.id === `col:${c}`)))
400 if (cols.length) for (const c of cols) link(`col:${c}`, `rule:${r.name}`, r.color, true)
401 else if (dsName) link('ds', `rule:${r.name}`, r.color, true)
402 if (r.clauses.length) link(`rule:${r.name}`, 'rows', r.color, true)
403 for (const m of r.masks) link(`rule:${r.name}`, `out:${m}`, C.red, true)
404 }
405
406 // The result: its rows and columns.
407 add('RESULT', [
408 { id: 'rows', title: `${input.rowCount.toLocaleString('en-US')} rows`, color: C.ds, facts: [{ label: 'Rows returned', value: input.rowCount.toLocaleString('en-US') }] },
409 ...input.columns.map(c => ({
410 id: `out:${c}`,
411 title: c,
412 subtitle: input.hidden.has(c) ? 'masked' : undefined,
413 color: input.hidden.has(c) ? C.red : C.ink,
414 muted: input.hidden.has(c),
415 facts: [{ label: 'Result column', value: c }, ...(input.hidden.has(c) ? [{ label: 'Policy', value: 'Returned as NULL by a masking rule' }] : [])],
416 })),
417 ])
418 // The rows come from the source; a rule that filters them says so above.
419 if (dsName) link('ds', 'rows', C.ds)
420 for (const c of input.columns) {
421 const ref = input.refs.find(r => {
422 const x = input.expansions.find(e => e.kind === r.kind && e.name === r.name)
423 return c === x?.alias || c === r.name || c === r.name.split('__').pop()
424 })
425 if (ref) link(`ref:${ref.kind}:${ref.name}`, `out:${c}`, ref.kind === 'Metric' ? C.metric : C.dim)
426 else if (layers.some(l => l.nodes.some(n => n.id === `col:${c.toUpperCase()}`))) link(`col:${c.toUpperCase()}`, `out:${c}`, C.gray)
427 else if (dsName && !input.hidden.has(c)) link('ds', `out:${c}`, C.gray)
428 }
429 return { layers, edges }
430}
431hooks/query-view/viz.ts 102 lines1// Feature #1067: the graph the Graph tab draws, decided from the result alone.
2// Pure: no `$`, no rendering.
3
4export type ChartSpec = {
5 type: 'bar' | 'line'
6 x: string
7 measure: string
8 categories: string[]
9 series: Array<{ name: string; values: Array<number | null> }>
10 // Set when the graph draws fewer x values than the result holds.
11 note?: string
12}
13
14// Series colors: Energy Teal first, then calm blues and violets; never yellow.
15export const SERIES_COLORS = ['#4DB6AC', '#5B8DEF', '#9B6DD6', '#E07A8B', '#2F8F83', '#7FA7C9']
16
17const TIME_NAME = /(^|_)(date|day|week|month|quarter|year|time|ts|period)(_|$)/i
18const TIME_VALUE = /^\d{4}-\d{2}(-\d{2})?([ T].*)?$/
19const MONTH_START = /^\d{4}-\d{2}-01(T00:00:00(\.0+)?Z?)?$/
20const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
21// A bar chart reads up to this many categories; a line keeps the SVG well
22// under the desktop's 131072-char limit at this many points per series.
23const MAX_BARS = 24
24const MAX_POINTS = 200
25
26const isNum = (v: unknown) => typeof v === 'number' && Number.isFinite(v)
27const plain = (v: unknown) => (v === null || v === undefined ? '(null)' : String(v))
28
29// Indices spread evenly over 0..n-1, first and last kept.
30function sampleIndices(n: number, k: number): number[] {
31 if (n <= k) return [...Array(n).keys()]
32 return [...new Set([...Array(k).keys()].map(i => Math.round((i * (n - 1)) / (k - 1))))]
33}
34
35// The graph the in-app agent would pick for this shape: time on x draws a line
36// (bars while there are too few points for a trend), a category draws bars; a
37// second category becomes the series. Masked columns never chart. Points are
38// sampled, never summed: summing a ratio such as CPI would invent numbers.
39export function chartSpec(columns: string[], rows: Array<Record<string, unknown>>, hidden: Set<string>): ChartSpec | null {
40 const visible = columns.filter(c => !hidden.has(c))
41 const measures = visible.filter(c => rows.length > 0 && rows.every(r => r[c] === null || isNum(r[c])) && rows.some(r => isNum(r[c])))
42 const dims = visible.filter(c => !measures.includes(c))
43 if (measures.length === 0 || rows.length === 0) return null
44 const isTime = (c: string) => TIME_NAME.test(c) || rows.every(r => typeof r[c] === 'string' && TIME_VALUE.test(r[c] as string))
45 const time = dims.find(isTime)
46 const x = time ?? dims[0]
47 if (!x) return null
48 const split = dims.find(c => c !== x)
49 // 2026-03-01T00:00:00 -> 2026-03-01; a month-start series -> Mar 2026.
50 const monthly = !!time && rows.every(r => MONTH_START.test(String(r[time])))
51 const label = (raw: string) => {
52 const text = raw.replace(/T00:00:00(\.0+)?Z?$/, '')
53 return monthly ? `${MONTHS[Number(text.slice(5, 7)) - 1]} ${text.slice(0, 4)}` : text
54 }
55
56 // One pass: each x value's first row per series, in result order.
57 const lead = split ? measures[0] : null
58 const names: string[] = []
59 const xs: string[] = []
60 const cell = new Map<string, Map<string, Record<string, unknown>>>()
61 for (const r of rows) {
62 const k = plain(r[x])
63 const name = split ? plain(r[split]) : ''
64 let bySeries = cell.get(k)
65 if (!bySeries) {
66 cell.set(k, (bySeries = new Map()))
67 xs.push(k)
68 }
69 if (!bySeries.has(name)) bySeries.set(name, r)
70 if (split && !names.includes(name) && names.length < SERIES_COLORS.length) names.push(name)
71 }
72 const seriesNames = split ? names : measures.slice(0, SERIES_COLORS.length)
73 const valueAt = (k: string, si: number) => {
74 const r = split ? cell.get(k)?.get(seriesNames[si]) : cell.get(k)?.get('')
75 const v = r?.[split ? (lead as string) : seriesNames[si]]
76 return isNum(v) ? (v as number) : null
77 }
78
79 let keep: string[]
80 let note: string | undefined
81 if (time) {
82 xs.sort()
83 keep = sampleIndices(xs.length, MAX_POINTS).map(i => xs[i])
84 if (keep.length < xs.length) note = `${xs.length.toLocaleString('en-US')} points, ${keep.length} shown evenly across the range`
85 } else if (xs.length > MAX_BARS) {
86 // The categories with the largest values, in result order.
87 const total = (k: string) => seriesNames.reduce((n, _, si) => n + Math.abs(valueAt(k, si) ?? 0), 0)
88 const top = new Set([...xs].sort((a, b) => total(b) - total(a)).slice(0, MAX_BARS))
89 keep = xs.filter(k => top.has(k))
90 note = `Top ${MAX_BARS} of ${xs.length.toLocaleString('en-US')} ${x} values`
91 } else {
92 keep = xs
93 }
94
95 const series = seriesNames.map((name, si) => ({ name, values: keep.map(k => valueAt(k, si)) }))
96 if (split && names.length < new Set(rows.map(r => plain(r[split]))).size) {
97 note = [note, `first ${names.length} ${split} values`].filter(Boolean).join('; ')
98 }
99 const type = time && keep.length > 4 ? 'line' : 'bar'
100 return { type, x, measure: split ? (lead as string) : measures.slice(0, SERIES_COLORS.length).join(', '), categories: keep.map(label), series, note }
101}
102