SLOPSHOPPER

graphit

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

newpanerowsguardprocess
v0.2.405UNLICENSEDupdated 2026-10-07omry-graphit/graphit-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · graphit
│ ┃ graphit-kb ✕ › fix the failing auth test and add an audit log call │ ┃ Click a KB reference in a query result to │ ┃ see its definition. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · graphit-kb
Click a KB reference in a query result to see its definition.
Pane · graphit-sql
Open the SQL tab on a query result, then Open full SQL.
Pane · graphit-results
Open Full results on a query result.
Pane · graphit-lineage
Open Lineage on a query result, then Explore lineage.
README

Graphit CLI

If you can talk it, you can graph it.

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.

npm node docs


@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."

Install

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 the graphit commands 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.

Authenticate

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).

Commands

GroupWhat it does
graphit authLog in, check status, log out
graphit kbKnowledge base: list, get, search, explore, and full CRUD for metrics, dimensions, rules, synonyms, domains, relationships, topics, tables, and templates
graphit queryRun a governed SQL query against your data
graphit dsData sources: list, create, refresh, verify, update cached sources
graphit dashboardCustom dashboards: list, create, get, update HTML or a single entity, export to PNG/PDF, delete
graphit connectorConnections: list, add, and test. Removal is admin-only in the Sources Hub; the CLI verb explains the handoff but does not delete.
graphit governanceQuery governance: inspect and set the enforcement mode
graphit setupInstall 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).

Why Graphit

  • Governed by default. Every query passes through a server-side governance gateway. Curated metrics and dimensions resolve through reference syntax ({{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.
  • Cached and fast. Create a data source once and query it in milliseconds instead of hitting the live warehouse on every request.
  • A bespoke canvas, not template tiles. Dashboards are real, hand-authored HTML, SVG, and CSS. The CLI manages the dashboard document; the rendering runtime resolves live data and wires interactivity (filters, parameters, saved views).
  • Retrieve to predict. Look up a number, monitor it, diagnose why it moved, or project where it is headed - all through one governed semantic layer.

Documentation

Full docs, the complete CLI reference, and the dashboard build workflow live at graphit-app.com.

License

UNLICENSED. Copyright Graphit.

Source 16 files
hooks/query-view/register.tsx 375 lines
1import { 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}
375
hooks/query-view/atoms.ts 19 lines
1// 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}
19
hooks/query-view/card.tsx 176 lines
1// 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}
176
hooks/query-view/card-desktop.tsx 220 lines
1// 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}
220
hooks/query-view/card-terminal.tsx 342 lines
1// 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}
342
hooks/query-view/loaders.ts 73 lines
1// 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}
73
hooks/query-view/panes.tsx 333 lines
1// 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}
333
hooks/query-view/results-pane.tsx 154 lines
1// 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}
154
hooks/query-view/result.ts 332 lines
1// 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*$/
332
hooks/query-view/sql.ts 266 lines
1// 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}
266
hooks/query-view/upstream.ts 431 lines
1// 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}
431
hooks/query-view/viz.ts 102 lines
1// 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