diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e018c..b0b9b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,32 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Added +- **Click a closed database row to draw its schema graph** (#124): expanding a + collapsed db in the tree now also draws its lineage in the bottom drawer, the + same as dragging it — collapsing again doesn't re-fetch or re-draw. + Drag-to-drawer is unchanged. On a schema with 50+ view/MV objects needing + `EXPLAIN AST`, the inline graph now draws **progressively**: the free edges + (dependencies/target/engine-arg/dictionary — no extra round trip) paint + immediately, then a single second layout merges in the view/MV source edges + once `EXPLAIN AST` settles, with a "resolving N/M…" toolbar readout. Below + that threshold the fetch is fast enough that a visible first paint would just + be flicker, so it still draws in one step. The loading placeholder / toolbar + now has a working **Cancel**: it aborts the in-flight fetch and either keeps + the already-drawn free-edges graph (marked partial) or falls back to the + empty-results placeholder, whichever has something to show. + +### Fixed +- The inline schema-lineage graph had a stale-write race (same class as #97): + running or Explaining a query — or dragging/clicking a second db/table — + while a lineage fetch was still in flight could let the stale fetch's + resolution land on the tab's *new* result once it finally settled, silently + showing an old graph instead of the actual query output. A request-identity + guard now drops any write from a superseded fetch. Separately, an abort + during the best-effort `system.dictionaries` read inside the lineage fetch is + now correctly propagated as a cancellation instead of silently degrading to + "no dictionaries, continue". + ## [0.2.0] - 2026-07-01 ### Added diff --git a/src/net/ch-client.js b/src/net/ch-client.js index 0e9b49d..6d271e1 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -77,9 +77,9 @@ export async function authedFetch(ctx, url, sql, signal) { } } -/** Run a query and return parsed JSON (FORMAT JSON). Throws on CH error. */ -export async function queryJson(ctx, sql) { - const resp = await authedFetch(ctx, chUrl(ctx.origin, { format: 'JSON' }), sql); +/** Run a query and return parsed JSON (FORMAT JSON). Throws on CH error. `signal` (optional) aborts the request. */ +export async function queryJson(ctx, sql, signal) { + const resp = await authedFetch(ctx, chUrl(ctx.origin, { format: 'JSON' }), sql, signal); if (!resp.ok) throw new Error(parseExceptionText(await resp.text())); return resp.json(); } @@ -140,6 +140,13 @@ export async function loadSchema(ctx) { return [...byDb.entries()].map(([db, v]) => ({ db, comment: v.comment, expanded: false, tables: v.tables })); } +// Below this many view/MV objects needing `EXPLAIN AST`, a visible free-edges- +// first paint is just flicker — the fan-out settles fast enough on a small +// schema that nobody perceives two draws, only a redraw. `loadSchemaLineage` +// skips `onBase`/`onProgress` entirely below the threshold so the caller does +// one single, final draw instead (matching the pre-progressive-draw behavior). +export const AST_PROGRESSIVE_THRESHOLD = 50; + /** * Load object-lineage rows for a database: the `system.tables` columns the graph * builder needs + `system.dictionaries` sources, and (for views/MVs) the @@ -147,8 +154,21 @@ export async function loadSchema(ctx) { * `target_table` are intentionally not selected — they're a ClickHouse-Cloud-only * column (absent on OSS/Altinity builds), so the MV target is parsed from * `create_table_query` in `buildSchemaGraph`. Returns `{ tables, dictionaries }`. + * + * `opts.signal` cancels every underlying request (including the best-effort + * `system.dictionaries` read — an abort there propagates as a rejection of the + * whole call, not a silent "no dictionaries"; see `tryQueryData`). + * `opts.onBase({tables, dictionaries})` fires as soon as the free data (no + * `EXPLAIN AST` needed) is known — the caller can draw a first-pass graph from + * it (issue #124's progressive draw) before the per-view/MV source resolution + * below even starts. `opts.onProgress(done, total)` fires as each `EXPLAIN AST` + * settles (success or best-effort failure), for a "resolving N/M…" indicator. + * Both are skipped when fewer than `opts.progressiveThreshold` (default + * `AST_PROGRESSIVE_THRESHOLD`) objects need `EXPLAIN AST` — see the constant's + * comment. */ -export async function loadSchemaLineage(ctx, focus) { +export async function loadSchemaLineage(ctx, focus, opts = {}) { + const { signal, onBase, onProgress, progressiveThreshold = AST_PROGRESSIVE_THRESHOLD } = opts; const db = (focus && focus.db) || ''; const cols = 'database, name, engine, engine_full, create_table_query, as_select, ' + 'toString(uuid) AS uuid, dependencies_database, dependencies_table, ' @@ -156,18 +176,29 @@ export async function loadSchemaLineage(ctx, focus) { // Card metadata (ignored by the inline graph; used by the rich fullscreen cards). + 'toUInt64(ifNull(total_rows, 0)) AS total_rows, toUInt64(ifNull(total_bytes, 0)) AS total_bytes, ' + 'partition_key, sorting_key, primary_key, sampling_key'; - const tablesJson = await queryJson(ctx, `SELECT ${cols} FROM system.tables WHERE database = ${sqlString(db)} ORDER BY startsWith(name, '_'), name`); + const tablesJson = await queryJson(ctx, `SELECT ${cols} FROM system.tables WHERE database = ${sqlString(db)} ORDER BY startsWith(name, '_'), name`, signal); const tables = tablesJson.data || []; // Best-effort: a denied/missing system.dictionaries (low-priv users lack - // SELECT on it) must degrade to no dictionary edges, never abort the graph. - const dictionaries = await tryQueryData(ctx, `SELECT database, name, source FROM system.dictionaries WHERE database = ${sqlString(db)}`) || []; + // SELECT on it) must degrade to no dictionary edges, never abort the graph — + // but a genuine cancellation must still propagate (tryQueryData rethrows it). + const dictionaries = await tryQueryData(ctx, `SELECT database, name, source FROM system.dictionaries WHERE database = ${sqlString(db)}`, signal) || []; // Robust source extraction for views/MVs: let ClickHouse parse the SELECT. - await Promise.all(tables.map(async (t) => { - if (!t.as_select || (t.engine !== 'View' && t.engine !== 'MaterializedView')) return; + const astTargets = tables.filter((t) => t.as_select && (t.engine === 'View' || t.engine === 'MaterializedView')); + const total = astTargets.length; + const progressive = total >= progressiveThreshold; + if (progressive && onBase) onBase({ tables, dictionaries }); + let done = 0; + await Promise.all(astTargets.map(async (t) => { try { - const ast = await queryJson(ctx, 'EXPLAIN AST ' + t.as_select); + const ast = await queryJson(ctx, 'EXPLAIN AST ' + t.as_select, signal); t.astTables = parseAstTables((ast.data || []).map((r) => r.explain).join('\n')); - } catch { /* best-effort — leave astTables undefined */ } + } catch (e) { + if (signal && signal.aborted && e && e.name === 'AbortError') throw e; + /* best-effort — leave astTables undefined */ + } finally { + done++; + if (progressive && onProgress) onProgress(done, total); + } })); return { tables, dictionaries }; } @@ -288,14 +319,23 @@ export async function loadTableDetail(ctx, db, table) { }; } -// 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. -async function tryQueryData(ctx, sql) { +// Run a query for its `data` rows, returning null on ANY error EXCEPT a +// cancellation of a caller-supplied signal. Editor reference data / schema- +// lineage best-effort reads are meant to degrade gracefully on a missing +// system table or a denied SELECT — but when the caller passed a `signal` and +// aborted it, that means the caller's whole operation was cancelled, not that +// this particular sub-query failed, so it must propagate rather than be +// swallowed into "no data, continue" (#124). Gated on `signal.aborted` +// (not just the error's name) so a caller that never passed a signal — every +// site except `loadSchemaLineage` — keeps today's unconditional swallow, even +// if the underlying fetch happens to throw an AbortError-shaped error for some +// unrelated reason. +async function tryQueryData(ctx, sql, signal) { try { - const json = await queryJson(ctx, sql); + const json = await queryJson(ctx, sql, signal); return json.data || []; - } catch { + } catch (e) { + if (signal && signal.aborted && e && e.name === 'AbortError') throw e; return null; } } diff --git a/src/state.js b/src/state.js index 648908d..f4b2c64 100644 --- a/src/state.js +++ b/src/state.js @@ -98,6 +98,11 @@ export function createState(read = { loadJSON, loadStr }) { // effects; `resultView` is the active Table/JSON/Chart tab. Via `.value`. running: signal(false), abortController: null, + // In-flight schema-lineage fetch (issue #124's inline drawer graph) — its own + // AbortController, separate from `abortController` (run/script) and the + // export controllers, since a graph fetch isn't gated by `running` and a + // second click/drag must be able to supersede an in-flight one. + schemaGraphAbortController: null, resultView: signal('table'), // True while a streaming Export (issue #87) is in flight — separate from // `running` (the grid run) so an export and a grid run never clobber each diff --git a/src/ui/app.js b/src/ui/app.js index 3a1fc6a..a993f0e 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -510,6 +510,7 @@ export function createApp(env = {}) { if (!srcSql.trim()) return; await ensureConfig(); if (!(await getToken())) { chCtx.onSignedOut(); return; } + cancelSchemaGraph(); // a Run/Explain takes over the result — don't leave a lineage fetch running // EXPLAIN-view bookkeeping: the Explain button (opts.explain) forces any query // into EXPLAIN-view mode; a normal Run clears that; switching an EXPLAIN tab @@ -632,6 +633,7 @@ export function createApp(env = {}) { if (app.state.running.value) return; await ensureConfig(); if (!(await getToken())) { chCtx.onSignedOut(); return; } + cancelSchemaGraph(); // a script run takes over the result — don't leave a lineage fetch running app.state.forceExplain = false; const tab = app.activeTab(); const t0 = now(); @@ -833,26 +835,89 @@ export function createApp(env = {}) { } } - // Render the ClickHouse object-lineage graph for a dropped database/table into - // the data pane (queries system.* + EXPLAIN AST; the editor SQL is untouched). + // Abort any in-flight schema-lineage fetch. Called both as a manual Cancel + // (clearResult: true — the user asked to stop) and automatically whenever a + // new operation takes over the drawer (a fresh graph request, or Run/Explain + // replacing the tab's result outright) — in the automatic case the caller + // overwrites tab.result itself right after, so aborting the network request + // is all that's needed there (the identity guard in showSchemaGraph makes + // this belt-and-suspenders, not load-bearing, for correctness). + // + // With clearResult, the visible result depends on how far the fetch got: if + // Phase A (the free-edges graph) had already drawn, keep it on screen marked + // `partial` (its view/MV source edges may be incomplete); otherwise there's + // nothing worth keeping, so drop back to the normal empty-results placeholder. + function cancelSchemaGraph({ clearResult = false } = {}) { + if (app.state.schemaGraphAbortController) app.state.schemaGraphAbortController.abort(); + app.state.schemaGraphAbortController = null; + if (!clearResult) return; + const tab = app.activeTab(); + const sg = tab.result && tab.result.schemaGraph; + if (!sg || !sg.loading) return; + if (sg.nodes && sg.nodes.length) { + sg.loading = false; + sg.partial = true; + } else { + tab.result = null; + } + renderResults(app); + } + + // Render the ClickHouse object-lineage graph for a dropped/clicked + // database/table into the data pane (queries system.* + EXPLAIN AST; the + // editor SQL is untouched). Two-phase on a large schema (#124): draws as soon + // as the free edges (dependencies/target/engine-arg/dictionary) are known, + // then a single second layout merges in view/MV source edges once EXPLAIN AST + // settles — so the pane isn't blank for the whole round trip. Below + // AST_PROGRESSIVE_THRESHOLD view/MV objects, loadSchemaLineage skips straight + // to one draw instead (onBase/onProgress never fire) — a visible first paint + // is just flicker when the whole fetch settles almost as fast anyway. async function showSchemaGraph(focus) { if (!focus || !focus.db) return; await ensureConfig(); if (!(await getToken())) { chCtx.onSignedOut(); return; } + cancelSchemaGraph(); // a new click/drag replaces whatever graph was in flight const tab = app.activeTab(); - // Show a loading placeholder first — the lineage queries (system.* + an - // EXPLAIN AST per view/MV) can take a moment on a large database. + // Show a loading placeholder first — even Phase A (system.tables + + // system.dictionaries) is a network round trip. tab.result = newResult('Table'); tab.result.schemaGraph = { focus, loading: true, nodes: [], edges: [] }; + // `result` is the stale-write guard (mirrors #97's identity-guard shape): + // captured once, checked before every later write, so a Run/Explain or a + // second graph request that replaces tab.result mid-fetch can never have + // this call's (Phase A or Phase B) result land on the new tab.result. + const result = tab.result; renderResults(app); + const controller = new AbortController(); + app.state.schemaGraphAbortController = controller; try { - const rows = await ch.loadSchemaLineage(chCtx, focus); - const g = buildSchemaGraph(rows, focus); + const lineage = await ch.loadSchemaLineage(chCtx, focus, { + signal: controller.signal, + onBase: (base) => { + if (tab.result !== result) return; // superseded before Phase A even landed + const g = buildSchemaGraph(base, focus); + result.schemaGraph = { focus, nodes: g.nodes, edges: g.edges, tableCount: (base.tables || []).length, loading: true }; + renderResults(app); + }, + onProgress: (done, total) => { + if (tab.result !== result || !result.schemaGraph || !result.schemaGraph.loading) return; + result.schemaGraph.progress = { done, total }; + renderResults(app); + }, + }); + if (tab.result !== result) return; // superseded while Phase B was resolving + const g = buildSchemaGraph(lineage, focus); // tableCount lets the renderer explain an empty result ("N tables, none linked"). - tab.result.schemaGraph = { focus, nodes: g.nodes, edges: g.edges, tableCount: (rows.tables || []).length }; + result.schemaGraph = { focus, nodes: g.nodes, edges: g.edges, tableCount: (lineage.tables || []).length }; } catch (e) { + // AbortError means cancelSchemaGraph() already left the pane in a clean + // state (partial graph or the empty placeholder) — nothing more to do. + if (e.name === 'AbortError') return; + if (tab.result !== result) return; tab.result = newResult('Table'); tab.result.error = String((e && e.message) || e); + } finally { + if (app.state.schemaGraphAbortController === controller) app.state.schemaGraphAbortController = null; } renderResults(app); } @@ -1460,6 +1525,7 @@ export function createApp(env = {}) { setExplainView, setResultRowLimit, showSchemaGraph, + cancelSchemaGraph, expandSchemaGraph, openNodeDetail, insertCreate, diff --git a/src/ui/placeholder.js b/src/ui/placeholder.js index 3627c1d..559adf6 100644 --- a/src/ui/placeholder.js +++ b/src/ui/placeholder.js @@ -6,8 +6,13 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; -export function loadingPlaceholder(msg) { +// `onCancel`, when given, adds a Cancel button (mirrors the `.exp-cancel` +// button in results.js's export progress banner) — used by the schema-graph +// drawer's pre-Phase-A loading state (#124), where there's nothing on screen +// yet to keep the graph's own toolbar Cancel visible instead. +export function loadingPlaceholder(msg, onCancel) { return h('div', { class: 'placeholder starting' }, h('span', { class: 'spin' }, Icon.spinner()), - h('div', null, msg)); + h('div', null, msg), + onCancel ? h('button', { class: 'exp-cancel', title: 'Cancel', onclick: onCancel }, Icon.close(), h('span', null, 'Cancel')) : null); } diff --git a/src/ui/results.js b/src/ui/results.js index 34792ed..7ea93f2 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -158,9 +158,13 @@ export function renderResults(app) { } else if (r.error) { inner.appendChild(h('div', { class: 'results-error' }, r.error)); } else if (r.schemaGraph) { - inner.appendChild(r.schemaGraph.loading - ? loadingPlaceholder('Loading data flow…') - : renderSchemaGraph(app, r)); + // Progressive draw (#124): once Phase A resolves (tableCount known) the + // real graph draws even while Phase B (per-view/MV EXPLAIN AST) is still + // loading — only the pre-Phase-A window (nothing known yet, always still + // loading by construction) shows the cancellable placeholder. + inner.appendChild(r.schemaGraph.tableCount != null + ? renderSchemaGraph(app, r) + : loadingPlaceholder('Loading data flow…', () => app.actions.cancelSchemaGraph({ clearResult: true }))); } else if (r.explainView) { inner.appendChild(renderExplainView(app, r)); } else if (r.rawText != null) { @@ -441,16 +445,32 @@ function buildToolbar(app, r) { } if (r && r.schemaGraph) { // Schema-lineage view: a title + Expand (fullscreen); no view-switcher / stats. - const f = r.schemaGraph.focus || {}; + const sg = r.schemaGraph; + const f = sg.focus || {}; const title = f.kind === 'table' ? f.db + '.' + f.table : f.db; toolbar.appendChild(h('div', { class: 'result-view-tabs' }, h('span', { class: 'res-graph-title' }, 'Schema · ' + title))); + if (sg.partial) toolbar.appendChild(h('span', { class: 'cancelled-badge' }, 'Cancelled · view/MV sources may be incomplete')); toolbar.appendChild(h('div', { style: { flex: '1' } })); - // Expand is meaningless until the graph has loaded, or when there's nothing - // to draw (no connected objects → the pane shows a message, not a graph). - if (!r.schemaGraph.loading && r.schemaGraph.nodes.length) { + if (sg.loading && sg.tableCount != null) { + // Phase A has already drawn the graph into the body; Phase B (per-view/MV + // EXPLAIN AST) is still resolving — a live progress readout + Cancel, same + // shape as the run-in-progress stat/cancel block below. Pre-Phase-A (no + // graph in the body yet) the loading placeholder carries its own Cancel + // instead, so this doesn't duplicate it. + if (sg.progress) { + toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic spin' }, Icon.spinner()), + h('span', { class: 'v' }, 'resolving ' + sg.progress.done + '/' + sg.progress.total + ' view sources…'))); + } + toolbar.appendChild(h('button', { + class: 'res-act cancel-act', title: 'Cancel schema graph', + onclick: () => app.actions.cancelSchemaGraph({ clearResult: true }), + }, Icon.close(), h('span', null, 'Cancel'))); + } else if (!sg.loading && sg.nodes.length) { + // Expand is meaningless when there's nothing to draw (no connected + // objects → the pane shows a message, not a graph). toolbar.appendChild(h('button', { class: 'res-act', title: 'Open the graph fullscreen with rich cards (pan & zoom)', - onclick: () => app.actions.expandSchemaGraph(r.schemaGraph.focus), + onclick: () => app.actions.expandSchemaGraph(sg.focus), }, Icon.expand(), h('span', null, 'Expand'))); } return toolbar; diff --git a/src/ui/schema.js b/src/ui/schema.js index 7ba215e..78036dc 100644 --- a/src/ui/schema.js +++ b/src/ui/schema.js @@ -122,6 +122,11 @@ export function renderSchema(app) { if (isDoubleClick(app, dbKey)) { app.actions.insertAtCursor(qdb); return; } state.expanded.value = toggleKey(state.expanded.value, dbKey); flipChevron(list, dbKey, dbOpen); + // Only the collapsed → expanded transition also draws the schema graph + // (issue #124) — collapsing an open db must not re-fetch/re-draw/steal + // focus back to the drawer, and re-clicking an already-open db is a no-op + // above (dbOpen unchanged), so this only fires on a genuine expand. + if (!dbOpen) app.actions.showSchemaGraph({ kind: 'db', db: db.db }); }, ...lineageDrag(qdb, { kind: 'db', db: db.db }), }, diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index a2ec4da..36b2630 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -83,6 +83,7 @@ export function makeApp(over = {}) { setExplainView: vi.fn(), setResultRowLimit: vi.fn(), showSchemaGraph: vi.fn(), + cancelSchemaGraph: vi.fn(), expandSchemaGraph: vi.fn(), openNodeDetail: vi.fn(), insertCreate: vi.fn(), diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index e31c543..e22addd 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { webcrypto } from 'node:crypto'; import dagre from '@dagrejs/dagre'; import { createApp } from '../../src/ui/app.js'; +import { AST_PROGRESSIVE_THRESHOLD } from '../../src/net/ch-client.js'; function jwt(payload) { const b = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); @@ -2503,6 +2504,229 @@ describe('schema lineage graph (drag a db/table onto the results pane)', () => { expect(app.activeTab().result.schemaGraph.savedPositions).toBe(positions); // same map reused document.body.querySelector('.graph-overlay').remove(); }); + + // #124 — stale-write race, cancellation, progressive draw. + // Local variant of makeFetch that forwards `init` to a function route, so a + // route can be signal-aware (reject when the request's own AbortController + // fires) — the shared makeFetch above only ever calls `r()` with no args. + function makeSignalFetch(routes) { + return vi.fn(async (url, init) => { + const sql = init && init.body; + for (const [test, r] of routes) if (test(url, sql)) return typeof r === 'function' ? r(url, init) : r; + return resp({ json: { data: [] } }); + }); + } + const hangsUntilAborted = (url, init) => new Promise((resolve, reject) => { + const abort = () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); }; + // Real fetch rejects immediately for an already-aborted signal — mirror that + // (a bare addEventListener would miss an abort that fired before this request + // was even dispatched, since the event has already come and gone). + if (init.signal.aborted) abort(); + else init.signal.addEventListener('abort', abort); + }); + // showSchemaGraph awaits ensureConfig()/getToken() before setting the initial + // placeholder — poll (bounded, no real timer) rather than guessing a fixed + // microtask-tick count. + async function untilResult(app) { + for (let i = 0; i < 50 && app.activeTab().result == null; i++) await Promise.resolve(); + } + // showSchemaGraph's Phase-A/Phase-B split only engages at/above + // AST_PROGRESSIVE_THRESHOLD view/MV objects (#124 — below it, a single-step + // draw avoids flicker on small schemas) — pad a fixture's table list with + // throwaway views so a specific scenario's real object(s) can still exercise + // the two-phase path under the real (non-test-overridden) default. + // 'SELECT pad…' (not just 'SELECT …') so a route matching a specific real + // object's exact EXPLAIN AST text (e.g. /EXPLAIN AST SELECT 1/) never + // accidentally also matches a padding row's. + const paddingViews = (n) => Array.from({ length: n }, (_, i) => ( + { database: 'lin', name: 'pad' + i, engine: 'View', as_select: 'SELECT pad' + i } + )); + + it('run() while a lineage fetch is in flight does not corrupt the query result (regression for #124)', async () => { + let resolveTables; + const tablesPending = new Promise((r) => { resolveTables = r; }); + const { app } = appForRun([ + [(u, sql) => /system\.tables/.test(sql), () => tablesPending], + [(u, sql) => /SELECT 1/.test(sql), resp({ body: streamBody(['{"meta":[{"name":"a","type":"UInt8"}]}\n', '{"row":{"a":"1"}}\n']) })], + ]); + const graphPromise = app.actions.showSchemaGraph({ kind: 'db', db: 'lin' }); // hangs on system.tables + await untilResult(app); // let the pre-Phase-A loading placeholder land + expect(app.activeTab().result.schemaGraph.loading).toBe(true); + app.activeTab().sql = 'SELECT 1'; + await app.actions.run(); + expect(app.activeTab().result.rows).toEqual([['1']]); + expect(app.activeTab().result.schemaGraph).toBeUndefined(); + // the stale lineage fetch resolving afterward must not clobber run()'s result + resolveTables(resp({ json: { data: [] } })); + await graphPromise; + expect(app.activeTab().result.rows).toEqual([['1']]); + expect(app.activeTab().result.schemaGraph).toBeUndefined(); + }); + + it('runScript() while a lineage fetch is in flight does not corrupt the query result (regression for #124)', async () => { + let resolveTables; + const tablesPending = new Promise((r) => { resolveTables = r; }); + const { app } = appForRun([ + [(u, sql) => /system\.tables/.test(sql), () => tablesPending], + [(u, sql) => /SELECT 1/.test(sql), resp({ text: JSON.stringify({ meta: [{ name: 'a', type: 'UInt8' }], data: [['1']] }) })], + ]); + const graphPromise = app.actions.showSchemaGraph({ kind: 'db', db: 'lin' }); + await untilResult(app); + expect(app.activeTab().result.schemaGraph.loading).toBe(true); + app.activeTab().sql = 'SELECT 1;\nSELECT 1'; // >1 statement → runScript path + await app.actions.run(); + expect(app.activeTab().result.script).toBeDefined(); + expect(app.activeTab().result.schemaGraph).toBeUndefined(); + resolveTables(resp({ json: { data: [] } })); + await graphPromise; + expect(app.activeTab().result.script).toBeDefined(); + expect(app.activeTab().result.schemaGraph).toBeUndefined(); + }); + + it('a second showSchemaGraph before the first resolves shows the second graph only — last-triggered wins, not last-resolved', async () => { + let resolveFirst; + const firstPending = new Promise((r) => { resolveFirst = r; }); + const { app } = appForRun([ + [(u, sql) => /system\.tables/.test(sql) && /database = 'a'/.test(sql), () => firstPending], + [(u, sql) => /system\.tables/.test(sql) && /database = 'b'/.test(sql), resp({ json: { data: [ + { database: 'b', name: 't', engine: 'MergeTree', as_select: '' }, + ] } })], + ]); + const first = app.actions.showSchemaGraph({ kind: 'db', db: 'a' }); + await untilResult(app); + const second = app.actions.showSchemaGraph({ kind: 'db', db: 'b' }); + await second; + expect(app.activeTab().result.schemaGraph.focus.db).toBe('b'); + resolveFirst(resp({ json: { data: [{ database: 'a', name: 'x', engine: 'MergeTree', as_select: '' }] } })); + await first; + expect(app.activeTab().result.schemaGraph.focus.db).toBe('b'); // unchanged — a's stale resolution was dropped + }); + + it('cancelSchemaGraph aborts the in-flight fetch; Starting Run cancels it automatically with no unhandled rejection', async () => { + const fetchImpl = makeSignalFetch([ + [(u, sql) => /system\.tables/.test(sql), hangsUntilAborted], + [(u, sql) => /SELECT 1/.test(sql), () => resp({ body: streamBody(['{"row":{}}\n']) })], + ]); + const app = createApp(env({ fetch: fetchImpl })); + app.renderApp(); + app.actions.showSchemaGraph({ kind: 'db', db: 'lin' }); + await untilResult(app); + expect(app.activeTab().result.schemaGraph.loading).toBe(true); + app.activeTab().sql = 'SELECT 1'; + await app.actions.run(); // aborts the pending lineage fetch via cancelSchemaGraph() at its top + expect(app.activeTab().result.schemaGraph).toBeUndefined(); // run()'s own result, not clobbered + }); + + it('a manual cancel keeps the Phase-A graph, marked partial, once Phase A has already drawn it', async () => { + // Padded to AST_PROGRESSIVE_THRESHOLD objects so the two-phase path actually + // engages under the real default (see paddingViews). + const tables = [ + { database: 'lin', name: 'mv', engine: 'MaterializedView', as_select: 'SELECT 1 FROM lin.events', create_table_query: '' }, + ...paddingViews(AST_PROGRESSIVE_THRESHOLD - 1), + ]; + const fetchImpl = makeSignalFetch([ + [(u, sql) => /system\.dictionaries/.test(sql), () => resp({ json: { data: [] } })], + [(u, sql) => /system\.tables/.test(sql), () => resp({ json: { data: tables } })], + [(u, sql) => /EXPLAIN AST/.test(sql), hangsUntilAborted], + ]); + const app = createApp(env({ fetch: fetchImpl })); + app.renderApp(); + const pending = app.actions.showSchemaGraph({ kind: 'db', db: 'lin' }); + // Let Phase A land (tableCount known) while Phase B (EXPLAIN AST) hangs. + await untilResult(app); + for (let i = 0; i < 50 && app.activeTab().result.schemaGraph.tableCount == null; i++) await Promise.resolve(); + expect(app.activeTab().result.schemaGraph.tableCount).not.toBeNull(); + expect(app.activeTab().result.schemaGraph.nodes.length).toBeGreaterThan(0); + app.actions.cancelSchemaGraph({ clearResult: true }); + const sg = app.activeTab().result.schemaGraph; + expect(sg.loading).toBe(false); + expect(sg.partial).toBe(true); + expect(sg.nodes.length).toBeGreaterThan(0); // kept on screen, not cleared + await pending; // the aborted EXPLAIN AST rejecting afterward must not resurrect `loading` + expect(app.activeTab().result.schemaGraph.loading).toBe(false); + expect(app.activeTab().result.schemaGraph.partial).toBe(true); + }); + + it('a manual cancel before Phase A has drawn anything clears the result to the empty placeholder', async () => { + const fetchImpl = makeSignalFetch([ + [(u, sql) => /system\.tables/.test(sql), hangsUntilAborted], + ]); + const app = createApp(env({ fetch: fetchImpl })); + app.renderApp(); + const pending = app.actions.showSchemaGraph({ kind: 'db', db: 'lin' }); + await untilResult(app); + expect(app.activeTab().result.schemaGraph.loading).toBe(true); + expect(app.activeTab().result.schemaGraph.nodes).toEqual([]); + app.actions.cancelSchemaGraph({ clearResult: true }); + expect(app.activeTab().result).toBeNull(); + await pending; + expect(app.activeTab().result).toBeNull(); // stays cleared — no stray write from the aborted fetch + }); + + it('draws the Phase-A graph (free edges) before EXPLAIN AST resolves, then merges in the view/MV source edges', async () => { + let resolveAst; + // Every EXPLAIN AST call (the real mv's and all padding views') shares this + // one pending promise, so resolving it once releases all of them together — + // the padding views picking up a spurious astTables entry from the shared + // response doesn't affect the specific edges asserted below (Set#has checks). + const astPending = new Promise((r) => { resolveAst = r; }); + const { app } = appForRun([ + [(u, sql) => /EXPLAIN AST/.test(sql), () => astPending], + [(u, sql) => /system\.dictionaries/.test(sql), resp({ json: { data: [] } })], + [(u, sql) => /system\.tables/.test(sql), resp({ json: { data: [ + { database: 'lin', name: 'events', engine: 'MergeTree', as_select: '' }, + { database: 'lin', name: 'mv', engine: 'MaterializedView', as_select: 'SELECT 1 FROM lin.events', create_table_query: 'CREATE MATERIALIZED VIEW lin.mv TO lin.dst AS SELECT 1 FROM lin.events' }, + { database: 'lin', name: 'dst', engine: 'MergeTree', as_select: '' }, + ...paddingViews(AST_PROGRESSIVE_THRESHOLD - 1), + ] } })], + ]); + const pending = app.actions.showSchemaGraph({ kind: 'db', db: 'lin' }); + await untilResult(app); + for (let i = 0; i < 50 && app.activeTab().result.schemaGraph.tableCount == null; i++) await Promise.resolve(); + const phaseA = app.activeTab().result.schemaGraph; + expect(phaseA.loading).toBe(true); + expect(phaseA.tableCount).toBe(3 + AST_PROGRESSIVE_THRESHOLD - 1); + // Phase A already has the MV → dst "writes" edge (free, parsed from create_table_query) + // but not yet the events → mv "feeds" edge (needs EXPLAIN AST — still pending). + const phaseAEdges = new Set(phaseA.edges.map((e) => `${e.from}>${e.to}:${e.kind}`)); + expect(phaseAEdges.has('lin.mv>lin.dst:writes')).toBe(true); + expect(phaseAEdges.has('lin.events>lin.mv:feeds')).toBe(false); + resolveAst(resp({ json: { data: [{ explain: ' TableIdentifier lin.events (alias e)' }] } })); + await pending; + const finalSg = app.activeTab().result.schemaGraph; + expect(finalSg.loading).toBeUndefined(); + const finalEdges = new Set(finalSg.edges.map((e) => `${e.from}>${e.to}:${e.kind}`)); + expect(finalEdges.has('lin.events>lin.mv:feeds')).toBe(true); + expect(finalEdges.has('lin.mv>lin.dst:writes')).toBe(true); + }); + + it('reports EXPLAIN AST resolution progress on the schemaGraph as each view/MV settles', async () => { + let resolveAstV2; + const astV2Pending = new Promise((r) => { resolveAstV2 = r; }); + // v1 + all padding views resolve immediately; v2 alone hangs — so progress + // should land at (padding+1)/total without waiting for the whole fetch. + const { app } = appForRun([ + [(u, sql) => /EXPLAIN AST SELECT 2/.test(sql), () => astV2Pending], + [(u, sql) => /EXPLAIN AST/.test(sql), resp({ json: { data: [{ explain: '' }] } })], + [(u, sql) => /system\.dictionaries/.test(sql), resp({ json: { data: [] } })], + [(u, sql) => /system\.tables/.test(sql), resp({ json: { data: [ + { database: 'lin', name: 'v1', engine: 'View', as_select: 'SELECT 1' }, + { database: 'lin', name: 'v2', engine: 'View', as_select: 'SELECT 2' }, + ...paddingViews(AST_PROGRESSIVE_THRESHOLD - 2), + ] } })], + ]); + const pending = app.actions.showSchemaGraph({ kind: 'db', db: 'lin' }); + await untilResult(app); + for (let i = 0; i < 50 && !app.activeTab().result.schemaGraph.progress; i++) await Promise.resolve(); + const progress = app.activeTab().result.schemaGraph.progress; + expect(progress.total).toBe(AST_PROGRESSIVE_THRESHOLD); + expect(progress.done).toBeGreaterThanOrEqual(1); + expect(progress.done).toBeLessThan(AST_PROGRESSIVE_THRESHOLD); // v2 hasn't settled yet + expect(app.activeTab().result.schemaGraph.loading).toBe(true); + resolveAstV2(resp({ json: { data: [{ explain: '' }] } })); + await pending; + expect(app.activeTab().result.schemaGraph.loading).toBeUndefined(); + }); }); describe('schema graph drop edge cases', () => { diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index 6bb0908..1f2fc09 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, exportQuery, loadSchemaLineage, loadSchemaCards, loadLineageTransitive, loadTableDetail, + chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, loadReferenceData, loadEntityDoc, runQuery, killQuery, exportQuery, loadSchemaLineage, loadSchemaCards, loadLineageTransitive, loadTableDetail, AST_PROGRESSIVE_THRESHOLD, } from '../../src/net/ch-client.js'; import { sqlString } from '../../src/core/format.js'; @@ -485,6 +485,123 @@ describe('loadSchemaLineage', () => { const tablesSql = seen.find((s) => /FROM system\.tables/.test(s)); expect(tablesSql).toMatch(/ORDER BY startsWith\(name, '_'\), name/); }); + + // #124 — progressive draw + cancellation. + it('calls onBase with the free-edges data before any EXPLAIN AST resolves', async () => { + let resolveAst; + const astPending = new Promise((r) => { resolveAst = r; }); + const ctx = ctxWith((url, init) => { + const sql = init.body; + if (/EXPLAIN AST/.test(sql)) return astPending; + if (/system\.dictionaries/.test(sql)) return jsonResp({ data: [] }); + return jsonResp({ data: [ + { database: 'lin', name: 'events', engine: 'MergeTree', as_select: '' }, + { database: 'lin', name: 'mv', engine: 'MaterializedView', as_select: 'SELECT 1 FROM lin.events', create_table_query: '' }, + ] }); + }); + let resolveBaseSeen; + const baseSeen = new Promise((r) => { resolveBaseSeen = r; }); + // progressiveThreshold: 1 forces the two-phase path with this tiny (1-astTarget) + // fixture — production leaves it at the AST_PROGRESSIVE_THRESHOLD default. + const pending = loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }, { onBase: resolveBaseSeen, progressiveThreshold: 1 }); + const base = await baseSeen; // resolves exactly when onBase fires — deterministic, no microtask counting + expect(base.tables).toHaveLength(2); + resolveAst(jsonResp({ data: [{ explain: '' }] })); + await pending; + }); + it('calls onProgress as each EXPLAIN AST settles, with a done/total count', async () => { + const ctx = ctxWith((url, init) => { + const sql = init.body; + if (/EXPLAIN AST/.test(sql)) return jsonResp({ data: [{ explain: '' }] }); + if (/system\.dictionaries/.test(sql)) return jsonResp({ data: [] }); + return jsonResp({ data: [ + { database: 'lin', name: 'v1', engine: 'View', as_select: 'SELECT 1' }, + { database: 'lin', name: 'v2', engine: 'View', as_select: 'SELECT 2' }, + ] }); + }); + const progress = []; + await loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }, + { onProgress: (done, total) => progress.push([done, total]), progressiveThreshold: 1 }); + expect(progress).toHaveLength(2); + expect(progress.every(([, total]) => total === 2)).toBe(true); + expect(progress.map(([done]) => done).sort()).toEqual([1, 2]); + }); + it('skips onBase/onProgress below the progressive threshold (small schemas draw in one step, no flicker)', async () => { + let resolveAst; + const astPending = new Promise((r) => { resolveAst = r; }); + const ctx = ctxWith((url, init) => { + const sql = init.body; + if (/EXPLAIN AST/.test(sql)) return astPending; + if (/system\.dictionaries/.test(sql)) return jsonResp({ data: [] }); + return jsonResp({ data: [{ database: 'lin', name: 'v', engine: 'View', as_select: 'SELECT 1' }] }); + }); + const onBase = vi.fn(); + const onProgress = vi.fn(); + // 1 astTarget is far below the default threshold — onBase must NOT fire even + // though the free-edges data (tables/dictionaries) is known well before the + // single EXPLAIN AST resolves. + const pending = loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }, { onBase, onProgress }); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + expect(onBase).not.toHaveBeenCalled(); + resolveAst(jsonResp({ data: [{ explain: '' }] })); + await pending; + expect(onBase).not.toHaveBeenCalled(); + expect(onProgress).not.toHaveBeenCalled(); + }); + it('calls onBase/onProgress at exactly the threshold (>= is progressive, not just >)', async () => { + const views = Array.from({ length: AST_PROGRESSIVE_THRESHOLD }, (_, i) => ({ + database: 'lin', name: 'v' + i, engine: 'View', as_select: 'SELECT ' + i, + })); + const ctx = ctxWith((url, init) => { + const sql = init.body; + if (/EXPLAIN AST/.test(sql)) return jsonResp({ data: [{ explain: '' }] }); + if (/system\.dictionaries/.test(sql)) return jsonResp({ data: [] }); + return jsonResp({ data: views }); + }); + const onBase = vi.fn(); + await loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }, { onBase }); + expect(onBase).toHaveBeenCalledTimes(1); + }); + it('threads an aborted signal through to fetch (network layer sees it)', async () => { + const controller = new AbortController(); + const seenSignals = []; + const ctx = ctxWith((url, init) => { + seenSignals.push(init.signal); + if (/system\.dictionaries/.test(init.body)) return jsonResp({ data: [] }); + return jsonResp({ data: [] }); + }); + await loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }, { signal: controller.signal }); + expect(seenSignals.every((s) => s === controller.signal)).toBe(true); + }); + it('propagates a cancellation during the best-effort system.dictionaries read instead of degrading to no dictionaries', async () => { + const controller = new AbortController(); + controller.abort(); + const ctx = ctxWith((url, init) => { + const sql = init.body; + if (/system\.dictionaries/.test(sql)) { const e = new Error('aborted'); e.name = 'AbortError'; throw e; } + return jsonResp({ data: [{ database: 'lin', name: 'events', engine: 'MergeTree', as_select: '' }] }); + }); + await expect(loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }, { signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' }); + }); + it('propagates a cancellation during a per-view EXPLAIN AST instead of degrading to no astTables', async () => { + const controller = new AbortController(); + controller.abort(); + const ctx = ctxWith((url, init) => { + const sql = init.body; + if (/EXPLAIN AST/.test(sql)) { const e = new Error('aborted'); e.name = 'AbortError'; throw e; } + if (/system\.dictionaries/.test(sql)) return jsonResp({ data: [] }); + return jsonResp({ data: [{ database: 'lin', name: 'v', engine: 'View', as_select: 'SELECT 1' }] }); + }); + await expect(loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }, { signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' }); + }); + it('does NOT rethrow an AbortError-shaped error from a call that never passed a signal (unrelated best-effort reads stay unaffected)', async () => { + const ctx = ctxWith(() => { const e = new Error('boom'); e.name = 'AbortError'; throw e; }); + // loadReferenceData's underlying tryQueryData calls never pass a signal — an + // AbortError there (e.g. a coincidental fetch abort unrelated to #124's + // cancellation) must still degrade gracefully, not surface as a rejection. + const out = await loadReferenceData(ctx); + expect(out).toEqual({ keywords: null, functions: null, formats: null }); + }); }); describe('loadSchemaCards', () => { diff --git a/tests/unit/placeholder.test.js b/tests/unit/placeholder.test.js index 1f9e4dc..b683da9 100644 --- a/tests/unit/placeholder.test.js +++ b/tests/unit/placeholder.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { loadingPlaceholder } from '../../src/ui/placeholder.js'; describe('loadingPlaceholder', () => { @@ -8,4 +8,17 @@ describe('loadingPlaceholder', () => { expect(el.querySelector('.spin svg')).not.toBeNull(); expect(el.textContent).toContain('Loading table…'); }); + it('omits the Cancel button when no onCancel is given', () => { + const el = loadingPlaceholder('Loading table…'); + expect(el.querySelector('.exp-cancel')).toBeNull(); + }); + it('adds a working Cancel button when onCancel is given (#124)', () => { + const onCancel = vi.fn(); + const el = loadingPlaceholder('Loading data flow…', onCancel); + const btn = el.querySelector('.exp-cancel'); + expect(btn).not.toBeNull(); + expect(btn.textContent).toContain('Cancel'); + btn.dispatchEvent(new Event('click', { bubbles: true })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index 64ec213..a8f5bcf 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -987,6 +987,7 @@ describe('schema lineage result', () => { focus: { kind: 'db', db: 'lin' }, nodes: [{ id: 'lin.a', label: 'a', kind: 'table' }, { id: 'lin.mv', label: 'mv', kind: 'mv' }], edges: [{ from: 'lin.a', to: 'lin.mv', kind: 'feeds' }], + tableCount: 2, // Phase A resolved (#124) — no longer `loading` }; return r; } @@ -1026,7 +1027,7 @@ describe('schema lineage result', () => { }); it('a DB with no objects shows the message and no Expand button', () => { const r = newResult('Table'); - r.schemaGraph = { focus: { kind: 'db', db: 'target_all' }, nodes: [], edges: [] }; + r.schemaGraph = { focus: { kind: 'db', db: 'target_all' }, nodes: [], edges: [], tableCount: 0 }; const app = appWithResult(r); renderResults(app); const region = app.dom.resultsRegion; @@ -1034,6 +1035,54 @@ describe('schema lineage result', () => { expect(region.querySelector('.placeholder').textContent).toMatch(/No objects in target_all/); expect([...region.querySelectorAll('.res-act')].find((b) => /Expand/.test(b.textContent))).toBeFalsy(); }); + + // #124 — progressive draw + cancellation. + it('the pre-Phase-A loading placeholder has a working Cancel button', () => { + const r = newResult('Table'); + r.schemaGraph = { focus: { kind: 'db', db: 'lin' }, loading: true, nodes: [], edges: [] }; + const app = appWithResult(r); + renderResults(app); + const region = app.dom.resultsRegion; + const btn = region.querySelector('.placeholder.starting .exp-cancel'); + expect(btn).not.toBeNull(); + click(btn); + expect(app.actions.cancelSchemaGraph).toHaveBeenCalledWith({ clearResult: true }); + }); + it('draws the graph once Phase A resolves even while Phase B is still loading, with a progress readout + Cancel in the toolbar', () => { + const r = newResult('Table'); + r.schemaGraph = { + focus: { kind: 'db', db: 'lin' }, + nodes: [{ id: 'lin.a', label: 'a', kind: 'table' }], + edges: [], + tableCount: 1, + loading: true, + progress: { done: 1, total: 3 }, + }; + const app = appWithResult(r); + renderResults(app); + const region = app.dom.resultsRegion; + // Phase A already drew the graph, not the placeholder. + expect(region.querySelector('svg.explain-graph')).not.toBeNull(); + expect(region.querySelector('.placeholder.starting')).toBeNull(); + expect(region.textContent).toMatch(/resolving 1\/3 view sources/); + const cancel = [...region.querySelectorAll('.res-act')].find((b) => /Cancel/.test(b.textContent)); + expect(cancel).toBeTruthy(); + click(cancel); + expect(app.actions.cancelSchemaGraph).toHaveBeenCalledWith({ clearResult: true }); + // no Expand while still loading + expect([...region.querySelectorAll('.res-act')].find((b) => /Expand/.test(b.textContent))).toBeFalsy(); + }); + it('shows a partial badge for a cancelled-but-kept Phase-A graph, and no Cancel/progress once not loading', () => { + const r = graphResult(); + r.schemaGraph.partial = true; + const app = appWithResult(r); + renderResults(app); + const region = app.dom.resultsRegion; + expect(region.querySelector('.cancelled-badge')).not.toBeNull(); + expect([...region.querySelectorAll('.res-act')].find((b) => /Cancel/.test(b.textContent))).toBeFalsy(); + // still loaded (not loading) → Expand is back + expect([...region.querySelectorAll('.res-act')].find((b) => /Expand/.test(b.textContent))).toBeTruthy(); + }); }); describe('multiquery script grid (#83)', () => { diff --git a/tests/unit/schema.test.js b/tests/unit/schema.test.js index 8140317..cbc5f3a 100644 --- a/tests/unit/schema.test.js +++ b/tests/unit/schema.test.js @@ -99,6 +99,36 @@ describe('renderSchema tree', () => { click(db2Row); expect(app.state.expanded.value.has('db:db2')).toBe(true); }); + it('clicking a closed db also draws its schema graph (collapsed → expanded only, #124)', () => { + const app = withSchema(); + renderSchema(app); + const db2Row = rows(app).find((r) => r.querySelector('.label').textContent === 'db2'); // starts collapsed + click(db2Row); + expect(app.actions.showSchemaGraph).toHaveBeenCalledWith({ kind: 'db', db: 'db2' }); + }); + it('collapsing an open db does not re-draw/re-fetch the schema graph', () => { + const app = withSchema(); + renderSchema(app); + const db1Row = rows(app).find((r) => r.querySelector('.label').textContent === 'db1'); // starts open + click(db1Row); // collapse + expect(app.state.expanded.value.has('db:db1')).toBe(false); + expect(app.actions.showSchemaGraph).not.toHaveBeenCalled(); + }); + it('shift-clicking a closed db inserts DDL without drawing the schema graph', () => { + const app = withSchema(); + renderSchema(app); + const db2Row = rows(app).find((r) => r.querySelector('.label').textContent === 'db2'); // closed + shiftClick(db2Row); + expect(app.actions.showSchemaGraph).not.toHaveBeenCalled(); + }); + it('double-clicking an already-open db just re-inserts the name (no re-draw)', () => { + const app = withSchema(); + renderSchema(app); + const db1Row = rows(app).find((r) => r.querySelector('.label').textContent === 'db1'); // open + dblclick(db1Row); // 1st click: collapses (open → closed, no graph); 2nd: the double, inserts the name + expect(app.actions.insertAtCursor).toHaveBeenCalledWith('db1'); + expect(app.actions.showSchemaGraph).not.toHaveBeenCalled(); + }); it('the chevron rotates to the down/open orientation on expand and back on collapse', () => { vi.useFakeTimers(); try {