diff --git a/src/core/dot-layout.js b/src/core/dot-layout.js index e8dd1c9..17721f3 100644 --- a/src/core/dot-layout.js +++ b/src/core/dot-layout.js @@ -46,9 +46,10 @@ export function dagreLayout(dagre, graph) { const outNodes = nodes.map((n) => { const dn = g.node(n.id); - // `kind`/`db`/`name` (node) and `label` (edge) pass through for the schema - // graph's colouring + click-to-SHOW-CREATE (so the UI need not re-split the id). - return { id: n.id, label: n.label, kind: n.kind, db: n.db, name: n.name, x: dn.x - dn.width / 2, y: dn.y - dn.height / 2, w: dn.width, h: dn.height }; + // `kind`/`db`/`name`/`external` (node) and `label` (edge) pass through for the + // schema graph's colouring, external-dimming + click-to-SHOW-CREATE (so the UI + // need not re-split the id or keep a side-channel for these). + return { id: n.id, label: n.label, kind: n.kind, db: n.db, name: n.name, external: n.external, x: dn.x - dn.width / 2, y: dn.y - dn.height / 2, w: dn.width, h: dn.height }; }); const outEdges = edges.map((e) => ({ from: e.from, to: e.to, kind: e.kind, label: e.label, diff --git a/src/core/schema-graph.js b/src/core/schema-graph.js index ea40517..97a03d8 100644 --- a/src/core/schema-graph.js +++ b/src/core/schema-graph.js @@ -108,11 +108,11 @@ export function buildSchemaGraph(rows, focus) { const byId = new Map(); // id → table row, for lookups const innerByUuid = new Map(); // implicit-MV inner storage, keyed by owner uuid - const node = (id, kind) => { - if (!nodes.has(id)) { - const dot = id.indexOf('.'); - nodes.set(id, { id, label: id, kind, db: id.slice(0, dot), name: id.slice(dot + 1) }); - } + // Every creation passes explicit db/name (callers build the id via joinId/rowId, + // so they always know the parts) — keeping a dotted *database* correct, not just a + // dotted table. The db/name args are ignored when the node already exists. + const node = (id, kind, db, name) => { + if (!nodes.has(id)) nodes.set(id, { id, label: id, kind, db, name }); return nodes.get(id); }; // external (non-CH dictionary source) leaf @@ -129,7 +129,7 @@ export function buildSchemaGraph(rows, focus) { const uuid = t.name.replace(/^\.inner(_id)?\./, ''); innerByUuid.set(uuid, id); } - node(id, objectKind(t.engine)); + node(id, objectKind(t.engine), t.database, t.name); } // friendlier labels for inner storage tables for (const id of innerByUuid.values()) { @@ -147,15 +147,18 @@ export function buildSchemaGraph(rows, focus) { seen.add(k); edges.push({ from, to, kind }); }; - const zip = (dbs, names) => (names || []).map((nm, i) => joinId((dbs && dbs[i]) || '', nm)); + const zip = (dbs, names) => (names || []).map((nm, i) => { + const d = (dbs && dbs[i]) || ''; + return { id: joinId(d, nm), db: d, name: nm }; + }); for (const t of tables) { const id = rowId(t); const kind = nodes.get(id).kind; // source → MV/View (structured dependents on the source side) for (const dep of zip(t.dependencies_database, t.dependencies_table)) { - node(dep, byId.has(dep) ? nodes.get(dep).kind : 'table'); - addEdge(id, dep, 'feeds'); + node(dep.id, byId.has(dep.id) ? nodes.get(dep.id).kind : 'table', dep.db, dep.name); + addEdge(id, dep.id, 'feeds'); } // fallback: EXPLAIN AST sources of a view/MV → source → this object. EXPLAIN // AST prints names unquoted, qualified-or-bare — so resolve against the known @@ -172,12 +175,18 @@ export function buildSchemaGraph(rows, focus) { if (kind === 'mv') { const target = parseMvTarget(t.create_table_query); const targetId = target ? joinId(target.db || t.database, target.table) : innerByUuid.get(String(t.uuid || '')); - if (targetId) { node(targetId, byId.has(targetId) ? nodes.get(targetId).kind : 'table'); addEdge(id, targetId, 'writes'); } + if (targetId) { + // For an implicit (.inner) target the node already exists with correct + // parts (created in the first pass), so db/name here are only used for the + // explicit-TO case. + node(targetId, byId.has(targetId) ? nodes.get(targetId).kind : 'table', target ? (target.db || t.database) : undefined, target ? target.table : undefined); + addEdge(id, targetId, 'writes'); + } } else if (kind === 'distributed' || kind === 'buffer' || kind === 'merge') { const ref = parseEngineRef(t.engine, t.engine_full); if (ref && ref.table) { const refId = joinId(ref.db || t.database, ref.table); - node(refId, byId.has(refId) ? nodes.get(refId).kind : 'table'); + node(refId, byId.has(refId) ? nodes.get(refId).kind : 'table', ref.db || t.database, ref.table); addEdge(refId, id, ref.kind === 'buffer' ? 'buffer' : 'shard'); } else if (ref && ref.regex) { let rx = null; @@ -200,10 +209,10 @@ export function buildSchemaGraph(rows, focus) { const ld = zip(t.loading_dependencies_database, t.loading_dependencies_table); const d = dictByid.get(id); if (ld.length) { - for (const src of ld) { node(src, byId.has(src) ? nodes.get(src).kind : 'table'); addEdge(src, id, 'dict'); } + for (const src of ld) { node(src.id, byId.has(src.id) ? nodes.get(src.id).kind : 'table', src.db, src.name); addEdge(src.id, id, 'dict'); } } else { const s = parseDictSource(d && d.source, t.create_table_query); - if (s && s.table) { const sid = joinId(s.db || t.database, s.table); node(sid, 'table'); addEdge(sid, id, 'dict'); } + if (s && s.table) { const sid = joinId(s.db || t.database, s.table); node(sid, 'table', s.db || t.database, s.table); addEdge(sid, id, 'dict'); } else if (s && s.external) addEdge(external(s.external), id, 'dict'); } } @@ -231,3 +240,53 @@ export function buildSchemaGraph(rows, focus) { } return { nodes: outNodes, edges: outEdges }; } + +/** + * The databases referenced by `graph`'s nodes that aren't in `loadedDbs` — the + * next databases to fetch to extend a transitive cross-DB lineage. External + * (non-CH `ext:`) leaves carry an empty db and are skipped. Pure. + */ +export function externalDbs(graph, loadedDbs) { + const loaded = new Set(loadedDbs || []); + const out = new Set(); + for (const n of (graph && graph.nodes) || []) { + if (n.db && !loaded.has(n.db)) out.add(n.db); + } + return [...out]; +} + +/** + * Transitive closure of `graph` around every node in `seedDb`: an undirected BFS + * over the edges in BOTH directions across database boundaries, until the frontier + * empties or `cap` nodes are reached (then `truncated`). Returns `{ nodes, edges, + * truncated }` with each node tagged `external = (n.db !== seedDb)` and only the + * reached nodes/edges kept. All of `seedDb` is seeded unconditionally; the cap + * bounds only the cross-DB expansion (a pathologically interconnected cluster + * can't freeze the view). Pure — the loader decides which DBs to fetch. + */ +export function expandLineage(graph, seedDb, opts = {}) { + const cap = opts.cap != null ? opts.cap : 600; + const allNodes = (graph && graph.nodes) || []; + const edges = (graph && graph.edges) || []; + const byId = new Map(allNodes.map((n) => [n.id, n])); + const adj = new Map(); + const link = (a, b) => { const l = adj.get(a); if (l) l.push(b); else adj.set(a, [b]); }; + for (const e of edges) { + if (!byId.has(e.from) || !byId.has(e.to)) continue; + link(e.from, e.to); link(e.to, e.from); + } + const visited = new Set(); + const queue = []; + for (const n of allNodes) if (n.db === seedDb) { visited.add(n.id); queue.push(n.id); } + let truncated = false; + while (queue.length && !truncated) { + for (const nb of adj.get(queue.shift()) || []) { + if (visited.has(nb)) continue; + if (visited.size >= cap) { truncated = true; break; } + visited.add(nb); queue.push(nb); + } + } + const nodes = [...visited].map((id) => ({ ...byId.get(id), external: byId.get(id).db !== seedDb })); + const outEdges = edges.filter((e) => visited.has(e.from) && visited.has(e.to)); + return { nodes, edges: outEdges, truncated }; +} diff --git a/src/net/ch-client.js b/src/net/ch-client.js index de82551..190ff9f 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -8,7 +8,7 @@ // so the whole module is unit-testable with plain stubs. import { parseExceptionText, isAuthExpiredBody, authDeniedMessage } from '../core/stream.js'; -import { parseAstTables } from '../core/schema-graph.js'; +import { parseAstTables, buildSchemaGraph, externalDbs } from '../core/schema-graph.js'; import { sqlString } from '../core/format.js'; /** Build a ClickHouse HTTP URL with query-string options. Pure. */ @@ -205,6 +205,70 @@ export async function loadSchemaCards(ctx, dbs) { return { columnsByKey, skipByKey }; } +/** + * Load lineage rows transitively across database boundaries: start at `focus.db`, + * then BFS into every database referenced by the graph built so far, merging rows, + * until no new database is referenced or a cap is hit. `opts.dbCap` bounds the + * number of databases fetched and `opts.nodeCap` the graph size — either tripping + * sets `truncated` (the caller shows a banner). Returns `{ rows, truncated }`; + * `rows` is the merged `{ tables, dictionaries }` for buildSchemaGraph + expandLineage. + */ +export async function loadLineageTransitive(ctx, focus, opts = {}) { + const nodeCap = opts.nodeCap != null ? opts.nodeCap : 600; + const dbCap = opts.dbCap != null ? opts.dbCap : 8; + const seed = (focus && focus.db) || ''; + const loaded = new Set(); + let frontier = seed ? [seed] : []; + let tables = []; + let dictionaries = []; + let truncated = false; + while (frontier.length) { + if (loaded.size >= dbCap) { truncated = true; break; } + // Load the whole frontier concurrently (bounded by the remaining db budget), + // rebuild the graph once per round, then take its newly-referenced dbs as the + // next frontier. Far fewer round-trips than fetching one db at a time. + const batch = frontier.slice(0, dbCap - loaded.size); + batch.forEach((db) => loaded.add(db)); + const parts = await Promise.all(batch.map((db) => loadSchemaLineage(ctx, { db }))); + for (const part of parts) { + tables = tables.concat(part.tables); + dictionaries = dictionaries.concat(part.dictionaries); + } + const graph = buildSchemaGraph({ tables, dictionaries }); + if (graph.nodes.length >= nodeCap) { truncated = true; break; } + frontier = externalDbs(graph, loaded); + } + return { rows: { tables, dictionaries }, truncated }; +} + +/** + * Per-table detail for the node detail pane: full columns (with key-role flags + + * compression sizes), per-partition part/row/byte sums, and the DDL. All reads are + * best-effort via tryQueryData (a denied/missing system table degrades to empty, + * never an error). Returns `{ columns, partitions, ddl }`. + */ +export async function loadTableDetail(ctx, db, table) { + const byCol = 'database = ' + sqlString(db) + ' AND table = ' + sqlString(table); + const byName = 'database = ' + sqlString(db) + ' AND name = ' + sqlString(table); + const [columns, partitions, ddlRows] = await Promise.all([ + tryQueryData(ctx, + 'SELECT name, type, compression_codec AS codec, ' + + 'is_in_partition_key, is_in_sorting_key, is_in_primary_key, is_in_sampling_key, ' + + 'toUInt64(data_compressed_bytes) AS compressed, toUInt64(data_uncompressed_bytes) AS uncompressed, ' + + 'toUInt64(marks_bytes) AS marks, position ' + + 'FROM system.columns WHERE ' + byCol + ' ORDER BY position FORMAT JSON'), + tryQueryData(ctx, + 'SELECT partition, count() AS parts, sum(rows) AS rows, sum(bytes_on_disk) AS bytes ' + + 'FROM system.parts WHERE ' + byCol + ' AND active GROUP BY partition ORDER BY partition FORMAT JSON'), + tryQueryData(ctx, 'SELECT create_table_query AS ddl FROM system.tables WHERE ' + byName + ' FORMAT JSON'), + ]); + return { + columns: columns || [], + partitions: partitions || [], + ddl: (ddlRows && ddlRows[0] && ddlRows[0].ddl) || '', + }; +} + // Run a query for its `data` rows, returning null on ANY error. Editor // reference data is best-effort: a missing system table on older ClickHouse (or // a denied SELECT) must degrade gracefully, never surface as a query error. diff --git a/src/styles.css b/src/styles.css index 94689bf..4a544a3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -681,6 +681,9 @@ body { .explain-graph .eg-badge--sk { fill: #6bb6ff; } .explain-graph .eg-badge--partition { fill: #c297ff; } .explain-graph .eg-badge--sampling { fill: #eab308; } +/* external (other-DB) cards pulled in transitively: dashed border + dimmed fill, + composed on top of the kind colour. */ +.explain-graph .eg-node--ext { stroke-dasharray: 4 3; fill-opacity: 0.4; } .schema-graph-view { position: relative; } .schema-graph-legend { @@ -729,6 +732,43 @@ body { } .graph-overlay-canvas.grabbing { cursor: grabbing; } .graph-overlay-canvas > svg.explain-graph { width: 100%; height: 100%; } +.graph-overlay-note { font-size: 11px; color: #eab308; } + +/* ------------ schema node detail pane (fullscreen graph) ------------ */ +.schema-detail { + flex: 0 0 280px; min-height: 90px; position: relative; + border-top: 1px solid var(--border); background: var(--bg-modal); overflow: auto; +} +.schema-detail-handle { + position: sticky; top: 0; height: 7px; cursor: row-resize; background: var(--border); +} +.schema-detail-handle:hover { background: var(--fg-faint); } +.schema-detail-close { + position: absolute; top: 10px; right: 10px; z-index: 1; + display: flex; align-items: center; justify-content: center; + width: 24px; height: 24px; border: none; border-radius: 6px; + background: transparent; color: var(--fg-mute); cursor: pointer; +} +.schema-detail-close:hover { background: var(--bg-hover); color: var(--fg); } +.schema-detail-body { padding: 10px 14px; } +.schema-detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.schema-detail-head b { font-size: 13px; word-break: break-all; } +.schema-detail-kind { font-size: 11px; color: var(--fg-faint); } +.schema-detail h4 { + font-size: 11px; text-transform: uppercase; letter-spacing: .04em; + color: var(--fg-faint); margin: 12px 0 6px; +} +.schema-detail-cols { width: 100%; border-collapse: collapse; font-size: 11px; } +.schema-detail-cols th, .schema-detail-cols td { + text-align: left; padding: 3px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; +} +.schema-detail-cols th { color: var(--fg-faint); font-weight: 600; } +.schema-detail-cols th.num, .schema-detail-cols td.num { text-align: right; } +.schema-detail-roles { color: #ff8f6b; font-weight: 600; } +.schema-detail-ddl { + background: var(--bg-table); border: 1px solid var(--border); border-radius: 6px; + padding: 10px; overflow: auto; font: 11px/1.45 var(--mono); white-space: pre-wrap; +} /* ------------ chart view ------------ */ /* `.res-body` is display:block, so height:100% (not flex:1) is what lets the diff --git a/src/ui/app.js b/src/ui/app.js index 564ad64..c160f8f 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -13,7 +13,7 @@ import { saveJSON, saveStr } from '../core/storage.js'; import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat } from '../core/format.js'; import { EXPLAIN_VIEWS, parseExplain, detectExplainView, buildExplainQuery } from '../core/explain.js'; -import { buildSchemaGraph } from '../core/schema-graph.js'; +import { buildSchemaGraph, expandLineage } from '../core/schema-graph.js'; import { buildCardGraph } from '../core/schema-cards.js'; import { resolveTarget } from '../core/target.js'; import { toTSV, toCSV } from '../core/export.js'; @@ -29,6 +29,7 @@ import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs. import { renderSchema } from './schema.js'; import { renderResults } from './results.js'; import { openSchemaFullscreen } from './explain-graph.js'; +import { openDetailPane } from './schema-detail.js'; import { renderSavedHistory } from './saved-history.js'; import { libraryControls, renderLibraryTitle } from './file-menu.js'; import { renderLogin } from './login.js'; @@ -545,21 +546,36 @@ export function createApp(env = {}) { if (!focus || !focus.db) return; await ensureConfig(); if (!(await getToken())) { chCtx.onSignedOut(); return; } - let rows, cards; + let lineage; try { - // The lineage rows and the card metadata are independent — load concurrently. - [rows, cards] = await Promise.all([ - ch.loadSchemaLineage(chCtx, focus), - ch.loadSchemaCards(chCtx, [focus.db]), - ]); + // Walk lineage transitively across DB boundaries (soft-capped) — pulls in + // objects an other database references, instead of dead-ending at the edge. + lineage = await ch.loadLineageTransitive(chCtx, focus); } catch { // The inline graph is still on screen; tell the user the expand didn't load. flashToast('Could not load the schema graph', { document: doc }); return; } - const g = buildSchemaGraph(rows, focus); - const cardGraph = buildCardGraph(g, { tables: rows.tables, columnsByKey: cards.columnsByKey, skipByKey: cards.skipByKey }); - openSchemaFullscreen(app, { nodes: cardGraph.nodes, edges: cardGraph.edges, focus, tableCount: (rows.tables || []).length }); + const g = buildSchemaGraph(lineage.rows, focus); + const ex = expandLineage(g, focus.db); // closure around focus.db, tags external nodes + // Card metadata for every database the expansion reached (external nodes too). + const dbs = [...new Set(ex.nodes.map((n) => n.db).filter(Boolean))]; + const cards = await ch.loadSchemaCards(chCtx, dbs); + const cardGraph = buildCardGraph({ nodes: ex.nodes, edges: ex.edges }, + { tables: lineage.rows.tables, columnsByKey: cards.columnsByKey, skipByKey: cards.skipByKey }); + openSchemaFullscreen(app, { + nodes: cardGraph.nodes, edges: cardGraph.edges, focus, + tableCount: (lineage.rows.tables || []).length, + truncated: lineage.truncated || ex.truncated, + }); + } + + // Open the detail pane for a clicked fullscreen node: lazily load the table's full + // columns / partitions / DDL (best-effort) and mount the pane in the overlay. + async function openNodeDetail(node) { + if (!node || !node.db || !node.name) return; + const detail = await ch.loadTableDetail(chCtx, node.db, node.name); + openDetailPane(app, node, detail); } // Explain the current query without editing it: run it through the EXPLAIN @@ -775,6 +791,7 @@ export function createApp(env = {}) { setExplainView, showSchemaGraph, expandSchemaGraph, + openNodeDetail, insertCreate, openShortcuts: () => openShortcuts(app), insertAtCursor: (text) => insertAtCursor(app, text), diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index d900637..a817e91 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -214,10 +214,12 @@ export function buildRichSchemaSvg(graph, dagre, onNode) { const { w, h } = cardSize(model); return { ...n, w, h }; }); + // `external` rides through dagreLayout (like kind/db/name), so the node class can + // read it off the laid node — no side-channel needed. const laid = dagreLayout(dagre, { nodes: sized, edges: g.edges || [] }); return renderRichGraphSvg(laid, { cardById, - nodeClass: (n) => 'eg-node eg-node--' + (n.kind || 'table'), + nodeClass: (n) => 'eg-node eg-node--' + (n.kind || 'table') + (n.external ? ' eg-node--ext' : ''), edgeClass: (e) => 'eg-edge eg-edge--' + (e.kind || 'feeds'), edgeLabel: (e) => e.kind, onNode, @@ -253,9 +255,10 @@ function schemaLegend() { * Open a graph in a fullscreen overlay (drag-pan, ⌘/Ctrl+wheel zoom, fit/zoom * buttons; Esc / ✕ / backdrop close). `build()` returns `{svg,width,height,nodeCount}` * — shared by the pipeline and schema graphs. `extra` is an optional overlay node - * (e.g. the schema legend). + * (e.g. the schema legend); `note` an optional banner shown in the bar (e.g. a + * truncation warning). */ -function openGraphFullscreen(app, title, build, extra, emptyMsg) { +function openGraphFullscreen(app, title, build, extra, emptyMsg, note) { const doc = (app && app.document) || document; const built = build(); const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; @@ -263,6 +266,7 @@ function openGraphFullscreen(app, title, build, extra, emptyMsg) { function close() { backdrop.remove(); doc.removeEventListener('keydown', onKey, true); } const bar = h('div', { class: 'graph-overlay-bar' }, h('span', { class: 'graph-overlay-title' }, title)); + if (note) bar.appendChild(h('span', { class: 'graph-overlay-note' }, note)); const canvas = h('div', { class: 'graph-overlay-canvas' }); if (!built.nodeCount) { canvas.appendChild(placeholder(emptyMsg || 'Nothing to display.')); @@ -298,9 +302,20 @@ const schemaClick = (app) => (n) => { app.actions.insertCreate(qualifyIdent(n.db, n.name)); }; -/** Fullscreen schema-lineage graph — rich cards (engine/rows/bytes + columns). */ +// In the fullscreen graph, clicking an object opens the detail pane (full columns / +// keys / partitions / DDL) instead of inserting SHOW CREATE — the pane carries its +// own "Insert SHOW CREATE" button. External (ext:) leaves have no detail to show. +const schemaDetailClick = (app) => (n) => { + if (!n.id || n.id.startsWith('ext:')) return; + app.actions.openNodeDetail(n); +}; + +/** Fullscreen schema-lineage graph — rich cards + click-a-node detail pane. */ export function openSchemaFullscreen(app, graph) { - return openGraphFullscreen(app, 'Schema', () => buildRichSchemaSvg(graph, app && app.Dagre, schemaClick(app)), schemaLegend(), schemaEmptyMessage(graph)); + const note = graph && graph.truncated + ? 'Lineage truncated — showing ' + (((graph.nodes && graph.nodes.length) || 0)) + ' objects' + : null; + return openGraphFullscreen(app, 'Schema', () => buildRichSchemaSvg(graph, app && app.Dagre, schemaDetailClick(app)), schemaLegend(), schemaEmptyMessage(graph), note); } /** diff --git a/src/ui/schema-detail.js b/src/ui/schema-detail.js new file mode 100644 index 0000000..ffae6d8 --- /dev/null +++ b/src/ui/schema-detail.js @@ -0,0 +1,82 @@ +// The node detail pane for the fullscreen schema graph: a resizable strip docked +// at the bottom of the overlay panel, showing a clicked object's full columns +// (with key-role flags + compression sizes), per-partition part/row/byte sums, and +// its DDL — plus an "Insert SHOW CREATE" action. Pure DOM over the app controller; +// the data is fetched by app.actions.openNodeDetail (ch.loadTableDetail). + +import { h } from './dom.js'; +import { Icon } from './icons.js'; +import { clamp, formatRows, formatBytes, qualifyIdent } from '../core/format.js'; +import { columnRoles } from '../core/schema-cards.js'; + +const MIN_H = 90; // smallest pane height; max is panel height - this margin +const TOP_MARGIN = 100; + +/** + * Mount (or replace) the detail pane for `node` inside the live fullscreen overlay, + * populated from `detail` ({ columns, partitions, ddl }). Returns the pane element, + * or null when no overlay is open. The ✕ button closes just the pane; Esc closes + * the whole overlay (which removes the pane with it). + */ +export function openDetailPane(app, node, detail) { + const doc = (app && app.document) || document; + const panel = doc.querySelector('.graph-overlay-panel'); + if (!panel) return null; // overlay already closed + const prior = panel.querySelector('.schema-detail'); + if (prior) prior.remove(); // re-opening for another node replaces the pane + + const cols = detail.columns || []; + const parts = detail.partitions || []; + const ident = qualifyIdent(node.db, node.name); + + const colsTable = h('table', { class: 'schema-detail-cols' }, + h('thead', null, h('tr', null, + h('th', null, 'column'), h('th', null, 'type'), h('th', null, 'codec'), + h('th', { class: 'num' }, 'compressed'), h('th', { class: 'num' }, 'uncompressed'), h('th', null, 'key'))), + h('tbody', null, ...cols.map((c) => h('tr', null, + h('td', null, c.name), h('td', null, c.type), h('td', null, c.codec || ''), + h('td', { class: 'num' }, formatBytes(c.compressed)), + h('td', { class: 'num' }, formatBytes(c.uncompressed)), + h('td', { class: 'schema-detail-roles' }, columnRoles(c).join(' ')))))); + + const partsSection = parts.length + ? h('div', null, + h('h4', null, 'Partitions (' + parts.length + ')'), + h('table', { class: 'schema-detail-cols' }, + h('thead', null, h('tr', null, + h('th', null, 'partition'), h('th', { class: 'num' }, 'parts'), + h('th', { class: 'num' }, 'rows'), h('th', { class: 'num' }, 'bytes'))), + h('tbody', null, ...parts.map((p) => h('tr', null, + h('td', null, p.partition), h('td', { class: 'num' }, formatRows(p.parts)), + h('td', { class: 'num' }, formatRows(p.rows)), h('td', { class: 'num' }, formatBytes(p.bytes))))))) + : null; + + const handle = h('div', { class: 'schema-detail-handle', title: 'Drag to resize' }); + const pane = h('div', { class: 'schema-detail' }, + handle, + h('button', { class: 'schema-detail-close', title: 'Close', onclick: () => pane.remove() }, Icon.close()), + h('div', { class: 'schema-detail-body' }, + h('div', { class: 'schema-detail-head' }, + h('b', null, ident), h('span', { class: 'schema-detail-kind' }, node.kind || 'table'), + h('button', { class: 'res-act', onclick: () => app.actions.insertCreate(ident) }, 'Insert SHOW CREATE')), + h('h4', null, 'Columns (' + cols.length + ')'), + colsTable, + partsSection, + detail.ddl ? h('h4', null, 'DDL') : null, + detail.ddl ? h('pre', { class: 'schema-detail-ddl' }, detail.ddl) : null)); + panel.appendChild(pane); + + // Drag the handle to resize. Listeners are added on mousedown and removed on + // mouseup, so nothing persists on the document between drags (or after close). + handle.addEventListener('mousedown', (e) => { + e.preventDefault(); + // The panel is the fixed full-screen overlay — its box is stable for the drag, + // so measure once here rather than reflowing on every mousemove. + const r = panel.getBoundingClientRect(); + const onMove = (ev) => { pane.style.flexBasis = clamp(r.bottom - ev.clientY, MIN_H, r.height - TOP_MARGIN) + 'px'; }; + const onUp = () => { doc.removeEventListener('mousemove', onMove); doc.removeEventListener('mouseup', onUp); }; + doc.addEventListener('mousemove', onMove); + doc.addEventListener('mouseup', onUp); + }); + return pane; +} diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index b6ca40c..b38fcad 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -75,6 +75,7 @@ export function makeApp(over = {}) { setExplainView: vi.fn(), showSchemaGraph: vi.fn(), expandSchemaGraph: vi.fn(), + openNodeDetail: vi.fn(), insertCreate: vi.fn(), openShortcuts: vi.fn(), insertAtCursor: vi.fn(), diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index fdfffc4..598cc38 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -1234,6 +1234,16 @@ describe('schema lineage graph (drag a db/table onto the results pane)', () => { await app3.actions.expandSchemaGraph({ kind: 'db', db: 'lin' }); expect(document.body.querySelector('.graph-overlay')).toBeNull(); }); + + it('openNodeDetail mounts the detail pane in the open overlay (and guards an incomplete node)', async () => { + const { app } = appForRun(lineageRoutes); + await app.actions.expandSchemaGraph({ kind: 'db', db: 'lin' }); + expect(document.body.querySelector('.graph-overlay')).not.toBeNull(); + await app.actions.openNodeDetail({ db: 'lin', name: 'events', kind: 'table' }); + expect(document.body.querySelector('.schema-detail')).not.toBeNull(); + await app.actions.openNodeDetail({ db: 'lin' }); // no name → guard returns, no throw + document.body.querySelector('.graph-overlay').remove(); + }); }); describe('schema graph drop edge cases', () => { diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index 5e1c1ca..b12291c 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { - chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, loadReferenceData, loadEntityDoc, runQuery, killQuery, loadSchemaLineage, loadSchemaCards, + chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, loadReferenceData, loadEntityDoc, runQuery, killQuery, loadSchemaLineage, loadSchemaCards, loadLineageTransitive, loadTableDetail, } from '../../src/net/ch-client.js'; import { sqlString } from '../../src/core/format.js'; @@ -406,3 +406,86 @@ describe('loadSchemaCards', () => { expect(ctx.fetch).not.toHaveBeenCalled(); }); }); + +describe('loadLineageTransitive', () => { + // 'a.t' depends on 'b.mv' (cross-DB), so the walk should pull in database 'b'. + const tbl = (database, name, engine, over = {}) => ({ + database, name, engine, engine_full: '', create_table_query: '', as_select: '', uuid: '', + dependencies_database: [], dependencies_table: [], loading_dependencies_database: [], loading_dependencies_table: [], ...over, + }); + const lineageCtx = (extra = {}) => ctxWith((url, init) => { + const sql = init.body; + if (/EXPLAIN AST/.test(sql)) return jsonResp({ data: [] }); + if (/system\.dictionaries/.test(sql)) return jsonResp({ data: [] }); + if (/database = 'a'/.test(sql)) return jsonResp({ data: [tbl('a', 't', 'MergeTree', { dependencies_database: ['b'], dependencies_table: ['mv'] })] }); + if (/database = 'b'/.test(sql)) return jsonResp({ data: [tbl('b', 'mv', 'MaterializedView', extra.b || {})] }); + if (/database = 'c'/.test(sql)) return jsonResp({ data: [tbl('c', 'x', 'MergeTree')] }); + return jsonResp({ data: [] }); + }); + + it('walks across DB boundaries and merges rows from both databases', async () => { + const out = await loadLineageTransitive(lineageCtx(), { db: 'a' }); + const dbs = new Set(out.rows.tables.map((t) => t.database)); + expect(dbs.has('a')).toBe(true); + expect(dbs.has('b')).toBe(true); // pulled in transitively + expect(out.truncated).toBe(false); + }); + + it('flags truncated when the database cap is hit', async () => { + // a → b → c; dbCap 2 stops before loading c. + const ctx = ctxWith((url, init) => { + const sql = init.body; + if (/EXPLAIN AST/.test(sql) || /system\.dictionaries/.test(sql)) return jsonResp({ data: [] }); + if (/database = 'a'/.test(sql)) return jsonResp({ data: [tbl('a', 't', 'MergeTree', { dependencies_database: ['b'], dependencies_table: ['u'] })] }); + if (/database = 'b'/.test(sql)) return jsonResp({ data: [tbl('b', 'u', 'MergeTree', { dependencies_database: ['c'], dependencies_table: ['w'] })] }); + return jsonResp({ data: [] }); + }); + const out = await loadLineageTransitive(ctx, { db: 'a' }, { dbCap: 2 }); + expect(out.truncated).toBe(true); + expect(new Set(out.rows.tables.map((t) => t.database)).has('c')).toBe(false); + }); + + it('flags truncated when the node cap is hit', async () => { + const out = await loadLineageTransitive(lineageCtx(), { db: 'a' }, { nodeCap: 1 }); + expect(out.truncated).toBe(true); + }); + + it('loads a multi-database frontier concurrently in one round', async () => { + const ctx = ctxWith((url, init) => { + const sql = init.body; + if (/EXPLAIN AST/.test(sql) || /system\.dictionaries/.test(sql)) return jsonResp({ data: [] }); + if (/database = 'a'/.test(sql)) return jsonResp({ data: [tbl('a', 't', 'MergeTree', { dependencies_database: ['b', 'c'], dependencies_table: ['mb', 'mc'] })] }); + if (/database = 'b'/.test(sql)) return jsonResp({ data: [tbl('b', 'mb', 'MergeTree')] }); + if (/database = 'c'/.test(sql)) return jsonResp({ data: [tbl('c', 'mc', 'MergeTree')] }); + return jsonResp({ data: [] }); + }); + const out = await loadLineageTransitive(ctx, { db: 'a' }); + const dbs = new Set(out.rows.tables.map((t) => t.database)); + expect(dbs.has('b') && dbs.has('c')).toBe(true); // both external dbs pulled in + expect(out.truncated).toBe(false); + }); + + it('returns empty rows for a missing focus db', async () => { + const ctx = ctxWith(() => { throw new Error('should not fetch'); }); + expect(await loadLineageTransitive(ctx, {})).toEqual({ rows: { tables: [], dictionaries: [] }, truncated: false }); + }); +}); + +describe('loadTableDetail', () => { + it('returns columns, per-partition sums, and DDL (best-effort)', async () => { + const ctx = ctxWith((url, init) => { + const sql = init.body; + if (/system\.parts/.test(sql)) return jsonResp({ data: [{ partition: '2024', parts: 3, rows: 100, bytes: 5000 }] }); + if (/create_table_query/.test(sql)) return jsonResp({ data: [{ ddl: 'CREATE TABLE a.t (id UInt64) ENGINE = MergeTree' }] }); + return jsonResp({ data: [{ name: 'id', type: 'UInt64', is_in_primary_key: 1, position: 1 }] }); + }); + const d = await loadTableDetail(ctx, 'a', 't'); + expect(d.columns).toHaveLength(1); + expect(d.partitions[0].partition).toBe('2024'); + expect(d.ddl).toContain('CREATE TABLE'); + }); + it('degrades to empty arrays + empty DDL when the system tables are denied', async () => { + const ctx = ctxWith(() => jsonResp('Code: 497', false, 500)); + expect(await loadTableDetail(ctx, 'a', 't')).toEqual({ columns: [], partitions: [], ddl: '' }); + }); +}); diff --git a/tests/unit/dot-layout.test.js b/tests/unit/dot-layout.test.js index c938d02..ad796bb 100644 --- a/tests/unit/dot-layout.test.js +++ b/tests/unit/dot-layout.test.js @@ -54,12 +54,13 @@ describe('dagreLayout', () => { it('honors an explicit node w/h, else falls back to label width + fixed height', () => { const g = dagreLayout(dagre, { - nodes: [{ id: 'card', label: 'x', w: 240, h: 120 }, { id: 'plain', label: 'plain' }], + nodes: [{ id: 'card', label: 'x', w: 240, h: 120, external: true }, { id: 'plain', label: 'plain' }], edges: [{ from: 'card', to: 'plain' }], }); const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); expect(by.card.w).toBe(240); // explicit size honored expect(by.card.h).toBe(120); + expect(by.card.external).toBe(true); // external rides through the layout expect(by.plain.w).toBe(nodeWidth('plain')); // no w → label-based width expect(by.plain.h).toBe(30); // no h → NODE_H }); diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index 41c2f66..04e1e34 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -195,6 +195,21 @@ describe('schema lineage graph', () => { expect(actions.insertCreate).toHaveBeenCalledWith('lin.mv'); }); + it('clicking an external (ext:) leaf in the inline graph is a no-op (no SHOW CREATE)', () => { + const actions = { insertCreate: vi.fn() }; + const g = { + focus: { kind: 'db', db: 'lin' }, + nodes: [ + { id: 'lin.d', label: 'd', kind: 'dictionary', db: 'lin', name: 'd' }, + { id: 'ext:HTTP', label: 'HTTP', kind: 'external', db: '', name: 'HTTP' }, + ], + edges: [{ from: 'ext:HTTP', to: 'lin.d', kind: 'dict' }], + }; + const el = renderSchemaGraph({ document, Dagre: dagre, actions }, { schemaGraph: g }); + el.querySelector('rect.eg-node--external').dispatchEvent(new Event('click', { bubbles: true })); + expect(actions.insertCreate).not.toHaveBeenCalled(); + }); + it('clicking a node with a non-bare name backtick-quotes the SHOW CREATE target', () => { const actions = { insertCreate: vi.fn() }; const g = { focus: { kind: 'db', db: 'target_all' }, nodes: [{ id: 'target_all.a-b.parquet', label: 'a-b.parquet', kind: 'table', db: 'target_all', name: 'a-b.parquet' }], edges: [] }; @@ -242,9 +257,41 @@ describe('schema lineage graph', () => { expect(document.body.contains(overlay)).toBe(true); expect(overlay.querySelector('svg.explain-graph')).not.toBeNull(); expect(overlay.querySelector('.schema-graph-legend')).not.toBeNull(); + expect(overlay.querySelector('.graph-overlay-note')).toBeNull(); // not truncated → no banner overlay.querySelector('.graph-overlay-close').dispatchEvent(new Event('click', { bubbles: true })); expect(document.body.contains(overlay)).toBe(false); }); + + it('clicking a fullscreen node opens the detail pane (openNodeDetail), not insertCreate', () => { + const actions = { openNodeDetail: vi.fn(), insertCreate: vi.fn() }; + const overlay = openSchemaFullscreen({ document, Dagre: dagre, actions }, GRAPH); + overlay.querySelector('g.eg-card').dispatchEvent(new Event('click', { bubbles: true })); + expect(actions.openNodeDetail).toHaveBeenCalledTimes(1); + expect(actions.insertCreate).not.toHaveBeenCalled(); + }); + + it('shows a truncation banner when the graph is truncated', () => { + const overlay = openSchemaFullscreen({ document, Dagre: dagre, actions: { openNodeDetail: vi.fn() } }, { ...GRAPH, truncated: true }); + const note = overlay.querySelector('.graph-overlay-note'); + expect(note).not.toBeNull(); + expect(note.textContent).toMatch(/truncated/i); + }); + + it('clicking an external (ext:) leaf in the fullscreen graph is a no-op (no detail pane)', () => { + const actions = { openNodeDetail: vi.fn() }; + const g = { + focus: { kind: 'db', db: 'lin' }, + nodes: [ + { id: 'lin.d', label: 'd', kind: 'dictionary', db: 'lin', name: 'd' }, + { id: 'ext:HTTP', label: 'HTTP', kind: 'external', db: '', name: 'HTTP', external: true }, + ], + edges: [{ from: 'ext:HTTP', to: 'lin.d', kind: 'dict' }], + }; + const overlay = openSchemaFullscreen({ document, Dagre: dagre, actions }, g); + const extCard = [...overlay.querySelectorAll('g.eg-card')].find((c) => c.querySelector('rect.eg-node--external')); + extCard.dispatchEvent(new Event('click', { bubbles: true })); + expect(actions.openNodeDetail).not.toHaveBeenCalled(); + }); }); describe('buildRichSchemaSvg (rich cards)', () => { @@ -310,4 +357,17 @@ describe('buildRichSchemaSvg (rich cards)', () => { expect(built.nodeCount).toBe(0); expect(built.svg.querySelectorAll('g.eg-card')).toHaveLength(0); }); + + it('marks external (other-db) nodes with eg-node--ext, leaving local nodes plain', () => { + const g = { + nodes: [ + { id: 'a.t', label: 't', kind: 'table', db: 'a', name: 't', external: false }, + { id: 'b.u', label: 'u', kind: 'mv', db: 'b', name: 'u', external: true }, + ], + edges: [{ from: 'a.t', to: 'b.u', kind: 'feeds' }], + }; + const built = buildRichSchemaSvg(g, dagre); + expect(built.svg.querySelectorAll('rect.eg-node--ext')).toHaveLength(1); + expect(built.svg.querySelector('rect.eg-node--ext').getAttribute('class')).toContain('eg-node--mv'); + }); }); diff --git a/tests/unit/schema-detail.test.js b/tests/unit/schema-detail.test.js new file mode 100644 index 0000000..aa9aacc --- /dev/null +++ b/tests/unit/schema-detail.test.js @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { openDetailPane } from '../../src/ui/schema-detail.js'; + +afterEach(() => { document.body.innerHTML = ''; }); + +// openDetailPane mounts into the live overlay panel — create a stand-in. +function mountPanel() { + const p = document.createElement('div'); + p.className = 'graph-overlay-panel'; + p.getBoundingClientRect = () => ({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600 }); + document.body.appendChild(p); + return p; +} +const APP = (over = {}) => ({ document, actions: { insertCreate: vi.fn() }, ...over }); +const NODE = { id: 'a.t', db: 'a', name: 't', kind: 'table' }; +const DETAIL = { + columns: [ + { name: 'id', type: 'UInt64', codec: 'LZ4', is_in_primary_key: 1, compressed: 1024, uncompressed: 4096 }, + { name: 'v', type: 'String', compressed: 50, uncompressed: 100 }, + ], + partitions: [{ partition: '2024', parts: 3, rows: 100, bytes: 5000 }], + ddl: 'CREATE TABLE a.t (id UInt64) ENGINE = MergeTree', +}; + +describe('openDetailPane', () => { + it('mounts a pane with columns + key roles, partitions, and DDL', () => { + mountPanel(); + const pane = openDetailPane(APP(), NODE, DETAIL); + expect(pane).not.toBeNull(); + expect(document.querySelector('.schema-detail')).not.toBeNull(); + const heads = [...pane.querySelectorAll('h4')].map((e) => e.textContent); + expect(heads).toContain('Columns (2)'); + expect(heads).toContain('Partitions (1)'); + expect(heads).toContain('DDL'); + // first column carries a PK role; second has none + const roleCells = [...pane.querySelectorAll('.schema-detail-roles')].map((e) => e.textContent); + expect(roleCells[0]).toBe('PK'); + expect(roleCells[1]).toBe(''); + expect(pane.querySelector('.schema-detail-ddl').textContent).toContain('CREATE TABLE'); + }); + + it('"Insert SHOW CREATE" runs insertCreate with the qualified ident', () => { + mountPanel(); + const app = APP(); + const pane = openDetailPane(app, NODE, DETAIL); + const btn = [...pane.querySelectorAll('button')].find((b) => /Insert SHOW CREATE/.test(b.textContent)); + btn.dispatchEvent(new Event('click', { bubbles: true })); + expect(app.actions.insertCreate).toHaveBeenCalledWith('a.t'); + }); + + it('the ✕ button removes just the pane', () => { + mountPanel(); + const pane = openDetailPane(APP(), NODE, DETAIL); + pane.querySelector('.schema-detail-close').dispatchEvent(new Event('click', { bubbles: true })); + expect(document.querySelector('.schema-detail')).toBeNull(); + }); + + it('dragging the handle resizes the pane, clamped to both bounds', () => { + const panel = mountPanel(); + const pane = openDetailPane(APP(), NODE, DETAIL); + const handle = pane.querySelector('.schema-detail-handle'); + handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + document.dispatchEvent(new MouseEvent('mousemove', { clientY: 0, bubbles: true })); // tall → clamp to max + expect(pane.style.flexBasis).toBe('500px'); // height(600) - TOP_MARGIN(100) + document.dispatchEvent(new MouseEvent('mousemove', { clientY: 590, bubbles: true })); // short → clamp to min + expect(pane.style.flexBasis).toBe('90px'); // MIN_H + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + // after mouseup the move listener is gone — a stray move does nothing + document.dispatchEvent(new MouseEvent('mousemove', { clientY: 100, bubbles: true })); + expect(pane.style.flexBasis).toBe('90px'); + void panel; + }); + + it('re-opening for another node replaces the pane, not stacks it', () => { + mountPanel(); + openDetailPane(APP(), NODE, DETAIL); + openDetailPane(APP(), { id: 'a.u', db: 'a', name: 'u' }, { columns: [], partitions: [], ddl: '' }); + expect(document.querySelectorAll('.schema-detail')).toHaveLength(1); + }); + + it('omits the partitions and DDL sections when absent; tolerates a kind-less node', () => { + mountPanel(); + const pane = openDetailPane(APP(), { id: 'a.v', db: 'a', name: 'v' }, { columns: [], partitions: [], ddl: '' }); + const heads = [...pane.querySelectorAll('h4')].map((e) => e.textContent); + expect(heads).toEqual(['Columns (0)']); // no Partitions / DDL headings + expect(pane.querySelector('.schema-detail-ddl')).toBeNull(); + expect(pane.querySelector('.schema-detail-kind').textContent).toBe('table'); // kind fallback + }); + + it('returns null when no overlay is open', () => { + expect(openDetailPane(APP(), NODE, DETAIL)).toBeNull(); + }); +}); diff --git a/tests/unit/schema-graph.test.js b/tests/unit/schema-graph.test.js index 0745a78..cc59fd2 100644 --- a/tests/unit/schema-graph.test.js +++ b/tests/unit/schema-graph.test.js @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { objectKind, parseAstTables, parseMvTarget, parseDictSource, parseEngineRef, buildSchemaGraph, + expandLineage, externalDbs, } from '../../src/core/schema-graph.js'; // Fixtures are the *actual* outputs captured from ClickHouse 26.5.1 (Docker) for a @@ -265,4 +266,69 @@ describe('buildSchemaGraph', () => { expect(buildSchemaGraph(null, { kind: 'db', db: 'x' })).toEqual({ nodes: [], edges: [] }); expect(buildSchemaGraph({}, null)).toEqual({ nodes: [], edges: [] }); }); + + it('keeps a dotted DATABASE name intact as node.db (not the first segment)', () => { + const rows = { tables: [ + T('my.db', 'tbl', 'MergeTree', { dependencies_database: ['my.db'], dependencies_table: ['mv'] }), + T('my.db', 'mv', 'MaterializedView'), + ], dictionaries: [] }; + const tbl = buildSchemaGraph(rows, { kind: 'db', db: 'my.db' }).nodes.find((n) => n.id === 'my.db.tbl'); + expect(tbl.db).toBe('my.db'); // not 'my' + expect(tbl.name).toBe('tbl'); // not 'db.tbl' + }); +}); + +describe('externalDbs', () => { + it('lists referenced dbs not yet loaded, skipping ext: leaves', () => { + const g = { nodes: [{ id: 'a.x', db: 'a' }, { id: 'b.y', db: 'b' }, { id: 'a.z', db: 'a' }, { id: 'ext:HTTP', db: '' }] }; + expect(externalDbs(g, ['a']).sort()).toEqual(['b']); + expect(externalDbs(g, []).sort()).toEqual(['a', 'b']); + expect(externalDbs(null, ['a'])).toEqual([]); + }); +}); + +describe('expandLineage', () => { + const G = { + nodes: [ + { id: 'a.t1', db: 'a' }, { id: 'a.t2', db: 'a' }, + { id: 'b.u', db: 'b' }, { id: 'c.w', db: 'c' }, { id: 'd.iso', db: 'd' }, + ], + edges: [ + { from: 'a.t1', to: 'b.u', kind: 'feeds' }, // a → b (1 hop) + { from: 'b.u', to: 'c.w', kind: 'writes' }, // b → c (transitive) + ], + }; + it('seeds the whole seed db and walks transitively across db boundaries, tagging external', () => { + const out = expandLineage(G, 'a'); + const ids = new Set(out.nodes.map((n) => n.id)); + expect(ids.has('a.t1')).toBe(true); + expect(ids.has('a.t2')).toBe(true); // seeded even though isolated within 'a' + expect(ids.has('b.u')).toBe(true); // 1 hop + expect(ids.has('c.w')).toBe(true); // transitive 2 hops + expect(ids.has('d.iso')).toBe(false); // unreached + expect(out.nodes.find((n) => n.id === 'a.t1').external).toBe(false); + expect(out.nodes.find((n) => n.id === 'b.u').external).toBe(true); + expect(out.edges).toHaveLength(2); + expect(out.truncated).toBe(false); + }); + it('walks both directions — a reverse edge into the seed pulls its source in', () => { + const g = { nodes: [{ id: 'a.t', db: 'a' }, { id: 'b.s', db: 'b' }], edges: [{ from: 'b.s', to: 'a.t', kind: 'feeds' }] }; + expect(new Set(expandLineage(g, 'a').nodes.map((n) => n.id)).has('b.s')).toBe(true); + }); + it('caps the cross-db expansion and flags truncated', () => { + const nodes = [{ id: 'a.seed', db: 'a' }]; + const edges = []; + let prev = 'a.seed'; + for (let i = 0; i < 10; i++) { const id = 'x.n' + i; nodes.push({ id, db: 'x' }); edges.push({ from: prev, to: id, kind: 'feeds' }); prev = id; } + const out = expandLineage({ nodes, edges }, 'a', { cap: 5 }); + expect(out.truncated).toBe(true); + expect(out.nodes.length).toBeLessThanOrEqual(5); + }); + it('ignores edges whose endpoints are not real nodes, and tolerates a null graph', () => { + const g = { nodes: [{ id: 'a.t', db: 'a' }], edges: [{ from: 'a.t', to: 'ghost', kind: 'feeds' }] }; + const out = expandLineage(g, 'a'); + expect(out.nodes.map((n) => n.id)).toEqual(['a.t']); + expect(out.edges).toEqual([]); + expect(expandLineage(null, 'a')).toEqual({ nodes: [], edges: [], truncated: false }); + }); });