diff --git a/CHANGELOG.md b/CHANGELOG.md index 4501546..01290b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Changed +- **Dashboard tile/filter runtime extracted into a route-scoped + `DashboardSession`** (#276 Phase 3b). Wave generations, per-slot + cancellation, the 6-way tile pool, filter waves/merging, and the retry + cascade now live in `src/ui/dashboard/dashboard-session.ts` — constructible + without the `App` object, fed an explicit `DashboardRuntimeInput` (built by + the shell from the favorites list, so a stored dashboard document can + replace that source later), with every DOM write behind injected shell + hooks. `renderDashboard(app)` keeps its signature as the integration entry + (the existing DOM-driven dashboard suite passed unmodified). `destroy()` + aborts all in-flight tile/KPI/filter work, tears down chart instances, + disposes the filter bar, and turns later entry points into no-ops — closing + the orphaned-debounce-timer gap (`buildFilterBar` now returns + `{ el, dispose }`; both the dashboard and the detached Data view dispose the + previous bar on re-render). A stale-generation guard on streamed progress + keeps a superseded request's last buffered chunk from touching the newer + wave's live label. Tile supersede remains client-abort only (no server + `KILL`) by design. Behavior byte-identical. - **Workbench run lifecycle extracted into a route-scoped `WorkbenchSession`** (#276 Phase 3a). `run`/`runScript`/`runEntry`/`cancel` orchestration now lives in `src/ui/workbench/workbench-session.ts`; the run bookkeeping diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 09707f1..7af5640 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -15,6 +15,16 @@ // the same `{name:Type}` mechanism the SQL Browser workbench uses, fanning it // out across every favorite instead of one query at a time. Per-tile overrides // and export arrive in later phases (D7–D8). +// +// #276 Phase 3b: the tile/filter execution runtime (wave generations, +// per-slot cancellation, the 6-way pool, filter-source waves) is extracted +// into `DashboardSession` (`./dashboard/dashboard-session.ts`), constructed +// here and driven through an injected `DashboardSessionHooks` bag. This +// module stays the shell: it owns every DOM write (tile/KPI-source/filter-bar +// state transitions), lazy slot/grid construction, and the `App`-typed glue +// the session must never see (issue #276 rule 1 — the session never touches +// `App`; `build/check-boundaries.mjs` keeps `src/ui/dashboard/**` off +// `src/ui/workbench/**`/`src/editor/**`). import { h } from './dom.js'; import { Icon as IconUntyped } from './icons.js'; @@ -23,38 +33,29 @@ import { schemaKey as schemaKeyUntyped } from '../core/chart-data.js'; import { resolvePanel } from '../core/panel-cfg.js'; import type { Column } from '../core/panel-cfg.js'; import { - DASH_TILE_ROW_CAP, DASH_TILE_BYTE_CAP, DASH_TABLE_DISPLAY_CAP, + DASH_TILE_ROW_CAP, DASH_TABLE_DISPLAY_CAP, activeDashboardView, dashboardViewSelection, partitionKpiBands, } from '../core/dashboard.js'; import { formatBytes as formatBytesUntyped, formatRows as formatRowsUntyped, - detectSqlFormat as detectSqlFormatUntyped, } from '../core/format.js'; -import { newResult } from '../core/stream.js'; -import type { StreamResult } from '../core/stream.js'; -import { - analyzeParameterizedSources, prepareParameterizedBatch, mergedSourceArgs, mergedSourceSql, fieldControls, -} from '../core/param-pipeline.js'; -import type { FieldControl, PreparedFieldState, PreparedSource, ValidationMode } from '../core/param-pipeline.js'; -import { hasOptionalBlocks } from '../core/optional-blocks.js'; -import { effectiveFilterActive, KEYS } from '../state.js'; -import { buildFilterBar as buildFilterBarUntyped } from './filter-bar.js'; import { queryDescription, queryFavorite, queryName, queryPanel } from '../core/saved-query.js'; -import { explicitPanel, isKpiPanel, panelExecution } from '../core/panel-execution.js'; +import { explicitPanel, isKpiPanel } from '../core/panel-execution.js'; import { effectiveDashboardRole } from '../core/result-choice.js'; -import { filterExecution } from '../core/filter-execution.js'; -import { readFilterOptions as readFilterOptionsUntyped } from '../core/filter-options.js'; -import { mergeDashboardFilterHelpers } from '../core/dashboard-filters.js'; -import type { - FilterDiagnostic, FilterHelper, FilterProvider, MergeDashboardFilterHelpersResult, -} from '../core/dashboard-filters.js'; -import { diagnostic } from '../core/diagnostics.js'; +import { KEYS } from '../state.js'; +import { buildFilterBar as buildFilterBarUntyped } from './filter-bar.js'; +import type { FieldControl, PreparedFieldState, ValidationMode } from '../core/param-pipeline.js'; +import type { FilterDiagnostic } from '../core/dashboard-filters.js'; import { buildKpiBand, buildKpiSourceSlot, setKpiSourceLoading, setKpiSourceUnfilled, applyKpiSourceResult, refreshBandWarnings, } from './dashboard-kpi-band.js'; -import type { KpiBand, KpiSourceSlot } from './dashboard-kpi-band.js'; -import type { Panel, SavedQueryV2 } from '../generated/json-schema.types.js'; +import { createDashboardSession } from './dashboard/dashboard-session.js'; +import type { + DashboardSession, DashboardSessionDeps, DashboardSessionHooks, TileDomHooks, KpiSourceDomHooks, + TileSlot, DashSlot, TileResultMeta, FavoriteSourceResult, +} from './dashboard/dashboard-session.js'; +import type { SavedQueryV2 } from '../generated/json-schema.types.js'; import type { App } from './app.types.js'; // ── Typed wrappers over still-untyped .js dependencies ────────────────────── @@ -77,144 +78,28 @@ const Icon: { arrow(): SVGElement; } = IconUntyped; -// format.js is unconverted — detectSqlFormat returns either the authored -// FORMAT keyword text or `null` (the same wrapper cast panel-execution.ts -// applies to this same export); formatRows/formatBytes render '—' for +// format.js is unconverted — formatRows/formatBytes render '—' for // null/NaN and compact human-readable text otherwise. -const detectSqlFormat = detectSqlFormatUntyped as (sql: string) => string | null; const formatRows: (n: number | null | undefined) => string = formatRowsUntyped; const formatBytes: (n: number | null | undefined) => string = formatBytesUntyped; // chart-data.js is unconverted — the same wrapper panels.ts pins for schemaKey. const schemaKey: (columns: Column[] | null | undefined) => string = schemaKeyUntyped; - // filter-bar.js is unconverted — buildFilterBar(app, params, onCommit, // getField, options) builds one field per `fieldControls` entry, reading the // shared varValues/filterActive state off `app`; `curatedFields` entries are // consumed structurally inside it, so the bag stays unknown-valued here. +// Returns `{ el, dispose }` (#276 Phase 3b filter-bar dispose seam): `dispose` +// clears every field's pending debounce timer — the caller must dispose the +// previous bar before building a new one, and on teardown. const buildFilterBar: ( app: App, params: FieldControl[], onCommit: (name: string) => void, getField: (name: string, mode: ValidationMode) => PreparedFieldState, options?: { curatedFields?: Record; document?: Document; ariaLabel?: string }, -) => HTMLElement = buildFilterBarUntyped; - -// filter-options.js is unconverted — readFilterOptions normalizes one Filter -// result row into helper columns + diagnostics, the exact shapes -// dashboard-filters.ts declares for the same pipeline. -const readFilterOptions = readFilterOptionsUntyped as (args: { - columns?: Column[]; row?: unknown; rowCount?: number; -}) => { helpers: FilterHelper[]; diagnostics: FilterDiagnostic[] }; - -// ── Slot & outcome contracts ───────────────────────────────────────────────── - -/** One fetched tile result's footer metadata (see `tileFooter` / - * `dashboardTileResult`). */ -interface TileResultMeta { - rows: number; - ms: number; - bytes: number; - truncated: boolean; -} - -/** A settled dashboard source outcome, as `runFavoriteSource` hands it to a - * hook's `applyResult`: either the error-only gate/rejection object (a - * per-source serialization/config error, an owned-FORMAT rejection) or - * `dashboardTileResult`'s full fetched shape. A type alias (implicit index - * signature) so it also flows into the KPI band's structurally matching - * result parameter. */ -type FavoriteSourceResult = { - error?: string | null; - cancelled?: boolean; - columns?: Column[]; - rows?: unknown[][]; - meta?: TileResultMeta; -}; - -/** Slot-persistent table-tile state (#166): the result-schema key this slot's - * grid state was built for, plus whatever sort/width state the panel - * registry parks on it (panels.ts's `state` holder contract). */ -type TilePanelState = { key: string; [k: string]: unknown }; - -/** One ordinary favorite's stable tile slot (`buildTileSlot`) — the - * `kind:'tile'` counterpart of dashboard-kpi-band.ts's `KpiSourceSlot`, so - * `runPlan`'s dispatch is one discriminated union. Lifecycle fields mirror - * that slot exactly: `gen` + `abortController` are the stale-wave guard, - * `destroy` tears down the live panel instance (PanelRenderResult.destroy), - * `loadLabel` is the streaming placeholder's live text node. */ -interface TileSlot { - kind: 'tile'; - card: HTMLElement; - body: HTMLElement; - foot: HTMLElement; - gen: number; - status: 'panel' | 'unfilled' | 'error' | 'skip' | null; - destroy: (() => void) | null; - panelState: TilePanelState | null; - abortController: AbortController | null; - loadLabel: HTMLElement | null; -} - -/** Any dashboard grid slot — an ordinary tile or a KPI band source (#240), - * discriminated on `kind`. */ -type DashSlot = TileSlot | KpiSourceSlot; - -/** The stale-wave guard fields every slot kind (tile, KPI source, Filter - * source) shares — `supersedeSlot`'s whole contract (#193/#237). */ -interface SupersedableSlot { - gen: number; - abortController: AbortController | null; -} - -/** One Filter-role query's in-memory slot (#237): the same generation/abort - * guard the tile slots use, plus the last provider it produced so a retry - * can re-merge every source's current contribution. */ -interface FilterSlot extends SupersedableSlot { - status: 'idle' | 'loading' | 'error' | 'success'; - lastProvider: FilterProvider | null; -} - -/** The per-consumer half of `runFavoriteSource` (#240): which explicit panel - * owns transport, the client row cap, whether the shared `detectSqlFormat` - * cross-check applies, and the three state-transition renderers. Generic - * over the slot kind so an ordinary tile's hooks can never be paired with a - * KPI source slot (or vice versa) — the handlers are function-typed (not - * method shorthand) to keep the slot parameter contravariant under - * strictFunctionTypes. `setLoading` returns only the loading label's live - * text node: streamed progress (#193 design req 4) may update that text and - * nothing else — a progress callback can never render/classify a result. */ -interface FavoriteSourceHooks { - explicit: Panel | null; - rowCap: number; - checkFormat: boolean; - setUnfilled: (slot: S, names: string[]) => void; - setLoading: (slot: S) => HTMLElement; - applyResult: (slot: S, r: FavoriteSourceResult) => void; -} - -/** One entry of a wave's execution plan (`planWave`): the favorite, its - * stable slot, its prepared source from the wave's batch, and the generation - * reserved for it at wave creation (#193 design req 3). `src` comes from the - * wave's `PreparedBatch.sources` by index — runAll temporarily plans against - * an empty wave and swaps the real `src` in after the filter wave resolves. */ -interface PlannedSource { - index: number; - q: SavedQueryV2; - slot: DashSlot; - src: PreparedSource; - generation: number; -} - -// At most this many tile queries run at once, so a large favorites list doesn't -// fire a thundering herd of concurrent reads at ClickHouse (saturating the -// browser's per-host pool and the cluster) on open and on every Refresh. -const TILE_CONCURRENCY = 6; - -/** One layout-switcher option: `[value, label, title?]` (the optional `title` - * becomes the button's hover tooltip). */ -type SegOption = [value: string, label: string, title?: string]; +) => { el: HTMLElement; dispose(): void } = buildFilterBarUntyped; /** * Build a segmented control (the four-way `Full width | Report | 2 columns | @@ -226,6 +111,7 @@ type SegOption = [value: string, label: string, title?: string]; * active button (and its `aria-pressed`) from `getActive()`, so a pick and the * shared `apply()` stay in agreement. */ +type SegOption = [value: string, label: string, title?: string]; function buildSeg( cls: string, options: SegOption[], @@ -269,48 +155,6 @@ function tileFooter(meta: TileResultMeta): HTMLElement[] { return parts; } -/** - * Adapt a streamed `result` (from `app.exec.executeRead`) to the tile result shape - * `applyTileResult`/`tileFooter` expect (#193). `ms` is wall-clock (start→finish, - * like run()'s finally), `bytes` is the streamed progress byte count, and - * `truncated` reflects the client-side cap (`result.capped` — set once a row - * past `DASH_TILE_ROW_CAP` arrives). Only a successful, non-cancelled, - * current-generation result is ever applied (see runSlotTile). - */ -function dashboardTileResult(result: StreamResult, startedAt: number, finishedAt: number): FavoriteSourceResult { - return { - columns: result.columns, - rows: result.rows, - error: result.error, - cancelled: result.cancelled, - meta: { - rows: result.rows.length, - ms: Math.round(finishedAt - startedAt), - bytes: result.progress.bytes, - truncated: result.capped, - }, - }; -} - -/** - * Bounded-concurrency map that preserves append order. Workers grab the next - * index in turn; each `worker` appends its card synchronously before its first - * await, so cards land in favorite order regardless of which query returns - * first. Returns the per-item results in index order. - */ -async function runPool(items: T[], limit: number, worker: (item: T, index: number) => Promise): Promise { - const results = new Array(items.length); - let next = 0; - const run = async () => { - while (next < items.length) { - const i = next++; - results[i] = await worker(items[i], i); - } - }; - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run())); - return results; -} - // One favorite's tile card, built once per dashboard load (favorite order) and // never removed/re-appended: a filter change can flip a tile between // skip ⇄ unfilled ⇄ chart repeatedly, and removing/re-inserting DOM nodes would @@ -346,33 +190,10 @@ function buildTileSlot(q: SavedQueryV2): TileSlot { }; } -// Reserve the next generation for a slot AND abort its in-flight streamed -// request, atomically, at WAVE CREATION time (#193 design req 3). A queued -// Refresh worker only reaches its request when a pool slot frees up; reserving -// the generation up front (not when the worker starts) closes the stale-wave -// race where a slower older wave's worker finally runs a tile and supersedes a -// newer affected wave with older values. Returns the reserved generation; the -// worker re-checks `slot.gen === generation` before issuing and after streaming. -function supersedeSlot(slot: SupersedableSlot): number { - const generation = ++slot.gen; - if (slot.abortController) slot.abortController.abort(); - slot.abortController = null; - return generation; -} - function destroySlotChart(slot: TileSlot): void { if (slot.destroy) { slot.destroy(); slot.destroy = null; } } -/** True for a text panel — the no-query partition (#166). */ -function isTextFav(q: SavedQueryV2): boolean { - const p = explicitPanel(q); - // `!`: explicitPanel only ever returns a panel whose `cfg` passed its own - // plain-object check (panel-execution.ts) — the schema marks `cfg` optional - // only for forward compatibility. - return !!p && p.cfg!.type === 'text'; -} - // Render a text favorite's tile: immediately, with zero queries — the #166 // partition runs this before any auth/SQL work. function renderTextSlot(app: App, q: SavedQueryV2, slot: TileSlot): void { @@ -487,137 +308,11 @@ function applyTileResult(app: App, q: SavedQueryV2, slot: TileSlot, r: FavoriteS slot.foot.replaceChildren(...tileFooter(r.meta!)); } -// Run (or re-run) one favorite's source into its slot, gated by its prepared -// source from the wave's batch (#173): unfilled OR invalid (#170) `{name:Type}` -// values show the placeholder (never issuing a request — an invalid value left -// to reach the server would either error confusingly or, for Int/UInt, silently -// wrap; see param-validate.js), a per-source error (e.g. a value that can't -// serialize for this tile's declaration) shows an error card — blocking only -// this source, never its siblings — otherwise stream the SQL read-only through -// the shared `app.exec.executeRead` seam (#193/#276) and classify ONCE on completion. -// `onSettled()` fires after every transition (unfilled, errored or fetched) so -// the caller can recompute the live "N not shown" count. -// -// Shared by an ordinary tile (`runSlotTile`) and an explicit KPI band source -// (`runKpiSourceTile`, #240) — the two differ only in which state-transition -// functions render each outcome, the client row cap, and whether an authored -// `FORMAT` needs the extra `detectSqlFormat` cross-check (a KPI's authored- -// FORMAT rejection is entirely `panelExecution`'s own); `hooks` supplies that -// difference so the streaming/gating/generation/abort discipline itself is -// written once (CLAUDE.md: extract a shared primitive on the second consumer -// of a pattern rather than copy it). -// -// `generation` was reserved (and any prior in-flight request aborted) by -// `supersedeSlot` at WAVE CREATION (#193 design req 3), not here: a queued -// Refresh worker whose slot a newer wave has already re-reserved discards itself -// up front without issuing, and a supersede mid-stream aborts this request and -// makes the post-await guard drop it — so a stale wave can never overwrite a -// newer one, even under the 6-way pool's queueing. -async function runFavoriteSource( - app: App, q: SavedQueryV2, slot: S, onSettled: () => void, - src: PreparedSource, generation: number, hooks: FavoriteSourceHooks, -): Promise { - if (slot.gen !== generation) return; // a newer wave already superseded this queued source - if (src.missing.length || src.invalid.length) { - hooks.setUnfilled(slot, src.missing.concat(src.invalid)); - onSettled(); - return; - } - if (src.errors.length) { - hooks.applyResult(slot, { error: src.errors[0] }); - onSettled(); - return; - } - // The wire text is the wave's materialized execution view (#165) — only when - // the favorite actually is a template; block-free SQL keeps its exact bytes. - const execSql = hasOptionalBlocks(q.sql) ? mergedSourceSql(src, q.sql) : q.sql; - const execution = panelExecution(hooks.explicit, execSql, { - format: 'Table', rowLimit: DASH_TILE_ROW_CAP + 1, - params: { readonly: 2, max_result_bytes: DASH_TILE_BYTE_CAP, ...mergedSourceArgs(src) }, - }); - // #193 design req 5: the shared seam streams the structured - // JSONStringsEachRowWithProgress format, so an explicit `FORMAT` clause would - // silently corrupt the tile (an empty successful-looking result, or ignored - // lines). Reject it with a clear error rather than mis-parse. - if (execution.error || (hooks.checkFormat && detectSqlFormat(execSql))) { - hooks.applyResult(slot, { - error: execution.error || 'Dashboard panels require structured streaming results. Remove the explicit FORMAT clause.', - }); - onSettled(); - return; - } - const label = hooks.setLoading(slot); - const ac = new AbortController(); - slot.abortController = ac; - const startedAt = app.now(); - // Client row limit = CAP (newResult trims + flags `capped`); server cap = - // CAP + 1 (the sentinel one past the client limit), so an exactly-CAP result - // is NOT marked truncated and a >CAP result is trimmed AND flagged (#193 req 1). - // `!`: format is always concrete here — the defaults above pin 'Table' and - // panelExecution's owned KPI arm overrides it with 'KPI'. - const result = newResult(execution.format!, hooks.rowCap); - await app.exec.executeRead(result, { - sql: execSql, - format: execution.format, - rowLimit: execution.rowLimit, - // readonly:2 rejects writes server-side (a favorite containing an INSERT/DDL - // is guarded, not executed); max_result_bytes bounds wide rows; param_ - // are the wave's prepared filter args (#173). - params: execution.params, - signal: ac.signal, - // Progress-only repaint (#193 design req 4): update the loading placeholder's - // row count as rows stream, never classify/render mid-stream. Updates the - // label captured for THIS request, so a superseded wave's late chunk can only - // touch its own (already-replaced) node. - onChunk: () => { label.textContent = 'Loading… ' + formatRows(result.progress.rows) + ' rows'; }, - }); - // Superseded mid-stream (a newer wave bumped the generation and aborted this - // request via supersedeSlot) or otherwise stale → discard silently: never - // render a partial/aborted result, never record recents. - if (slot.gen !== generation) return; - slot.abortController = null; - const r = dashboardTileResult(result, startedAt, app.now()); - hooks.applyResult(slot, r); - // #171: this source completed (current generation) — record its bound params - // on success only (the exact wave's boundParams snapshot, so a param confined - // to an inactive optional block — never in `src.statements[*].boundParams` — - // is never recorded). An errored source records nothing. - if (r.error == null) app.recordBoundParams(src.statements.flatMap((s) => s.boundParams)); - onSettled(); -} - -// `q` here is never an explicit KPI favorite — those run through -// runKpiSourceTile instead (#240) — so `explicitPanel(q)` (if non-null) is -// never `isKpiPanel`. -function runSlotTile( - app: App, q: SavedQueryV2, slot: TileSlot, onSettled: () => void, src: PreparedSource, generation: number, -): Promise { - return runFavoriteSource(app, q, slot, onSettled, src, generation, { - explicit: explicitPanel(q), rowCap: DASH_TILE_ROW_CAP, checkFormat: true, - setUnfilled: setSlotUnfilled, - setLoading: setSlotLoading, - applyResult: (s, r) => applyTileResult(app, q, s, r), - }); -} - -// The KPI-source counterpart of runSlotTile (#240), sharing its gating/ -// generation/abort discipline exactly via runFavoriteSource. `explicit` is -// always `cfg.type === 'kpi'` here (the caller only dispatches here for a -// `kind:'kpi-source'` slot, which partitionKpiBands only ever builds from an -// explicit KPI favorite) — so panelExecution always takes its KPI branch -// (owned typed transport, two-row sentinel) and the authored-FORMAT rejection -// is entirely panelExecution's own (no detectSqlFormat cross-check needed, -// unlike the ordinary-tile path). -function runKpiSourceTile( - app: App, q: SavedQueryV2, explicit: Panel, slot: KpiSourceSlot, - onSettled: () => void, src: PreparedSource, generation: number, -): Promise { - return runFavoriteSource(app, q, slot, onSettled, src, generation, { - explicit, rowCap: 2, checkFormat: false, - setUnfilled: setKpiSourceUnfilled, - setLoading: setKpiSourceLoading, - applyResult: (s, r) => applyKpiSourceResult(app, explicit, s, r), - }); +// Streamed row progress (#193 design req 4): shared by both the tile and +// KPI-source hook bags — both slot kinds carry the same `loadLabel` field, the +// live text node `setSlotLoading`/`setKpiSourceLoading` parked on the slot. +function setSlotProgress(slot: { loadLabel: HTMLElement | null }, text: string): void { + if (slot.loadLabel) slot.loadLabel.textContent = text; } /** Render the dashboard into `app.root`. */ @@ -646,26 +341,6 @@ export function renderDashboard(app: App): Promise { // fetched result, so it can't drift with what a query happens to return. const layoutItems = partitionKpiBands(panelFavorites.map((q) => isKpiPanel(explicitPanel(q)))); - // The favorites snapshot is fixed for this render, so the parameter analysis - // (#173 phase 1 — structure only) runs once; each wave (runAll / a filter's - // runAffected) prepares it against the current varValues with one wall-clock - // read, and every tile gate + fetch of that wave reads the same batch. - const tileId = (i: number): string => 'tile:' + (panelFavorites[i].id || i); - const analysis = analyzeParameterizedSources(panelFavorites.map((q, i) => ({ - id: tileId(i), label: queryName(q), kind: 'tile', sql: isTextFav(q) ? '' : q.sql, bindPolicy: 'row-returning', - }))); - const prepareBatch = (validationMode: ValidationMode = 'execute') => prepareParameterizedBatch(analysis, { - values: Object.fromEntries(Object.entries(app.state.varValues).map(([name, value]): [string, string] => [ - name, curatedFields?.[name] && !app.state.filterActive[name] ? '' : value, - ])), - active: effectiveFilterActive(app.state.varValues, app.state.filterActive), - wallNowMs: app.wallNow(), validationMode, - }); - const prepareWave = () => prepareBatch('execute').sources; - // The filter bar's per-keystroke field-state read (#170): 'input' while - // typing (neutral on a plausible prefix), 'execute' on blur/Enter (hardens). - const getFilterField = (name: string, mode: ValidationMode) => prepareBatch(mode).fields[name]; - const favChip = h('span', { class: 'dash-chip dash-fav' }, Icon.star(true), h('span', null, favorites.length + (favorites.length === 1 ? ' favorite' : ' favorites'))); @@ -742,273 +417,162 @@ export function renderDashboard(app: App): Promise { }, 'Dashboard layout'); const layoutWrap = h('div', { class: 'dash-layout-wrap' }, h('span', { class: 'dash-seg-label' }, 'Layout'), layoutSeg.el); - const controls = fieldControls(analysis); - // Seed from the persisted last-known bundle (#234) so a curated field paints - // as the combobox immediately instead of flashing plain text for one frame - // before the first Filter wave resolves; the live wave replaces it below. - let curatedFields: Record = state.filterCurated || {}; + const filterHost = h('div', { class: 'dash-filter-host' }); const filterDiagnosticsHost = h('div', { class: 'dash-filter-diagnostics' }); - const renderFilterBar = () => filterHost.replaceChildren(buildFilterBar( - app, controls, (name) => runAffected(name), getFilterField, { curatedFields }, - )); - renderFilterBar(); - // The toolbar is flex-start (default), so layoutWrap + filterBar pack left as - // the issue specifies — no trailing spacer needed now the right-aligned - // Columns control is gone (#184). - const toolbar = h('div', { - class: 'dash-toolbar' + (controls.length ? ' has-filters' : ''), - }, layoutWrap, filterHost); - apply(); - // #root is a fixed, overflow:hidden flex column (the workbench layout), so the - // dashboard needs its own scroll container — otherwise a tall grid clips with - // no vertical scroll. The header + toolbar share one sticky top bar inside it. - // `!`: the dashboard renders only into a mounted page — main.js/openDashboard - // always hand createApp a real root element. - app.root!.replaceChildren(h('div', { class: 'dash-page' }, - h('div', { class: 'dash-topbar' }, header, toolbar), - ...roleDiagnostics.map((item) => h('div', { class: `dash-config-diagnostic is-${item.severity}` }, item.message)), - filterDiagnosticsHost, empty, grid)); - - // One stable slot per favorite (favorite order), built lazily on the first - // successful run (below) and reused for the tab's lifetime — a filter edit - // or Refresh updates a slot's contents/visibility in place rather than - // inserting/removing grid children (see buildTileSlot). - let slots: DashSlot[] = []; - // Filter sources reuse the SAME generation/abort guard tile slots use - // (supersedeSlot / `slot.gen`, #237) — a second consumer of the stale-wave - // pattern gets the existing primitive, not a re-implementation. `gen` is - // reserved at wave-creation time (see runFilterWave), so a queued worker from - // an older wave sees `slot.gen !== generation` and discards itself. - const filterSlots = new Map(filterFavorites.map((query): [string, FilterSlot] => [query.id, { - gen: 0, abortController: null, status: 'idle', lastProvider: null, - }])); - - async function runFilterSource(query: SavedQueryV2, slot: FilterSlot, generation: number): Promise { - const execution = filterExecution(query.sql); - if (execution.error) { - // filter-execution.ts's `FilterSqlDiagnostic` now extends the same - // `Diagnostic` base as `FilterDiagnostic` (dashboard-filters.ts), so its - // array assigns straight into the provider's `FilterDiagnostic[]`. - const provider: FilterProvider = { - sourceId: query.id, sourceName: queryName(query), helpers: [], diagnostics: execution.diagnostics, - }; - if (slot.gen !== generation) return null; - slot.status = 'error'; - slot.lastProvider = provider; - return provider; - } - if (slot.gen !== generation) return null; - slot.status = 'loading'; - const result = newResult(execution.format, execution.rowLimit); - const ac = new AbortController(); - slot.abortController = ac; - await app.exec.executeRead(result, { - sql: query.sql, format: execution.format, rowLimit: execution.rowLimit, - params: execution.params, signal: ac.signal, - }); - if (slot.gen !== generation) return null; - slot.abortController = null; - let provider: FilterProvider; - if (result.error || result.cancelled) { - provider = { - sourceId: query.id, sourceName: queryName(query), helpers: [], diagnostics: [diagnostic( - 'error', 'filter-query-failed', - `${queryName(query)}: ${result.error || 'Filter query was cancelled.'}`, { sourceId: query.id }, - )], - }; - slot.status = 'error'; - } else { - const normalized = readFilterOptions({ - columns: result.columns, row: result.rows[0], rowCount: result.rows.length, - }); - provider = { sourceId: query.id, sourceName: queryName(query), ...normalized }; - slot.status = normalized.helpers.length ? 'success' : 'error'; - } - slot.lastProvider = provider; - return provider; - } + // The route session this render drives — constructed below, once its hooks + // are wired. `renderFilterBar`/`renderFilterDiagnostics` close over it (only + // ever CALLED once construction below has completed, so the forward + // reference is safe — the same shell↔session wiring pattern + // `WorkbenchSession`'s `attachShell` uses). + let session!: DashboardSession; + + // Rebuild the filter bar with the current curated-field bundle, disposing + // the previous bar's pending debounce timers first (#276 Phase 3b filter-bar + // dispose seam — closes the orphan-timer gap a bare rebuild used to leave). + // `disposeCurrentFilterBar` is also handed to the session as its + // `disposeFilterBar` hook (destroy()'s own teardown) — the SAME function, + // not a second closure, so its one line of teardown logic isn't duplicated. + let filterBarDispose: (() => void) | null = null; + const disposeCurrentFilterBar = (): void => { filterBarDispose?.(); }; + const renderFilterBar = (curatedFields: Record): void => { + disposeCurrentFilterBar(); + const bar = buildFilterBar(app, session.controls, (name) => session.runAffected(name), session.getFilterField, { curatedFields }); + filterHost.replaceChildren(bar.el); + filterBarDispose = bar.dispose; + }; - const renderFilterDiagnostics = (diagnostics: FilterDiagnostic[]) => { + const renderFilterDiagnostics = (diagnostics: FilterDiagnostic[]): void => { filterDiagnosticsHost.replaceChildren(...diagnostics.map((item) => { // `as`: `sourceId` reaches FilterDiagnostic only through its open index // signature (`unknown`), but every 'filter-query-failed' diagnostic is - // minted in runFilterSource above with `sourceId: query.id` (a string). + // minted in the session's runFilterSource with `sourceId: query.id` (a + // string). const retry = item.code === 'filter-query-failed' && item.sourceId - ? h('button', { type: 'button', onclick: () => retryFilter(item.sourceId as string) }, 'Retry') + ? h('button', { type: 'button', onclick: () => session.retryFilter(item.sourceId as string) }, 'Retry') : null; return h('div', { class: `dash-config-diagnostic is-${item.severity}` }, item.message, retry); })); }; - const applyFilterProviders = (providers: (FilterProvider | null)[]): MergeDashboardFilterHelpersResult => { - const merged = mergeDashboardFilterHelpers({ - // The predicate is `filter(Boolean)` made narrowable: a provider is only - // ever null here (a superseded run), never any other falsy value. - providers: providers.filter((provider): provider is FilterProvider => provider !== null), controls, - values: state.varValues, active: effectiveFilterActive(state.varValues, state.filterActive), - }); - curatedFields = merged.fields; - // Persist the live bundle so the next dashboard load can seed it (#234). - state.filterCurated = merged.fields; - app.saveJSON(KEYS.filterCurated, merged.fields); - if (merged.changed.length) { - state.filterActive = merged.active; - app.saveFilterActive(); + // Build the grid's slot array once, lazily, on the first successful wave + // (#276 Phase 3b: lazy slot/DOM construction stays shell-side — the session + // only reserves/aborts generations on whatever slots it's handed). An + // ordinary tile appends its own card; a KPI band builds one full-width + // container and gives each of its member favorites a stable source slot + // inside its shared stream, in favorite order. The returned array stays flat + // over panelFavorites (the index space the session's planWave/tileId/ + // runAffected all key off), regardless of which favorites share a band. + const ensureSlotsBuilt = (): DashSlot[] => { + const built = new Array(panelFavorites.length); + for (const item of layoutItems) { + if (item.kind === 'tile') { + const q = panelFavorites[item.index]; + const slot = buildTileSlot(q); + built[item.index] = slot; + grid.appendChild(slot.card); + } else { + const band = buildKpiBand(); + for (const i of item.indices) { + // `explicit` is cached on the slot once, here (structural build + // time), so the session's dispatch reads `slot.explicit` on every + // later wave instead of re-deriving it from `q` on every + // Refresh/filter run. `!`: partitionKpiBands only groups indices + // whose favorite passed isKpiPanel(explicitPanel(q)) above — the + // explicit panel is present. + built[i] = buildKpiSourceSlot(band, explicitPanel(panelFavorites[i])!, queryName(panelFavorites[i])); + } + grid.appendChild(band.el); + } } - renderFilterBar(); - renderFilterDiagnostics(merged.diagnostics); - return merged; + return built; }; - async function runFilterWave(): Promise { - const plan = filterFavorites.map((query) => { - // `!`: filterSlots is keyed from this exact filterFavorites list above. - const slot = filterSlots.get(query.id)!; - return { query, slot, generation: supersedeSlot(slot) }; - }); - const providers = await runPool(plan, TILE_CONCURRENCY, - ({ query, slot, generation }) => runFilterSource(query, slot, generation)); - return applyFilterProviders(providers); - } - - async function retryFilter(sourceId: string): Promise { - const query = filterFavorites.find((item) => item.id === sourceId); - const slot = filterSlots.get(sourceId); - if (!query || !slot) return; - if (!(await app.ensureFreshToken())) { app.chCtx.onSignedOut(); return; } - await runFilterSource(query, slot, supersedeSlot(slot)); - // `!`: same filterSlots key invariant as runFilterWave above. - const merged = applyFilterProviders(filterFavorites.map((item) => filterSlots.get(item.id)!.lastProvider)); - for (const name of merged.changed) await runAffected(name); - } - - const updateSkipNote = () => { - const skipped = slots.filter((s) => s.status === 'skip').length; - if (skipped) { - skipNote.style.display = ''; - skipNote.textContent = skipped + ' not shown'; - skipNote.title = skipped + ' empty favorite(s) with no panel to render.'; - } else { - skipNote.style.display = 'none'; - } + const tileHooks: TileDomHooks = { + setUnfilled: setSlotUnfilled, + setLoading: setSlotLoading, + onProgress: setSlotProgress, + applyResult: (q, slot, r) => applyTileResult(app, q, slot, r), + renderText: (q, slot) => renderTextSlot(app, q, slot), }; - - // Build the wave's execution plan for a set of query-backed favorites: one - // `{ q, slot, src, generation }` per tile, reserving each slot's generation - // (and aborting any in-flight request) synchronously HERE, at wave creation - // (#193 design req 3). Reserving up front — not when a pool worker starts — - // closes the stale-wave race: a queued older worker sees `slot.gen !== - // generation` and discards itself instead of superseding a newer wave. - const planWave = (indices: number[], wave: PreparedSource[]): PlannedSource[] => indices - .filter((i) => !isTextFav(panelFavorites[i])) - .map((i) => ({ index: i, q: panelFavorites[i], slot: slots[i], src: wave[i], generation: supersedeSlot(slots[i]) })); - - const runPlan = (plan: PlannedSource[]): Promise => { - // Mark every planned slot loading up front — before the 6-way pool starts — - // so tiles beyond TILE_CONCURRENCY's window don't linger on stale content - // while queued. Applies to BOTH full Refresh and targeted affected waves - // (#193); runSlotTile/runKpiSourceTile re-mark their own slot loading when - // their worker starts (capturing the progress label), so filled tiles/cards - // simply repaint identically. Dispatch is by `slot.kind` (#240): an explicit - // KPI favorite's slot always came from buildKpiSourceSlot, never buildTileSlot. - // setKpiSourceLoading does NOT refresh its band's shared warning area itself - // (that would be one O(band size) DOM rebuild PER member, back to back, - // synchronously, with only the last ever visible) — collect every band this - // plan touches and refresh each exactly once after marking the whole batch. - const touchedBands = new Set(); - plan.forEach(({ slot }) => { - if (slot.kind === 'kpi-source') { setKpiSourceLoading(slot); touchedBands.add(slot.band); } - else setSlotLoading(slot); - }); - touchedBands.forEach(refreshBandWarnings); - return runPool(plan, TILE_CONCURRENCY, - ({ q, slot, src, generation }) => (slot.kind === 'kpi-source' - ? runKpiSourceTile(app, q, slot.explicit, slot, updateSkipNote, src, generation) - : runSlotTile(app, q, slot, updateSkipNote, src, generation))); + const kpiHooks: KpiSourceDomHooks = { + setUnfilled: setKpiSourceUnfilled, + setLoading: setKpiSourceLoading, + onProgress: setSlotProgress, + applyResult: (explicit, slot, r) => applyKpiSourceResult(app, explicit, slot, r), + refreshBandWarnings, }; - // Re-run only the favorites whose SQL references `name` (a filter field's - // debounced/committed edit, #149 D3) — not the whole grid. Affected-source - // detection comes from the analysis (#173): `optionalIn` keeps a tile - // affected even while the param's optional blocks are inactive (#165), so an - // activation flip re-runs it exactly like a value change. A no-op before - // the first successful run (slots not built yet). - async function runAffected(name: string): Promise { - if (!slots.length) return undefined; - // Match full Refresh: ONE token preflight before the wave (#193 design - // req 2). `exec.executeRead` leaves token freshness to the caller, so without - // this each affected tile would independently race a rotating-token refresh - // through authedFetch; a failed preflight issues no requests and drives - // sign-out exactly once, exactly like Refresh. - if (!(await app.ensureFreshToken())) { app.chCtx.onSignedOut(); return undefined; } - const f = analysis.fields[name]; // the filter bar only renders analyzed params - const affected = new Set(f.requiredIn.concat(f.optionalIn)); - const wave = prepareWave(); - const targets = panelFavorites.map((q, i) => i).filter((i) => affected.has(tileId(i))); - // Same 6-way pool as full Refresh (#193 design req 7): a wide filter change - // is bounded to TILE_CONCURRENCY concurrent reads, not an unbounded fan-out. - return runPlan(planWave(targets, wave)); - } - - const runAll = async (): Promise => { - // Resolve (and refresh) the auth token ONCE up front. This both avoids N - // tiles racing an expired-token refresh and lets a lost session redirect to - // login exactly once — rather than each tile firing onSignedOut in parallel. - if (!(await app.ensureFreshToken())) { app.chCtx.onSignedOut(); return; } - refreshBtn.disabled = true; - if (!slots.length) { - // Build the grid from the structural layout items (#240): an ordinary - // tile appends its own card; a KPI band builds one full-width container - // and gives each of its member favorites a stable source slot inside its - // shared stream, in favorite order. `slots` stays flat over panelFavorites - // (the index space planWave/tileId/runAffected all key off), regardless - // of which favorites share a band. - slots = new Array(panelFavorites.length); - for (const item of layoutItems) { - if (item.kind === 'tile') { - const q = panelFavorites[item.index]; - const slot = buildTileSlot(q); - slots[item.index] = slot; - grid.appendChild(slot.card); - } else { - const band = buildKpiBand(); - for (const i of item.indices) { - // `explicit` is cached on the slot once, here (structural build - // time), so runPlan's dispatch reads `slot.explicit` on every later - // wave instead of re-deriving it from `q` on every Refresh/filter run. - // `!`: partitionKpiBands only groups indices whose favorite passed - // isKpiPanel(explicitPanel(q)) above — the explicit panel is present. - slots[i] = buildKpiSourceSlot(band, explicitPanel(panelFavorites[i])!, queryName(panelFavorites[i])); - } - grid.appendChild(band.el); - } + const hooks: DashboardSessionHooks = { + tile: tileHooks, + kpi: kpiHooks, + ensureSlotsBuilt, + renderFilterBar, + renderFilterDiagnostics, + updateSkipNote: (skipped) => { + if (skipped) { + skipNote.style.display = ''; + skipNote.textContent = skipped + ' not shown'; + skipNote.title = skipped + ' empty favorite(s) with no panel to render.'; + } else { + skipNote.style.display = 'none'; } - } - // Partition before execution (#166): text panels render right here — - // synchronously, before any tile query is issued — and they never join - // the wave below (zero queries for a text favorite). - // `as`: a text favorite is never an explicit KPI (its cfg.type is 'text'), - // so partitionKpiBands always made its slot an ordinary tile above. - slots.forEach((s, i) => { if (isTextFav(panelFavorites[i])) renderTextSlot(app, panelFavorites[i], s as TileSlot); }); - // One prepared batch (and one wall-clock read) for the whole refresh wave; - // reserve every query-backed slot's generation NOW (planWave), before the - // pool starts, so a queued worker from an older Refresh discards itself. - // runPlan marks every planned slot loading up front (queued tiles included). - const reservedPlan = planWave(panelFavorites.map((q, i) => i), []); - // try/finally so the button always re-enables and the timestamp always - // updates — even if a tile render unexpectedly throws (runSlotTile itself - // is total, so this is belt-and-suspenders against the pool rejecting). - try { - await runFilterWave(); - const wave = prepareWave(); - await runPlan(reservedPlan.map((item) => ({ ...item, src: wave[item.index] }))); - } finally { + }, + disposeFilterBar: disposeCurrentFilterBar, + onAuthFailed: () => { app.chCtx.onSignedOut(); }, + onRunAllStart: () => { refreshBtn.disabled = true; }, + onRunAllSettled: () => { updated.textContent = 'Updated ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); refreshBtn.disabled = false; - } + }, + }; + + // Seed from the persisted last-known curated-filter bundle (#234) so a + // curated field paints as the combobox immediately instead of flashing + // plain text for one frame before the first Filter wave resolves; the live + // wave (inside the session) replaces it thereafter. + const filterCuratedSeed: Record = state.filterCurated || {}; + const deps: DashboardSessionDeps = { + exec: app.exec, + ensureFreshToken: () => app.ensureFreshToken(), + now: () => app.now(), + wallNow: () => app.wallNow(), + recordBoundParams: (bp) => app.recordBoundParams(bp), + varValues: () => app.state.varValues, + filterActive: () => app.state.filterActive, + filterCuratedSeed, + persistFilterCurated: (fields) => { + state.filterCurated = fields; + app.saveJSON(KEYS.filterCurated, fields); + }, + persistFilterActive: (active) => { + state.filterActive = active; + app.saveFilterActive(); + }, + hooks, }; - refreshBtn.onclick = runAll; - return runAll(); + session = createDashboardSession(deps, { panelFavorites, filterFavorites }); + + renderFilterBar(filterCuratedSeed); + // The toolbar is flex-start (default), so layoutWrap + filterBar pack left as + // the issue specifies — no trailing spacer needed now the right-aligned + // Columns control is gone (#184). + const toolbar = h('div', { + class: 'dash-toolbar' + (session.controls.length ? ' has-filters' : ''), + }, layoutWrap, filterHost); + apply(); + + // #root is a fixed, overflow:hidden flex column (the workbench layout), so the + // dashboard needs its own scroll container — otherwise a tall grid clips with + // no vertical scroll. The header + toolbar share one sticky top bar inside it. + // `!`: the dashboard renders only into a mounted page — main.js/openDashboard + // always hand createApp a real root element. + app.root!.replaceChildren(h('div', { class: 'dash-page' }, + h('div', { class: 'dash-topbar' }, header, toolbar), + ...roleDiagnostics.map((item) => h('div', { class: `dash-config-diagnostic is-${item.severity}` }, item.message)), + filterDiagnosticsHost, empty, grid)); + + refreshBtn.onclick = session.runAll; + return session.runAll(); } diff --git a/src/ui/dashboard/dashboard-session.ts b/src/ui/dashboard/dashboard-session.ts new file mode 100644 index 0000000..dc406f2 --- /dev/null +++ b/src/ui/dashboard/dashboard-session.ts @@ -0,0 +1,676 @@ +// #276 Phase 3b DashboardSession — route-scoped owner of the dashboard tile/ +// filter execution runtime (wave generations, per-slot cancellation, the +// 6-way pool), extracted from `src/ui/dashboard.ts` so it is constructible +// without the `App` object (issue rule 1) and unit-testable with plain fakes, +// mirroring `src/ui/workbench/workbench-session.ts`'s own extraction +// (#276 Phase 3a). `destroy()` cancels ALL in-flight work (tile, KPI-source, +// and Filter-source requests), disposes the filter bar, and turns every +// later entry point (`runAll`/`runAffected`/`retryFilter`) into a no-op — +// so an orphaned filter-bar debounce timer firing after teardown can never +// issue a request or trigger a sign-out. Safe when idle or never run. Like +// `workbench-session.ts`'s destroy(), it has NO production caller yet (the +// dashboard is its own tab; today its lifetime is the tab's) — Phase 5's +// route shells wire real teardown; behavior is proven by the unit tests per +// the issue's acceptance criteria. +// +// Depends on `QueryExecutionService` + a narrow deps/hooks bag — never the +// `App` controller, never `src/ui/workbench/**` or `src/editor/**` (the +// route-session boundary `build/check-boundaries.mjs` enforces for +// `src/ui/dashboard/**`). Every DOM write stays in the shell +// (`src/ui/dashboard.ts`) behind the injected `DashboardSessionHooks` — this +// module only ever touches a slot's plain bookkeeping fields (`gen`, +// `abortController`, `status`, `destroy`) and pure `src/core/**` logic. +// +// Slots (`TileSlot`/`KpiSourceSlot`) carry their route's OWN DOM nodes for +// now — the session holds the array (so it can reserve generations / abort +// requests / tear down charts on `destroy()`), but building a slot's DOM and +// appending it to the grid stays a shell responsibility +// (`hooks.ensureSlotsBuilt()`). Splitting session-state from shell-DOM further +// (a slot that carries no DOM at all) is deferred to Phase 5 — deliberate, +// not an oversight. + +import { formatRows, detectSqlFormat } from '../../core/format.js'; +import { DASH_TILE_ROW_CAP, DASH_TILE_BYTE_CAP } from '../../core/dashboard.js'; +import { + analyzeParameterizedSources, prepareParameterizedBatch, mergedSourceArgs, mergedSourceSql, fieldControls, +} from '../../core/param-pipeline.js'; +import type { + FieldControl, PreparedFieldState, PreparedSource, ValidationMode, BoundParamSnapshot, ParameterAnalysis, +} from '../../core/param-pipeline.js'; +import { hasOptionalBlocks } from '../../core/optional-blocks.js'; +import { effectiveFilterActive } from '../../state.js'; +import { queryName } from '../../core/saved-query.js'; +import { explicitPanel, isKpiPanel, panelExecution } from '../../core/panel-execution.js'; +import { filterExecution } from '../../core/filter-execution.js'; +import { readFilterOptions } from '../../core/filter-options.js'; +import { mergeDashboardFilterHelpers } from '../../core/dashboard-filters.js'; +import type { FilterDiagnostic, FilterProvider, MergeDashboardFilterHelpersResult } from '../../core/dashboard-filters.js'; +import { diagnostic } from '../../core/diagnostics.js'; +import { newResult } from '../../core/stream.js'; +import type { StreamResult } from '../../core/stream.js'; +import type { Column } from '../../core/panel-cfg.js'; +import type { KpiSourceSlot, KpiBand } from '../dashboard-kpi-band.js'; +import type { Panel, SavedQueryV2 } from '../../generated/json-schema.types.js'; +import type { QueryExecutionService } from '../../application/query-execution-service.js'; + +// ── Slot & outcome contracts (moved verbatim from dashboard.ts, #276) ─────── + +/** One fetched tile result's footer metadata (dashboard.ts's `tileFooter` / + * `applyTileResult` render it). */ +export interface TileResultMeta { + rows: number; + ms: number; + bytes: number; + truncated: boolean; +} + +/** A settled dashboard source outcome, as `runFavoriteSource` hands it to a + * hook's `applyResult`: either the error-only gate/rejection object (a + * per-source serialization/config error, an owned-FORMAT rejection) or the + * full fetched shape (`dashboardTileResult`). */ +export interface FavoriteSourceResult { + error?: string | null; + cancelled?: boolean; + columns?: Column[]; + rows?: unknown[][]; + meta?: TileResultMeta; +} + +/** Slot-persistent table-tile state (#166): the result-schema key this slot's + * grid state was built for, plus whatever sort/width state the panel + * registry parks on it (dashboard.ts/panels.ts's `state` holder contract). */ +export type TilePanelState = { key: string; [k: string]: unknown }; + +/** One ordinary favorite's stable tile slot (dashboard.ts's `buildTileSlot`) — + * the `kind:'tile'` counterpart of `KpiSourceSlot`, so `runPlan`'s dispatch + * is one discriminated union. `card`/`body`/`foot`/`loadLabel` are the + * shell's own DOM nodes (Phase 3b keeps a slot's DOM inline — see the module + * doc above); the session only ever reads/writes `gen`/`abortController`/ + * `status`/`destroy`. */ +export interface TileSlot { + kind: 'tile'; + card: HTMLElement; + body: HTMLElement; + foot: HTMLElement; + gen: number; + status: 'panel' | 'unfilled' | 'error' | 'skip' | null; + destroy: (() => void) | null; + panelState: TilePanelState | null; + abortController: AbortController | null; + loadLabel: HTMLElement | null; +} + +/** Any dashboard grid slot — an ordinary tile or a KPI band source (#240), + * discriminated on `kind`. */ +export type DashSlot = TileSlot | KpiSourceSlot; + +/** The stale-wave guard fields every slot kind (tile, KPI source, Filter + * source) shares — `supersedeSlot`'s whole contract (#193/#237). */ +export interface SupersedableSlot { + gen: number; + abortController: AbortController | null; +} + +/** One Filter-role query's in-memory slot (#237): the same generation/abort + * guard the tile slots use, plus the last provider it produced so a retry + * can re-merge every source's current contribution. */ +export interface FilterSlot extends SupersedableSlot { + status: 'idle' | 'loading' | 'error' | 'success'; + lastProvider: FilterProvider | null; +} + +/** The per-consumer half of `runFavoriteSource` (#240): which explicit panel + * owns transport, the client row cap, whether the shared `detectSqlFormat` + * cross-check applies, and the state-transition hooks. Generic over the slot + * kind so an ordinary tile's hooks can never be paired with a KPI source + * slot (or vice versa). `setLoading`/`onProgress`/`setUnfilled`/`applyResult` + * are all DOM writes — they stay shell-owned (`hooks.tile`/`hooks.kpi` in + * `DashboardSessionHooks`); this generic interface is just the per-call + * wiring `runSlotTile`/`runKpiSourceTile` build around them. */ +interface FavoriteSourceHooks { + explicit: Panel | null; + rowCap: number; + checkFormat: boolean; + setUnfilled: (slot: S, names: string[]) => void; + /** Shell writes `slot.loadLabel` and shows the loading chrome; the session + * never reads the return value (only `dashboard.ts`'s own `setSlotLoading`/ + * `setKpiSourceLoading` still return the label node, for their own + * progress-hook wiring). */ + setLoading: (slot: S) => void; + /** Streamed row-count progress (#193 design req 4): the shell writes + * `slot.loadLabel.textContent = text` — the session never touches DOM. */ + onProgress: (slot: S, text: string) => void; + applyResult: (slot: S, r: FavoriteSourceResult) => void; +} + +/** One entry of a wave's execution plan (`planWave`): the favorite, its + * stable slot, its prepared source from the wave's batch, and the generation + * reserved for it at wave creation (#193 design req 3). NOTE: `runAll` first + * plans against an EMPTY wave (`planWave(indices, [])`) purely to reserve + * every slot's generation before the filter wave runs, then swaps the real + * `src` in once the post-filter `prepareWave()` resolves — a temporarily + * `undefined` `src` on a planned entry is intentional there, not a bug. */ +interface PlannedSource { + index: number; + q: SavedQueryV2; + slot: DashSlot; + src: PreparedSource; + generation: number; +} + +// At most this many tile queries run at once, so a large favorites list +// doesn't fire a thundering herd of concurrent reads at ClickHouse. +export const TILE_CONCURRENCY = 6; + +/** True for a text panel — the no-query partition (#166). Exported for + * direct unit testing (the shell's former uses were absorbed into this + * session with the structural-analysis and text-slot render dispatch). */ +export function isTextFav(q: SavedQueryV2): boolean { + const p = explicitPanel(q); + // `!`: explicitPanel only ever returns a panel whose `cfg` passed its own + // plain-object check — the schema marks `cfg` optional only for forward + // compatibility. + return !!p && p.cfg!.type === 'text'; +} + +/** + * Adapt a streamed `result` (from `exec.executeRead`) to the tile result shape + * `applyTileResult`/`tileFooter` expect (#193). `ms` is wall-clock (start→ + * finish, like run()'s finally), `bytes` is the streamed progress byte count, + * and `truncated` reflects the client-side cap (`result.capped` — set once a + * row past `DASH_TILE_ROW_CAP` arrives). + */ +function dashboardTileResult(result: StreamResult, startedAt: number, finishedAt: number): FavoriteSourceResult { + return { + columns: result.columns, + rows: result.rows, + error: result.error, + cancelled: result.cancelled, + meta: { + rows: result.rows.length, + ms: Math.round(finishedAt - startedAt), + bytes: result.progress.bytes, + truncated: result.capped, + }, + }; +} + +/** + * Bounded-concurrency map that preserves append order. Workers grab the next + * index in turn; each `worker` call is awaited in order, so callers that mark + * a slot (e.g. "loading") synchronously before this runs see slots update in + * favorite order regardless of which query returns first. Returns the + * per-item results in index order. + */ +export async function runPool(items: T[], limit: number, worker: (item: T, index: number) => Promise): Promise { + const results = new Array(items.length); + let next = 0; + const run = async () => { + while (next < items.length) { + const i = next++; + results[i] = await worker(items[i], i); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run())); + return results; +} + +// Reserve the next generation for a slot AND abort its in-flight streamed +// request, atomically, at WAVE CREATION time (#193 design req 3). A queued +// Refresh worker only reaches its request when a pool slot frees up; reserving +// the generation up front (not when the worker starts) closes the stale-wave +// race where a slower older wave's worker finally runs a tile and supersedes a +// newer affected wave with older values. Returns the reserved generation; the +// worker re-checks `slot.gen === generation` before issuing and after streaming. +export function supersedeSlot(slot: SupersedableSlot): number { + const generation = ++slot.gen; + if (slot.abortController) slot.abortController.abort(); + slot.abortController = null; + return generation; +} + +// ── Construction contracts ────────────────────────────────────────────────── + +/** The favorites this route's session runs — built inside `renderDashboard` + * from `state.savedQueries.filter(queryFavorite)` partitioned by + * `effectiveDashboardRole` exactly as before (#276 Phase 3b rule: the session + * receives this input, it never reads `state.savedQueries` itself). */ +export interface DashboardRuntimeInput { + panelFavorites: SavedQueryV2[]; + filterFavorites: SavedQueryV2[]; +} + +/** An ordinary tile's DOM hooks — every state transition a tile slot can + * reach, all shell-owned (dashboard.ts). */ +export interface TileDomHooks { + setUnfilled(slot: TileSlot, names: string[]): void; + setLoading(slot: TileSlot): void; + onProgress(slot: TileSlot, text: string): void; + applyResult(q: SavedQueryV2, slot: TileSlot, r: FavoriteSourceResult): void; + /** The #166 zero-query text partition — rendered synchronously, never + * through `runFavoriteSource`. */ + renderText(q: SavedQueryV2, slot: TileSlot): void; +} + +/** A KPI band source's DOM hooks (#240), mirroring `TileDomHooks` — plus + * `refreshBandWarnings`, called once per touched band after a wave marks its + * members loading (never once per member — see dashboard.ts's `runPlan`). */ +export interface KpiSourceDomHooks { + setUnfilled(slot: KpiSourceSlot, names: string[]): void; + setLoading(slot: KpiSourceSlot): void; + onProgress(slot: KpiSourceSlot, text: string): void; + applyResult(explicit: Panel, slot: KpiSourceSlot, r: FavoriteSourceResult): void; + refreshBandWarnings(band: KpiBand): void; +} + +/** Every DOM/render callback the session invokes — the shell owns all of it; + * the session owns only wave orchestration (generations, pool concurrency, + * abort, param prep, persistence). */ +export interface DashboardSessionHooks { + tile: TileDomHooks; + kpi: KpiSourceDomHooks; + /** Build (once, lazily on the first successful wave) this render's slot + * array in layout order, appending each slot's card/band into the grid — + * dashboard.ts's `buildTileSlot`/`buildKpiBand`/`buildKpiSourceSlot` stay + * entirely shell-side (#276 Phase 3b: lazy slot/DOM construction is a shell + * responsibility). Called at most once per session. */ + ensureSlotsBuilt(): DashSlot[]; + /** Rebuild the filter bar (disposing the previous one) with the current + * curated-field bundle. */ + renderFilterBar(curatedFields: Record): void; + renderFilterDiagnostics(diagnostics: FilterDiagnostic[]): void; + /** The live "N not shown" header note — `skipped` is the current count. */ + updateSkipNote(skipped: number): void; + /** Tears down the live filter bar's pending debounce timers (the filter-bar + * dispose seam, #276 Phase 3b) — called by `destroy()`. */ + disposeFilterBar(): void; + /** Fired once per wave when the auth preflight fails (retryFilter/ + * runAffected/runAll) — the shell wires this to `chCtx.onSignedOut()`. */ + onAuthFailed(): void; + /** `runAll`'s own shell bits: disable the Refresh button up front... */ + onRunAllStart(): void; + /** ...and, in the `finally`, re-enable it + stamp the "Updated HH:MM" text. */ + onRunAllSettled(): void; +} + +export interface DashboardSessionDeps { + exec: Pick; + ensureFreshToken(): Promise; + /** Perf clock — matches `app.now()` (wall-clock `ms` in tile footers). */ + now(): number; + /** Wall clock — one snapshot per prepared wave (the #173 F6 invariant). */ + wallNow(): number; + recordBoundParams(boundParams: BoundParamSnapshot[]): void; + /** Live accessors onto `app.state` — a plain object reference read fresh on + * every call (filter-bar.ts mutates it in place on every keystroke), not a + * snapshot. Mirrors `WorkbenchSession`'s narrow state-slice precedent. */ + varValues(): Record; + filterActive(): Record; + /** The persisted last-known curated-field bundle (#234) to seed from, so a + * curated field paints as the combobox immediately instead of flashing + * plain text for one frame before the first Filter wave resolves. */ + filterCuratedSeed: Record; + persistFilterCurated(fields: Record): void; + /** Called only when the merge actually changed an activation (mirrors the + * original `if (merged.changed.length)` guard). */ + persistFilterActive(active: Record): void; + hooks: DashboardSessionHooks; +} + +export interface DashboardSession { + /** The field controls the filter bar renders — computed once from the + * input's `panelFavorites` (structure only; no query has run yet). */ + readonly controls: FieldControl[]; + /** The filter bar's per-keystroke field-state read (#170): 'input' while + * typing, 'execute' on blur/Enter/curated pick. */ + getFilterField(name: string, mode: ValidationMode): PreparedFieldState; + runAll(): Promise; + runAffected(name: string): Promise; + retryFilter(sourceId: string): Promise; + /** Bumps every slot's generation, aborts every live AbortController + * (tile/KPI-source/Filter-source), tears down each tile slot's live chart, + * and disposes the filter bar. Safe when idle or never run; no hook fires + * for a request already in flight at the time of the call. */ + destroy(): void; +} + +export function createDashboardSession(deps: DashboardSessionDeps, input: DashboardRuntimeInput): DashboardSession { + const { hooks } = deps; + const { panelFavorites, filterFavorites } = input; + + // One stable slot per favorite (favorite order), built lazily on the first + // successful run (hooks.ensureSlotsBuilt) and reused for the session's + // lifetime — a filter edit or Refresh updates a slot's contents/visibility + // in place rather than inserting/removing grid children. + let slots: DashSlot[] = []; + // Flipped once by destroy(): every later entry point becomes a no-op, so an + // orphaned filter-bar debounce timer firing post-teardown can never issue a + // request or trigger the auth-failed path. + let destroyed = false; + + // Filter sources reuse the SAME generation/abort guard tile slots use + // (supersedeSlot / `slot.gen`, #237). `gen` is reserved at wave-creation + // time (see runFilterWave), so a queued worker from an older wave sees + // `slot.gen !== generation` and discards itself. + const filterSlots = new Map(filterFavorites.map((query): [string, FilterSlot] => [query.id, { + gen: 0, abortController: null, status: 'idle', lastProvider: null, + }])); + + // The favorites snapshot is fixed for this session, so the parameter + // analysis (#173 phase 1 — structure only) runs once; each wave (runAll / + // a filter's runAffected) prepares it against the current varValues with + // one wall-clock read, and every tile gate + fetch of that wave reads the + // same batch. + const tileId = (i: number): string => 'tile:' + (panelFavorites[i].id || i); + const analysis: ParameterAnalysis = analyzeParameterizedSources(panelFavorites.map((q, i) => ({ + id: tileId(i), label: queryName(q), kind: 'tile', sql: isTextFav(q) ? '' : q.sql, bindPolicy: 'row-returning', + }))); + const controls = fieldControls(analysis); + + // Seed from the persisted last-known bundle (#234); the live wave replaces + // it below (applyFilterProviders). + let curatedFields: Record = deps.filterCuratedSeed || {}; + + const prepareBatch = (validationMode: ValidationMode = 'execute') => prepareParameterizedBatch(analysis, { + values: Object.fromEntries(Object.entries(deps.varValues()).map(([name, value]): [string, string] => [ + name, curatedFields?.[name] && !deps.filterActive()[name] ? '' : value, + ])), + active: effectiveFilterActive(deps.varValues(), deps.filterActive()), + wallNowMs: deps.wallNow(), validationMode, + }); + const prepareWave = () => prepareBatch('execute').sources; + const getFilterField = (name: string, mode: ValidationMode): PreparedFieldState => prepareBatch(mode).fields[name]; + + // ── Tile / KPI-source execution (shared core, #240) ─────────────────────── + + // Run (or re-run) one favorite's source into its slot, gated by its prepared + // source from the wave's batch (#173): unfilled OR invalid (#170) values + // show the placeholder (never issuing a request), a per-source error shows + // an error card (blocking only this source), otherwise stream the SQL + // read-only through `deps.exec.executeRead` and classify ONCE on completion. + // `onSettled()` fires after every transition so the caller can recompute the + // live "N not shown" count. `generation` was reserved (and any prior + // in-flight request aborted) by `supersedeSlot` at WAVE CREATION (#193 + // design req 3), not here. + async function runFavoriteSource( + q: SavedQueryV2, slot: S, onSettled: () => void, + src: PreparedSource, generation: number, favHooks: FavoriteSourceHooks, + ): Promise { + if (slot.gen !== generation) return; // a newer wave already superseded this queued source + if (src.missing.length || src.invalid.length) { + favHooks.setUnfilled(slot, src.missing.concat(src.invalid)); + onSettled(); + return; + } + if (src.errors.length) { + favHooks.applyResult(slot, { error: src.errors[0] }); + onSettled(); + return; + } + // The wire text is the wave's materialized execution view (#165) — only + // when the favorite actually is a template; block-free SQL keeps its + // exact bytes. + const execSql = hasOptionalBlocks(q.sql) ? mergedSourceSql(src, q.sql) : q.sql; + const execution = panelExecution(favHooks.explicit, execSql, { + format: 'Table', rowLimit: DASH_TILE_ROW_CAP + 1, + params: { readonly: 2, max_result_bytes: DASH_TILE_BYTE_CAP, ...mergedSourceArgs(src) }, + }); + // #193 design req 5: the shared seam streams the structured + // JSONStringsEachRowWithProgress format, so an explicit `FORMAT` clause + // would silently corrupt the tile. Reject it with a clear error instead. + if (execution.error || (favHooks.checkFormat && detectSqlFormat(execSql))) { + favHooks.applyResult(slot, { + error: execution.error || 'Dashboard panels require structured streaming results. Remove the explicit FORMAT clause.', + }); + onSettled(); + return; + } + favHooks.setLoading(slot); + const ac = new AbortController(); + slot.abortController = ac; + const startedAt = deps.now(); + // Client row limit = CAP (newResult trims + flags `capped`); server cap = + // CAP + 1 (the sentinel one past the client limit). + // `!`: format is always concrete here — the defaults above pin 'Table' and + // panelExecution's owned KPI arm overrides it with 'KPI'. + const result = newResult(execution.format!, favHooks.rowCap); + await deps.exec.executeRead(result, { + sql: execSql, + format: execution.format, + rowLimit: execution.rowLimit, + params: execution.params, + signal: ac.signal, + // Progress-only repaint (#193 design req 4): update the loading + // placeholder's row count as rows stream, never classify/render + // mid-stream. Stale-generation guard FIRST: a superseded request can + // emit one last buffered chunk after abort() but before the reader + // observes it — the shell's `slot.loadLabel` is live (a newer wave's + // setLoading reassigns it), so without this guard a stale chunk would + // corrupt the NEW generation's label. (The pre-#276 code was immune by + // closing over the request-local label node; the guard restores that.) + onChunk: () => { + if (slot.gen !== generation) return; + favHooks.onProgress(slot, 'Loading… ' + formatRows(result.progress.rows) + ' rows'); + }, + }); + // Superseded mid-stream or otherwise stale → discard silently: never + // render a partial/aborted result, never record recents. + if (slot.gen !== generation) return; + slot.abortController = null; + const r = dashboardTileResult(result, startedAt, deps.now()); + favHooks.applyResult(slot, r); + // #171: this source completed (current generation) — record its bound + // params on success only. + if (r.error == null) deps.recordBoundParams(src.statements.flatMap((s) => s.boundParams)); + onSettled(); + } + + // `q` here is never an explicit KPI favorite — those run through + // runKpiSourceTile instead (#240). + function runSlotTile( + q: SavedQueryV2, slot: TileSlot, onSettled: () => void, src: PreparedSource, generation: number, + ): Promise { + return runFavoriteSource(q, slot, onSettled, src, generation, { + explicit: explicitPanel(q), rowCap: DASH_TILE_ROW_CAP, checkFormat: true, + setUnfilled: hooks.tile.setUnfilled, + setLoading: hooks.tile.setLoading, + onProgress: hooks.tile.onProgress, + applyResult: (s, r) => hooks.tile.applyResult(q, s, r), + }); + } + + // The KPI-source counterpart of runSlotTile (#240), sharing its gating/ + // generation/abort discipline exactly via runFavoriteSource. + function runKpiSourceTile( + q: SavedQueryV2, explicit: Panel, slot: KpiSourceSlot, + onSettled: () => void, src: PreparedSource, generation: number, + ): Promise { + return runFavoriteSource(q, slot, onSettled, src, generation, { + explicit, rowCap: 2, checkFormat: false, + setUnfilled: hooks.kpi.setUnfilled, + setLoading: hooks.kpi.setLoading, + onProgress: hooks.kpi.onProgress, + applyResult: (s, r) => hooks.kpi.applyResult(explicit, s, r), + }); + } + + // Build the wave's execution plan for a set of query-backed favorites: one + // `{ q, slot, src, generation }` per tile, reserving each slot's generation + // (and aborting any in-flight request) synchronously HERE, at wave creation + // (#193 design req 3). + function planWave(indices: number[], wave: PreparedSource[]): PlannedSource[] { + return indices + .filter((i) => !isTextFav(panelFavorites[i])) + .map((i) => ({ index: i, q: panelFavorites[i], slot: slots[i], src: wave[i], generation: supersedeSlot(slots[i]) })); + } + + async function runPlan(plan: PlannedSource[]): Promise { + // Mark every planned slot loading up front — before the 6-way pool + // starts — so tiles beyond TILE_CONCURRENCY's window don't linger on + // stale content while queued. setKpiSourceLoading does NOT refresh its + // band's shared warning area itself (that would be one O(band size) DOM + // rebuild PER member) — collect every band this plan touches and refresh + // each exactly once after marking the whole batch. + const touchedBands = new Set(); + plan.forEach(({ slot }) => { + if (slot.kind === 'kpi-source') { hooks.kpi.setLoading(slot); touchedBands.add(slot.band); } + else hooks.tile.setLoading(slot); + }); + touchedBands.forEach((band) => hooks.kpi.refreshBandWarnings(band)); + return runPool(plan, TILE_CONCURRENCY, + ({ q, slot, src, generation }) => (slot.kind === 'kpi-source' + ? runKpiSourceTile(q, slot.explicit, slot, updateSkipNote, src, generation) + : runSlotTile(q, slot, updateSkipNote, src, generation))); + } + + function updateSkipNote(): void { + const skipped = slots.filter((s) => s.status === 'skip').length; + hooks.updateSkipNote(skipped); + } + + // ── Filter sources (#237) ────────────────────────────────────────────────── + + async function runFilterSource(query: SavedQueryV2, slot: FilterSlot, generation: number): Promise { + const execution = filterExecution(query.sql); + if (execution.error) { + const provider: FilterProvider = { + sourceId: query.id, sourceName: queryName(query), helpers: [], diagnostics: execution.diagnostics, + }; + if (slot.gen !== generation) return null; + slot.status = 'error'; + slot.lastProvider = provider; + return provider; + } + if (slot.gen !== generation) return null; + slot.status = 'loading'; + const result = newResult(execution.format, execution.rowLimit); + const ac = new AbortController(); + slot.abortController = ac; + await deps.exec.executeRead(result, { + sql: query.sql, format: execution.format, rowLimit: execution.rowLimit, + params: execution.params, signal: ac.signal, + }); + if (slot.gen !== generation) return null; + slot.abortController = null; + let provider: FilterProvider; + if (result.error || result.cancelled) { + provider = { + sourceId: query.id, sourceName: queryName(query), helpers: [], diagnostics: [diagnostic( + 'error', 'filter-query-failed', + `${queryName(query)}: ${result.error || 'Filter query was cancelled.'}`, { sourceId: query.id }, + )], + }; + slot.status = 'error'; + } else { + const normalized = readFilterOptions({ + columns: result.columns, row: result.rows[0], rowCount: result.rows.length, + }); + provider = { sourceId: query.id, sourceName: queryName(query), ...normalized }; + slot.status = normalized.helpers.length ? 'success' : 'error'; + } + slot.lastProvider = provider; + return provider; + } + + function applyFilterProviders(providers: (FilterProvider | null)[]): MergeDashboardFilterHelpersResult { + const merged = mergeDashboardFilterHelpers({ + // A provider is only ever null here (a superseded run), never any + // other falsy value. + providers: providers.filter((provider): provider is FilterProvider => provider !== null), controls, + values: deps.varValues(), active: effectiveFilterActive(deps.varValues(), deps.filterActive()), + }); + curatedFields = merged.fields; + deps.persistFilterCurated(merged.fields); + if (merged.changed.length) deps.persistFilterActive(merged.active); + hooks.renderFilterBar(curatedFields); + hooks.renderFilterDiagnostics(merged.diagnostics); + return merged; + } + + async function runFilterWave(): Promise { + const plan = filterFavorites.map((query) => { + // `!`: filterSlots is keyed from this exact filterFavorites list above. + const slot = filterSlots.get(query.id)!; + return { query, slot, generation: supersedeSlot(slot) }; + }); + const providers = await runPool(plan, TILE_CONCURRENCY, + ({ query, slot, generation }) => runFilterSource(query, slot, generation)); + return applyFilterProviders(providers); + } + + async function retryFilter(sourceId: string): Promise { + if (destroyed) return; + const query = filterFavorites.find((item) => item.id === sourceId); + const slot = filterSlots.get(sourceId); + if (!query || !slot) return; + // Re-check destroyed after the await (see runAll). + if (!(await deps.ensureFreshToken())) { if (!destroyed) hooks.onAuthFailed(); return; } + if (destroyed) return; + await runFilterSource(query, slot, supersedeSlot(slot)); + // `!`: same filterSlots key invariant as runFilterWave above. + const merged = applyFilterProviders(filterFavorites.map((item) => filterSlots.get(item.id)!.lastProvider)); + for (const name of merged.changed) await runAffected(name); + } + + // ── Waves: runAffected / runAll ──────────────────────────────────────────── + + // Re-run only the favorites whose SQL references `name` (a filter field's + // debounced/committed edit) — not the whole grid. A no-op before the first + // successful run (slots not built yet). + async function runAffected(name: string): Promise { + if (destroyed || !slots.length) return undefined; + // Match full Refresh: ONE token preflight before the wave (#193 design + // req 2). Re-check destroyed after the await (see runAll). + if (!(await deps.ensureFreshToken())) { if (!destroyed) hooks.onAuthFailed(); return undefined; } + if (destroyed) return undefined; + const f = analysis.fields[name]; // the filter bar only renders analyzed params + const affected = new Set(f.requiredIn.concat(f.optionalIn)); + const wave = prepareWave(); + const targets = panelFavorites.map((_q, i) => i).filter((i) => affected.has(tileId(i))); + // Same 6-way pool as full Refresh (#193 design req 7). + return runPlan(planWave(targets, wave)); + } + + async function runAll(): Promise { + if (destroyed) return; + // Resolve (and refresh) the auth token ONCE up front. Re-check destroyed + // AFTER the await: destroy() during the preflight must stop the wave + // before any generation is reserved or hook fires. + if (!(await deps.ensureFreshToken())) { if (!destroyed) hooks.onAuthFailed(); return; } + if (destroyed) return; + hooks.onRunAllStart(); + if (!slots.length) slots = hooks.ensureSlotsBuilt(); + // Partition before execution (#166): text panels render right here — + // synchronously, before any tile query is issued. + slots.forEach((s, i) => { if (isTextFav(panelFavorites[i])) hooks.tile.renderText(panelFavorites[i], s as TileSlot); }); + // One prepared batch (and one wall-clock read) for the whole refresh wave; + // reserve every query-backed slot's generation NOW (planWave), before the + // pool starts. + const reservedPlan = planWave(panelFavorites.map((_q, i) => i), []); + try { + await runFilterWave(); + const wave = prepareWave(); + await runPlan(reservedPlan.map((item) => ({ ...item, src: wave[item.index] }))); + } finally { + hooks.onRunAllSettled(); + } + } + + // ── destroy() ──────────────────────────────────────────────────────────── + + function destroy(): void { + destroyed = true; + for (const slot of slots) { + slot.gen++; + if (slot.abortController) { slot.abortController.abort(); slot.abortController = null; } + if (slot.kind === 'tile' && slot.destroy) { slot.destroy(); slot.destroy = null; } + } + for (const slot of filterSlots.values()) { + slot.gen++; + if (slot.abortController) { slot.abortController.abort(); slot.abortController = null; } + } + hooks.disposeFilterBar(); + } + + return { controls, getFilterField, runAll, runAffected, retryFilter, destroy }; +} diff --git a/src/ui/filter-bar.ts b/src/ui/filter-bar.ts index d1ffa20..6167dec 100644 --- a/src/ui/filter-bar.ts +++ b/src/ui/filter-bar.ts @@ -91,6 +91,17 @@ const buildRecentField = _buildRecentField as (opts: { // Enter/blur bypass this entirely for a fast explicit-commit path. export const FILTER_DEBOUNCE_MS = 500; +/** `buildFilterBar`'s return value (#276 Phase 3b filter-bar dispose seam): + * `el` is the bar's root node; `dispose()` clears every field's pending + * debounce timer. A caller that rebuilds the bar (a filter-value merge + * repaint) must dispose the previous bar first — and dispose on its own + * teardown — so an in-flight debounce never fires against a detached field + * (the orphan-timer gap a bare `replaceChildren` rebuild used to leave). */ +export interface FilterBarHandle { + el: HTMLElement; + dispose(): void; +} + /** * Build a filter bar: one field per `{name:Type}` parameter in `params` (the * shape from `fieldControls(analysis)`), sharing `app.state.varValues` / @@ -107,6 +118,9 @@ export const FILTER_DEBOUNCE_MS = 500; * the detached Data view passes its child-tab document so the comboboxes anchor * in the right realm — #185). `options.ariaLabel`, when set, names the bar as a * labeled group for assistive tech (the detached view labels it "Query filters"). + * + * Returns `{ el, dispose }` (#276 Phase 3b) rather than the bare root node — + * see `FilterBarHandle`. */ export function buildFilterBar( app: FilterBarApp, @@ -114,13 +128,15 @@ export function buildFilterBar( onCommit: (name: string) => void, getField: (name: string, mode: ValidationMode) => PreparedFieldState, options: BuildFilterBarOptions = {}, -): HTMLElement { +): FilterBarHandle { const document = options.document || app.document; const attrs: Record = { class: 'dash-filters' }; if (options.ariaLabel) { attrs.role = 'group'; attrs['aria-label'] = options.ariaLabel; } - if (!params.length) return h('div', { ...attrs, style: { display: 'none' } }); - return h('div', attrs, ...params.map((p) => { + if (!params.length) return { el: h('div', { ...attrs, style: { display: 'none' } }), dispose: () => {} }; + const timerClears: Array<() => void> = []; + const el = h('div', attrs, ...params.map((p) => { let timer: ReturnType | null = null; + timerClears.push(() => { if (timer != null) clearTimeout(timer); timer = null; }); // #173 acceptance (review F1): a type-conflicted param (declared with // disagreeing types across favorites) degrades to the plain text control // (fieldControlKind below) and says so visibly — a warning style distinct @@ -221,4 +237,5 @@ export function buildFilterBar( return h('label', { class: 'var-field' + (p.optional ? ' is-optional' : '') }, h('span', { class: 'var-name' }, p.name), combo.el); })); + return { el, dispose: () => timerClears.forEach((clear) => clear()) }; } diff --git a/src/ui/results.ts b/src/ui/results.ts index 1e52d41..066b59b 100644 --- a/src/ui/results.ts +++ b/src/ui/results.ts @@ -1029,6 +1029,7 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { // Filter row (#185): only when the source declares `{name:Type}` fields — // omitted entirely otherwise (no empty toolbar). Committing a field or // clicking Refresh re-runs only this detached query. + let filterBarDispose: (() => void) | null = null; if (fields.length) { const getField = (name: string, mode: ValidationMode): PreparedFieldState => prepareParameterizedBatch(analysis, { values: app.state.varValues, @@ -1039,12 +1040,13 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { // A committed field re-runs only this detached query (rerun ignores the // param name buildFilterBar passes — a single source re-runs wholesale). const filterBar = buildFilterBar(app as FilterBarApp, fields, rerun, getField, { document: doc, ariaLabel: 'Query filters' }); + filterBarDispose = filterBar.dispose; refreshBtn = h('button', { class: 'res-act detached-refresh', title: 'Re-run this query with the current filter values', onclick: () => rerun(), }, Icon.play(), h('span', null, 'Refresh')); statusEl = h('div', { class: 'detached-status', role: 'status' }); - pane.appendChild(h('div', { class: 'detached-filter-row' }, filterBar, refreshBtn, statusEl)); + pane.appendChild(h('div', { class: 'detached-filter-row' }, filterBar.el, refreshBtn, statusEl)); } pane.appendChild(inner); body.appendChild(pane); @@ -1066,6 +1068,7 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { closed = true; if (ac) ac.abort(); if (chartInstance) { chartInstance.destroy(); chartInstance = null; } + filterBarDispose?.(); if (!isTab) doc.removeEventListener('keydown', onKey, true); }; }, diff --git a/tests/unit/dashboard-session.test.ts b/tests/unit/dashboard-session.test.ts new file mode 100644 index 0000000..c65f4b3 --- /dev/null +++ b/tests/unit/dashboard-session.test.ts @@ -0,0 +1,831 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + createDashboardSession, runPool, supersedeSlot, isTextFav, TILE_CONCURRENCY, +} from '../../src/ui/dashboard/dashboard-session.js'; +import type { + DashboardSessionDeps, DashboardSessionHooks, TileSlot, TileDomHooks, KpiSourceDomHooks, FavoriteSourceResult, +} from '../../src/ui/dashboard/dashboard-session.js'; +import type { KpiSourceSlot, KpiBand } from '../../src/ui/dashboard-kpi-band.js'; +import type { StreamResult } from '../../src/core/stream.js'; +import type { ExecuteReadRequest } from '../../src/application/query-execution-service.js'; +import type { Panel, SavedQueryV2 } from '../../src/generated/json-schema.types.js'; +import { savedQuery } from '../helpers/saved-query.js'; + +// DashboardSession (#276 Phase 3b) — the extracted route-scoped tile/filter +// execution runtime, unit-tested directly against fake deps/hooks (no App, +// no real DOM beyond the minimal slot stubs below). `renderDashboard`'s own +// integration suite (tests/unit/dashboard.test.ts) is the end-to-end safety +// net for the shell wiring; these tests are the session's own unit surface — +// pool bound/order, generation/abort semantics, filter-wave orchestration, +// and destroy() teardown. + +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +function makeTileSlot(overrides: Partial = {}): TileSlot { + return { + kind: 'tile', + card: document.createElement('div'), + body: document.createElement('div'), + foot: document.createElement('div'), + gen: 0, + status: null, + destroy: null, + panelState: null, + abortController: null, + loadLabel: null, + ...overrides, + }; +} + +function makeKpiBand(): KpiBand { + return { + el: document.createElement('div'), + stream: document.createElement('div'), + warningHost: document.createElement('div'), + sources: [], + }; +} + +function makeKpiSlot(overrides: Partial = {}): KpiSourceSlot { + const band = overrides.band || makeKpiBand(); + const slot: KpiSourceSlot = { + kind: 'kpi-source', + host: document.createElement('div'), + band, + name: 'KPI', + explicit: { cfg: { type: 'kpi' } } as Panel, + warnings: [], + gen: 0, + status: null, + abortController: null, + loadLabel: null, + ...overrides, + }; + band.sources.push(slot); + return slot; +} + +function makeTileHooks(overrides: Partial = {}): TileDomHooks { + return { + setUnfilled: vi.fn(), + setLoading: vi.fn(), + onProgress: vi.fn(), + applyResult: vi.fn(), + renderText: vi.fn(), + ...overrides, + }; +} + +function makeKpiHooks(overrides: Partial = {}): KpiSourceDomHooks { + return { + setUnfilled: vi.fn(), + setLoading: vi.fn(), + onProgress: vi.fn(), + applyResult: vi.fn(), + refreshBandWarnings: vi.fn(), + ...overrides, + }; +} + +function makeHooks(overrides: Partial = {}): DashboardSessionHooks { + return { + tile: makeTileHooks(), + kpi: makeKpiHooks(), + ensureSlotsBuilt: vi.fn(() => []), + renderFilterBar: vi.fn(), + renderFilterDiagnostics: vi.fn(), + updateSkipNote: vi.fn(), + disposeFilterBar: vi.fn(), + onAuthFailed: vi.fn(), + onRunAllStart: vi.fn(), + onRunAllSettled: vi.fn(), + ...overrides, + }; +} + +function makeDeps(overrides: Partial = {}): DashboardSessionDeps & { + varValuesObj: Record; + filterActiveObj: Record; +} { + const varValuesObj: Record = {}; + const filterActiveObj: Record = {}; + const base: DashboardSessionDeps = { + exec: { executeRead: vi.fn(async (result: StreamResult) => result) }, + ensureFreshToken: vi.fn(async () => true), + now: vi.fn(() => 0), + wallNow: vi.fn(() => 0), + recordBoundParams: vi.fn(), + varValues: () => varValuesObj, + filterActive: () => filterActiveObj, + filterCuratedSeed: {}, + persistFilterCurated: vi.fn(), + persistFilterActive: vi.fn(), + hooks: makeHooks(), + }; + return Object.assign(base, overrides, { varValuesObj, filterActiveObj }); +} + +const fav = (id: string, sql: string, panel?: Panel): SavedQueryV2 => + savedQuery({ id, sql, favorite: true, ...(panel ? { panel } : {}) }); + +// A deferred `executeRead` double: each call is queued, resolved manually via +// `resolvers`, and the AbortSignal is captured so a test can assert it aborted. +function deferredExec() { + const signals: (AbortSignal | undefined)[] = []; + const resolvers: Array<(out?: Partial) => void> = []; + const executeRead = vi.fn((result: StreamResult, opts: ExecuteReadRequest) => { + signals.push(opts.signal); + return new Promise((resolve) => resolvers.push((out = {}) => { + Object.assign(result, out); + resolve(result); + })); + }); + return { executeRead, signals, resolvers }; +} + +describe('isTextFav', () => { + it('is false with no explicit panel, false for a non-text panel, true for an explicit text panel', () => { + expect(isTextFav(fav('1', 'select 1'))).toBe(false); + expect(isTextFav(fav('2', 'select 1', { cfg: { type: 'table' } }))).toBe(false); + expect(isTextFav(fav('3', 'select 1', { cfg: { type: 'text' } }))).toBe(true); + }); +}); + +describe('runPool', () => { + it('bounds concurrency and preserves append order regardless of completion order', async () => { + const started: number[] = []; + const resolvers: Array<() => void> = []; + const p = runPool([0, 1, 2, 3, 4], 2, (item, i) => { + started.push(i); + return new Promise((resolve) => resolvers.push(() => resolve(item * 10))); + }); + await flush(); + expect(started).toEqual([0, 1]); // only 2 in flight (limit) + // Resolve out of order — item 1 first — the RESULT array still lands by index. + resolvers[1](); + await flush(); + expect(started).toEqual([0, 1, 2]); + resolvers[0](); + await flush(); + expect(started).toEqual([0, 1, 2, 3]); + resolvers.slice(2).forEach((r) => r()); + await flush(); + expect(started).toEqual([0, 1, 2, 3, 4]); + resolvers[4](); + expect(await p).toEqual([0, 10, 20, 30, 40]); + }); + + it('a limit at or above item count runs everything immediately', async () => { + const order: number[] = []; + const result = await runPool([1, 2, 3], 10, async (item) => { order.push(item); return item; }); + expect(result).toEqual([1, 2, 3]); + expect(order).toEqual([1, 2, 3]); + }); +}); + +describe('supersedeSlot', () => { + it('bumps the generation and is a no-op abort when idle', () => { + const slot = { gen: 0, abortController: null }; + expect(supersedeSlot(slot)).toBe(1); + expect(slot.abortController).toBeNull(); + }); + + it('aborts a live in-flight AbortController and clears it', () => { + const ac = new AbortController(); + const slot = { gen: 3, abortController: ac }; + expect(supersedeSlot(slot)).toBe(4); + expect(ac.signal.aborted).toBe(true); + expect(slot.abortController).toBeNull(); + }); +}); + +describe('createDashboardSession — runAll wave', () => { + it('runs the token preflight once, marks Run-all bookkeeping, and streams every tile through exec.executeRead', async () => { + const { executeRead, resolvers } = deferredExec(); + const slots = [makeTileSlot(), makeTileSlot()]; + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => slots) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const queries = [fav('1', 'SELECT 1'), fav('2', 'SELECT 2')]; + const session = createDashboardSession(deps, { panelFavorites: queries, filterFavorites: [] }); + const p = session.runAll(); + await flush(); + expect(deps.ensureFreshToken).toHaveBeenCalledTimes(1); + expect(hooks.onRunAllStart).toHaveBeenCalledTimes(1); + expect(hooks.ensureSlotsBuilt).toHaveBeenCalledTimes(1); + expect(executeRead).toHaveBeenCalledTimes(2); + // Once per tile marking it loading up front (runPlan, before the pool + // starts) and once more per tile when its own worker actually starts + // (runFavoriteSource) — both re-marks are harmless (same loading chrome). + expect(hooks.tile.setLoading).toHaveBeenCalledTimes(4); + resolvers.splice(0).forEach((r) => r({ columns: [{ name: 'k', type: 'String' }], rows: [['a']] })); + await p; + expect(hooks.onRunAllSettled).toHaveBeenCalledTimes(1); + }); + + it('reuses the already-built slot array on a second Refresh (ensureSlotsBuilt called once)', async () => { + const executeRead = vi.fn(async (result: StreamResult) => { result.columns = [{ name: 'k', type: 'String' }]; result.rows = [['a']]; return result; }); + const slots = [makeTileSlot()]; + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => slots) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const session = createDashboardSession(deps, { panelFavorites: [fav('1', 'SELECT 1')], filterFavorites: [] }); + await session.runAll(); + await session.runAll(); + expect(hooks.ensureSlotsBuilt).toHaveBeenCalledTimes(1); + expect(executeRead).toHaveBeenCalledTimes(2); + expect(hooks.onRunAllSettled).toHaveBeenCalledTimes(2); + }); + + it('renders a text favorite synchronously with zero queries', async () => { + const executeRead = vi.fn(async (result: StreamResult) => result); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const q = fav('1', 'ignored', { cfg: { type: 'text' } }); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + await session.runAll(); + expect(hooks.tile.renderText).toHaveBeenCalledWith(q, slot); + expect(executeRead).not.toHaveBeenCalled(); // a text favorite never queries + }); + + it('auth preflight failure issues no requests and fires onAuthFailed exactly once', async () => { + const executeRead = vi.fn(async (result: StreamResult) => result); + const hooks = makeHooks(); + const deps = makeDeps({ exec: { executeRead }, ensureFreshToken: vi.fn(async () => false), hooks }); + const session = createDashboardSession(deps, { panelFavorites: [fav('1', 'SELECT 1')], filterFavorites: [] }); + await session.runAll(); + expect(hooks.onAuthFailed).toHaveBeenCalledTimes(1); + expect(executeRead).not.toHaveBeenCalled(); + expect(hooks.ensureSlotsBuilt).not.toHaveBeenCalled(); // never got past the preflight + }); + + it('a KPI-source favorite dispatches through the kpi hooks and refreshes its band warnings once', async () => { + const executeRead = vi.fn(async (result: StreamResult) => { + result.columns = [{ name: 'n', type: 'UInt64' }]; + result.rows = [['42']]; + return result; + }); + const band = makeKpiBand(); + const explicit: Panel = { cfg: { type: 'kpi' } }; + const slot = makeKpiSlot({ band, explicit }); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const q = fav('1', 'SELECT 42 AS n', explicit); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + await session.runAll(); + expect(hooks.kpi.setLoading).toHaveBeenCalledWith(slot); + expect(hooks.kpi.refreshBandWarnings).toHaveBeenCalledTimes(1); + expect(hooks.kpi.refreshBandWarnings).toHaveBeenCalledWith(band); + expect(hooks.kpi.applyResult).toHaveBeenCalledWith(explicit, slot, expect.objectContaining({ + rows: [['42']], + })); + }); +}); + +describe('createDashboardSession — generation/abort semantics (#193)', () => { + it('a newer runAffected wave aborts the previous slot request at wave creation, before it resolves', async () => { + const { executeRead, signals, resolvers } = deferredExec(); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + deps.varValuesObj.year = '1'; + const q = fav('1', 'SELECT {year:UInt16} AS n'); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + const p0 = session.runAll(); + await flush(); + expect(signals).toHaveLength(1); + resolvers[0]({ columns: [{ name: 'k', type: 'String' }], rows: [['a']] }); + await p0; + + deps.varValuesObj.year = '11'; + const a = session.runAffected('year'); // wave A + await flush(); + expect(signals).toHaveLength(2); + + deps.varValuesObj.year = '22'; + const b = session.runAffected('year'); // wave B — created before A resolves, supersedes it + await flush(); + expect(signals).toHaveLength(3); + expect(signals[1]!.aborted).toBe(true); // A superseded at B's CREATION + expect(signals[2]!.aborted).toBe(false); + resolvers[1]({ columns: [{ name: 'k', type: 'String' }], rows: [['a']] }); + resolvers[2]({ columns: [{ name: 'k', type: 'String' }], rows: [['b']] }); + await Promise.all([a, b]); + }); + + it('a queued Refresh worker superseded by a newer wave discards itself without issuing a request', async () => { + const { executeRead, resolvers } = deferredExec(); + const slots = Array.from({ length: 8 }, () => makeTileSlot()); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => slots) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + deps.varValuesObj.year = '1'; + const queries = Array.from({ length: 8 }, (_, i) => fav(String(i), `SELECT {year:UInt16} AS n${i}`)); + const session = createDashboardSession(deps, { panelFavorites: queries, filterFavorites: [] }); + const p0 = session.runAll(); // wave A: fans out 6, queues 2 + await flush(); + expect(executeRead).toHaveBeenCalledTimes(TILE_CONCURRENCY); + + deps.varValuesObj.year = '2'; + const p1 = session.runAffected('year'); // wave B supersedes every slot at CREATION + await flush(); + expect(executeRead).toHaveBeenCalledTimes(TILE_CONCURRENCY + 6); // B's own 6 in flight + + // Drain everything; A's two queued workers dequeue AFTER B superseded them + // and discard WITHOUT ever calling executeRead for year=1. + while (resolvers.length) { + resolvers.splice(0).forEach((r) => r({ columns: [{ name: 'k', type: 'String' }], rows: [['x']] })); + await flush(); + } + await Promise.all([p0, p1]); + expect(executeRead).toHaveBeenCalledTimes(6 + 8); // A only ever issued 6; B issued all 8 + }); + + it('a stale (superseded) response neither renders nor records recents', async () => { + const resolvers: Array<(out: Partial) => void> = []; + const executeRead = vi.fn((result: StreamResult) => new Promise((resolve) => resolvers.push((out) => { + Object.assign(result, out); + resolve(result); + }))); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + deps.varValuesObj.year = '1'; + const q = fav('1', 'SELECT {year:UInt16} AS n'); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + const p0 = session.runAll(); + await flush(); + resolvers[0]({ columns: [{ name: 'k', type: 'String' }], rows: [['a'], ['a2']] }); + await p0; + (deps.recordBoundParams as ReturnType).mockClear(); + + deps.varValuesObj.year = '11'; // wave A (superseded below) + const a = session.runAffected('year'); + await flush(); + deps.varValuesObj.year = '22'; // wave B supersedes A + const b = session.runAffected('year'); + await flush(); + // B resolves first (current), then the stale A resolves late. + resolvers[2]({ columns: [{ name: 'k', type: 'String' }], rows: [['B'], ['B2']] }); + await flush(); + resolvers[1]({ columns: [{ name: 'k', type: 'String' }], rows: [['A-stale'], ['A2']] }); + await Promise.all([a, b]); + expect(hooks.tile.applyResult).toHaveBeenCalledTimes(2); // initial run + B; never the stale A + expect(hooks.tile.applyResult).not.toHaveBeenCalledWith(q, slot, expect.objectContaining({ + rows: [['A-stale'], ['A2']], + })); + expect(deps.recordBoundParams).toHaveBeenCalledTimes(1); // only B recorded + }); +}); + +describe('createDashboardSession — gating (#170/#173) and FORMAT rejection (#193 req 5)', () => { + it('an empty required {name:Type} value shows the unfilled hook and issues no request', async () => { + const executeRead = vi.fn(async (result: StreamResult) => result); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const q = fav('1', 'SELECT {year:UInt16} AS n'); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + await session.runAll(); + expect(hooks.tile.setUnfilled).toHaveBeenCalledWith(slot, ['year']); + expect(executeRead).not.toHaveBeenCalled(); + expect(hooks.updateSkipNote).toHaveBeenCalled(); + }); + + it('an invalid active value shows the unfilled hook (invalid names) and issues no request', async () => { + const executeRead = vi.fn(async (result: StreamResult) => result); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + deps.varValuesObj.year = 'not-a-number'; + deps.filterActiveObj.year = true; + const q = fav('1', 'SELECT {year:UInt16} AS n'); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + await session.runAll(); + expect(hooks.tile.setUnfilled).toHaveBeenCalledWith(slot, ['year']); + expect(executeRead).not.toHaveBeenCalled(); + }); + + it('a per-source serialization error (structural value/declaration mismatch) shows only its own error', async () => { + const executeRead = vi.fn(async (result: StreamResult) => result); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const badValues: Record = { db: ['not', 'scalar'] }; + Object.assign(deps.varValuesObj, badValues as Record); + deps.filterActiveObj.db = true; + const q = fav('1', 'SELECT {db:String} AS n'); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + await session.runAll(); + expect(executeRead).not.toHaveBeenCalled(); + expect(hooks.tile.applyResult).toHaveBeenCalledWith(q, slot, expect.objectContaining({ + error: expect.stringContaining('array value'), + })); + }); + + it('rejects an explicit FORMAT clause on an ordinary tile with a clear error and issues no request', async () => { + const executeRead = vi.fn(async (result: StreamResult) => result); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const q = fav('1', 'SELECT 1 FORMAT JSON'); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + await session.runAll(); + expect(executeRead).not.toHaveBeenCalled(); + expect(hooks.tile.applyResult).toHaveBeenCalledWith(q, slot, { + error: 'Dashboard panels require structured streaming results. Remove the explicit FORMAT clause.', + }); + }); + + it('rejects an explicit FORMAT clause on a KPI source with the KPI-owned diagnostic (no checkFormat cross-check needed)', async () => { + const executeRead = vi.fn(async (result: StreamResult) => result); + const explicit: Panel = { cfg: { type: 'kpi' } }; + const slot = makeKpiSlot({ explicit }); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const q = fav('1', 'SELECT 1 FORMAT CSV', explicit); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + await session.runAll(); + expect(executeRead).not.toHaveBeenCalled(); + expect(hooks.kpi.applyResult).toHaveBeenCalledWith(explicit, slot, { + error: 'KPI panel owns the result format. Remove FORMAT CSV from the SQL.', + }); + }); +}); + +describe('createDashboardSession — onProgress + recordBoundParams (#193 req 4, #171)', () => { + it('pulses onProgress with the formatted row count as chunks stream, and never twice after settling', async () => { + const executeRead = vi.fn(async (result: StreamResult, opts: ExecuteReadRequest) => { + result.progress.rows = 1420; + opts.onChunk?.(); + result.columns = [{ name: 'k', type: 'String' }]; + result.rows = [['a']]; + return result; + }); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const q = fav('1', 'SELECT 1'); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + await session.runAll(); + expect(hooks.tile.onProgress).toHaveBeenCalledWith(slot, 'Loading… 1.4K rows'); + }); + + it('records bound params only on a successful (non-errored) completion', async () => { + const ok = vi.fn(async (result: StreamResult) => { result.columns = [{ name: 'n', type: 'UInt16' }]; result.rows = [['9']]; return result; }); + const slotOk = makeTileSlot(); + const hooksOk = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slotOk]) }); + const depsOk = makeDeps({ exec: { executeRead: ok }, hooks: hooksOk }); + depsOk.varValuesObj.year = '2024'; + const qOk = fav('1', 'SELECT {year:UInt16} AS n'); + await createDashboardSession(depsOk, { panelFavorites: [qOk], filterFavorites: [] }).runAll(); + expect(depsOk.recordBoundParams).toHaveBeenCalledTimes(1); + expect(depsOk.recordBoundParams).toHaveBeenCalledWith([ + expect.objectContaining({ name: 'year', serializedValue: '2024' }), + ]); + + const failing = vi.fn(async (result: StreamResult) => { result.error = 'boom'; return result; }); + const slotErr = makeTileSlot(); + const hooksErr = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slotErr]) }); + const depsErr = makeDeps({ exec: { executeRead: failing }, hooks: hooksErr }); + const qErr = fav('1', 'SELECT 1'); + await createDashboardSession(depsErr, { panelFavorites: [qErr], filterFavorites: [] }).runAll(); + expect(depsErr.recordBoundParams).not.toHaveBeenCalled(); + }); +}); + +describe('createDashboardSession — Filter wave, merge, and persistence (#160/#237)', () => { + const filterFav = (id: string, sql: string): SavedQueryV2 => savedQuery({ + id, sql, favorite: true, dashboard: { role: 'filter' }, + }); + + it('runs the Filter wave before Panels, persists the curated bundle, and cascades a changed field to an affected Panel', async () => { + const executeRead = vi.fn(async (result: StreamResult, opts: ExecuteReadRequest) => { + if (opts.sql === 'SELECT filter_options') { + result.columns = [{ name: 'origin', type: 'Array(String)' }]; + result.rows = [[['ATL', 'JFK']]]; + return result; + } + result.columns = [{ name: 'k', type: 'String' }]; + result.rows = [['a']]; + return result; + }); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + // A stale active value the fresh options no longer contain — triggers the + // "changed" (deactivate) branch and its cascade to the affected Panel. + deps.varValuesObj.origin = 'stale'; + deps.filterActiveObj.origin = true; + const queries = [fav('p', 'SELECT * FROM t WHERE origin={origin:String}')]; + const filters = [filterFav('f', 'SELECT filter_options')]; + const session = createDashboardSession(deps, { panelFavorites: queries, filterFavorites: filters }); + await session.runAll(); + expect(executeRead.mock.calls.map(([, opts]) => opts.sql)).toEqual([ + 'SELECT filter_options', 'SELECT * FROM t WHERE origin={origin:String}', + ]); + expect(deps.persistFilterCurated).toHaveBeenCalledWith(expect.objectContaining({ + origin: expect.objectContaining({ declaredType: 'String' }), + })); + expect(deps.persistFilterActive).toHaveBeenCalledWith(expect.objectContaining({ origin: false })); + expect(hooks.renderFilterBar).toHaveBeenCalled(); + expect(hooks.renderFilterDiagnostics).toHaveBeenCalled(); + }); + + it('does not persist filterActive when the merge changes nothing', async () => { + const executeRead = vi.fn(async (result: StreamResult, opts: ExecuteReadRequest) => { + if (opts.sql === 'SELECT filter_options') { + result.columns = [{ name: 'origin', type: 'Array(String)' }]; + result.rows = [[['ATL']]]; + return result; + } + result.columns = [{ name: 'k', type: 'String' }]; + result.rows = [['a']]; + return result; + }); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + deps.varValuesObj.origin = 'ATL'; + deps.filterActiveObj.origin = true; + const queries = [fav('p', 'SELECT * FROM t WHERE origin={origin:String}')]; + const filters = [filterFav('f', 'SELECT filter_options')]; + const session = createDashboardSession(deps, { panelFavorites: queries, filterFavorites: filters }); + await session.runAll(); + expect(deps.persistFilterActive).not.toHaveBeenCalled(); + }); + + it('a failed Filter query reports a diagnostic and still runs Panels', async () => { + const executeRead = vi.fn(async (result: StreamResult, opts: ExecuteReadRequest) => { + if (opts.sql === 'SELECT filter_options') { result.error = 'boom'; return result; } + result.columns = [{ name: 'k', type: 'String' }]; + result.rows = [['a']]; + return result; + }); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const queries = [fav('p', 'SELECT {x:String}')]; + const filters = [filterFav('f', 'SELECT filter_options')]; + const session = createDashboardSession(deps, { panelFavorites: queries, filterFavorites: filters }); + await session.runAll(); + expect(hooks.renderFilterDiagnostics).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ code: 'filter-query-failed', sourceId: 'f' }), + ])); + }); + + it('a Filter SQL contract violation (own diagnostic) issues no request', async () => { + const executeRead = vi.fn(async (result: StreamResult, opts: ExecuteReadRequest) => { + result.columns = []; result.rows = []; void opts; return result; + }); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const queries = [fav('p', 'SELECT 1')]; + const filters = [filterFav('f', 'SELECT 1 FORMAT CSV')]; + const session = createDashboardSession(deps, { panelFavorites: queries, filterFavorites: filters }); + await session.runAll(); + expect(executeRead.mock.calls.map(([, opts]) => opts.sql)).toEqual(['SELECT 1']); // never the Filter source + }); +}); + +describe('createDashboardSession — retryFilter (#237)', () => { + const filterFav = (id: string, sql: string): SavedQueryV2 => savedQuery({ + id, sql, favorite: true, dashboard: { role: 'filter' }, + }); + + it('is a no-op for an unknown sourceId (no query/slot found)', async () => { + const deps = makeDeps(); + const session = createDashboardSession(deps, { panelFavorites: [], filterFavorites: [] }); + await session.retryFilter('nonexistent'); + expect(deps.ensureFreshToken).not.toHaveBeenCalled(); + }); + + it('auth preflight failure fires onAuthFailed and never re-issues the Filter request', async () => { + const executeRead = vi.fn(async (result: StreamResult, opts: ExecuteReadRequest) => { + if (opts.sql === 'SELECT filter_options') { result.error = 'boom'; return result; } + result.columns = [{ name: 'k', type: 'String' }]; + result.rows = [['a']]; + return result; + }); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const queries = [fav('p', 'SELECT {x:String}')]; + const filters = [filterFav('f', 'SELECT filter_options')]; + const session = createDashboardSession(deps, { panelFavorites: queries, filterFavorites: filters }); + await session.runAll(); + executeRead.mockClear(); + (deps.ensureFreshToken as ReturnType).mockResolvedValue(false); + await session.retryFilter('f'); + expect(hooks.onAuthFailed).toHaveBeenCalledTimes(1); + expect(executeRead).not.toHaveBeenCalled(); + }); + + it('retries a single failed source and cascades the reconciled value to the affected Panel', async () => { + let attempt = 0; + const executeRead = vi.fn(async (result: StreamResult, opts: ExecuteReadRequest) => { + if (opts.sql === 'SELECT filter_options') { + attempt++; + if (attempt === 1) { result.error = 'temporary'; return result; } + result.columns = [{ name: 'x', type: 'Array(String)' }]; + result.rows = [[['new']]]; + return result; + } + result.columns = [{ name: 'k', type: 'String' }]; + result.rows = [['a']]; + return result; + }); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + deps.varValuesObj.x = 'stale'; + deps.filterActiveObj.x = true; + const queries = [fav('p', 'SELECT {x:String}')]; + const filters = [filterFav('f', 'SELECT filter_options')]; + const session = createDashboardSession(deps, { panelFavorites: queries, filterFavorites: filters }); + await session.runAll(); + expect(attempt).toBe(1); + executeRead.mockClear(); + await session.retryFilter('f'); + expect(attempt).toBe(2); + // The reconciled value ('stale' isn't in the new options) deactivates and + // cascades a re-run of the affected Panel. + expect(executeRead.mock.calls.map(([, opts]) => opts.sql)).toEqual(['SELECT filter_options', 'SELECT {x:String}']); + }); +}); + +describe('createDashboardSession — runAffected before any run', () => { + it('is a no-op before slots exist (nothing has ever run)', async () => { + const deps = makeDeps(); + const session = createDashboardSession(deps, { panelFavorites: [fav('1', 'SELECT {x:String}')], filterFavorites: [] }); + expect(await session.runAffected('x')).toBeUndefined(); + expect(deps.ensureFreshToken).not.toHaveBeenCalled(); + }); + + it('a failed auth preflight fires onAuthFailed and issues no requests', async () => { + const executeRead = vi.fn(async (result: StreamResult) => { result.columns = [{ name: 'k', type: 'String' }]; result.rows = [['a']]; return result; }); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const q = fav('1', 'SELECT {year:UInt16} AS n'); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + await session.runAll(); + executeRead.mockClear(); + (deps.ensureFreshToken as ReturnType).mockResolvedValue(false); + expect(await session.runAffected('year')).toBeUndefined(); + expect(hooks.onAuthFailed).toHaveBeenCalledTimes(1); + expect(executeRead).not.toHaveBeenCalled(); + }); +}); + +describe('createDashboardSession — getFilterField / controls', () => { + it('exposes one control per declared {name:Type} and its field state', () => { + const deps = makeDeps(); + const q = fav('1', 'SELECT {x:String}'); + const session = createDashboardSession(deps, { panelFavorites: [q], filterFavorites: [] }); + expect(session.controls.map((c) => c.name)).toEqual(['x']); + expect(session.getFilterField('x', 'input').state).toBe('missing'); + }); +}); + +describe('createDashboardSession — destroy()', () => { + it('is safe when idle (never run)', () => { + const deps = makeDeps(); + const session = createDashboardSession(deps, { panelFavorites: [fav('1', 'SELECT {x:String}')], filterFavorites: [] }); + expect(() => session.destroy()).not.toThrow(); + expect((deps.hooks.disposeFilterBar as ReturnType)).toHaveBeenCalledTimes(1); + }); + + it('aborts every in-flight tile and KPI-source request, tears down the tile chart instance, and disposes the filter bar', async () => { + const { executeRead, signals, resolvers } = deferredExec(); + const explicit: Panel = { cfg: { type: 'kpi' } }; + // One tile with a live chart to tear down, one tile with none (destroy is + // a no-op there), and one KPI source (never carries a `destroy` field). + const tileWithChart = makeTileSlot({ destroy: vi.fn() }); + const tileWithoutChart = makeTileSlot(); + const kpiSlot = makeKpiSlot({ explicit }); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [tileWithChart, tileWithoutChart, kpiSlot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const queries = [fav('1', 'SELECT 1'), fav('2', 'SELECT 2'), fav('3', 'SELECT 3', explicit)]; + const session = createDashboardSession(deps, { panelFavorites: queries, filterFavorites: [] }); + const p = session.runAll(); + await flush(); + expect(signals).toHaveLength(3); // no Filter favorites — the filter wave resolved instantly + const tileDestroy = tileWithChart.destroy as ReturnType; + session.destroy(); + expect(signals.every((s) => s?.aborted)).toBe(true); + expect(tileDestroy).toHaveBeenCalledTimes(1); + expect(tileWithChart.destroy).toBeNull(); + expect(hooks.disposeFilterBar).toHaveBeenCalledTimes(1); + // Draining the aborted requests must never fire a hook (stale generation). + (hooks.tile.applyResult as ReturnType).mockClear(); + (hooks.kpi.applyResult as ReturnType).mockClear(); + resolvers.splice(0).forEach((r) => r({ columns: [{ name: 'k', type: 'String' }], rows: [['a']] })); + await p.catch(() => {}); + await flush(); + expect(hooks.tile.applyResult).not.toHaveBeenCalled(); + expect(hooks.kpi.applyResult).not.toHaveBeenCalled(); + }); + + it('aborts an in-flight Filter-source request', async () => { + const { executeRead, signals, resolvers } = deferredExec(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => []) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const filters = [savedQuery({ id: 'f', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } })]; + const session = createDashboardSession(deps, { panelFavorites: [], filterFavorites: filters }); + const p = session.runAll(); + await flush(); + expect(signals).toHaveLength(1); + session.destroy(); + expect(signals[0]!.aborted).toBe(true); + expect(hooks.disposeFilterBar).toHaveBeenCalledTimes(1); + resolvers.splice(0).forEach((r) => r({ columns: [{ name: 'origin', type: 'Array(String)' }], rows: [[['ATL']]] })); + await p.catch(() => {}); + }); + + it('is safe to call again after a Filter source already completed (no live abortController left to abort)', async () => { + const executeRead = vi.fn(async (result: StreamResult) => { + result.columns = [{ name: 'origin', type: 'Array(String)' }]; + result.rows = [[['ATL']]]; + return result; + }); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => []) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const filters = [savedQuery({ id: 'f', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } })]; + const session = createDashboardSession(deps, { panelFavorites: [], filterFavorites: filters }); + await session.runAll(); + expect(() => session.destroy()).not.toThrow(); + }); + + it('turns every later entry point into a no-op — an orphaned debounce timer firing after teardown issues no request and no auth preflight', async () => { + const executeRead = vi.fn(async (result: StreamResult) => result); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [makeTileSlot()]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const filters = [savedQuery({ id: 'f', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } })]; + const session = createDashboardSession(deps, { panelFavorites: [fav('1', 'SELECT {x:String}')], filterFavorites: filters }); + session.destroy(); + const preflight = deps.ensureFreshToken as ReturnType; + preflight.mockClear(); + executeRead.mockClear(); + await session.runAll(); + await session.runAffected('x'); + await session.retryFilter('f'); + expect(preflight).not.toHaveBeenCalled(); + expect(executeRead).not.toHaveBeenCalled(); + expect(hooks.onAuthFailed).not.toHaveBeenCalled(); + }); + + it('destroy() during a pending token preflight stops the wave — no request, no generation reserved, no onAuthFailed', async () => { + const executeRead = vi.fn(async (result: StreamResult) => result); + let releasePreflight!: (ok: boolean) => void; + const ensureFreshToken = vi.fn(() => new Promise((resolve) => { releasePreflight = resolve; })); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [makeTileSlot()]) }); + const deps = makeDeps({ exec: { executeRead }, ensureFreshToken, hooks }); + const session = createDashboardSession(deps, { panelFavorites: [fav('1', 'SELECT 1')], filterFavorites: [] }); + const p = session.runAll(); + await flush(); + expect(ensureFreshToken).toHaveBeenCalledTimes(1); + session.destroy(); + releasePreflight(true); + await p; + expect(executeRead).not.toHaveBeenCalled(); + // The failure variant must not fire onAuthFailed against a torn-down session either. + const p2 = session.runAll(); + await p2; + expect(hooks.onAuthFailed).not.toHaveBeenCalled(); + }); +}); + +describe('createDashboardSession — stale-generation progress guard', () => { + it('a superseded request\'s late buffered chunk never reaches onProgress (would corrupt the newer wave\'s live label)', async () => { + // deferredExec captures each request's onChunk so the test can emit a + // chunk AFTER the slot was superseded — modelling ch-client's one-last- + // buffered-chunk-after-abort window. + const chunks: Array<() => void> = []; + const resolvers: Array<(v: unknown) => void> = []; + const signals: Array = []; + const executeRead = vi.fn((result: StreamResult, opts: { signal?: AbortSignal; onChunk?: () => void }) => { + signals.push(opts.signal); + if (opts.onChunk) chunks.push(opts.onChunk); + return new Promise((resolve) => { resolvers.push(() => resolve(result)); }); + }); + const slot = makeTileSlot(); + const hooks = makeHooks({ ensureSlotsBuilt: vi.fn(() => [slot]) }); + const deps = makeDeps({ exec: { executeRead }, hooks }); + const session = createDashboardSession(deps, { panelFavorites: [fav('1', 'SELECT 1')], filterFavorites: [] }); + const first = session.runAll(); + await flush(); + expect(chunks).toHaveLength(1); + // A live chunk paints progress for the current generation… + chunks[0]!(); + expect(hooks.tile.onProgress).toHaveBeenCalledTimes(1); + // …then a newer wave supersedes the slot; the old request's late chunk + // must be discarded, not forwarded to the (now-reassigned) live label. + const second = session.runAll(); + await flush(); + (hooks.tile.onProgress as ReturnType).mockClear(); + chunks[0]!(); + expect(hooks.tile.onProgress).not.toHaveBeenCalled(); + resolvers.splice(0).forEach((r) => r(undefined)); + await Promise.all([first.catch(() => {}), second.catch(() => {})]); + }); +}); diff --git a/tests/unit/filter-bar.test.ts b/tests/unit/filter-bar.test.ts index e35d7b2..d7444c8 100644 --- a/tests/unit/filter-bar.test.ts +++ b/tests/unit/filter-bar.test.ts @@ -7,7 +7,8 @@ import { makeApp } from '../helpers/fake-app.js'; // The field-family construction, debounce, commit, conflict, and optional // behavior of buildFilterBar are exercised end-to-end through the dashboard // suite (renderDashboard → buildFilterBar). These tests cover the extraction's -// own seams: the injected document realm and the accessible-group label (#185). +// own seams: the injected document realm, the accessible-group label (#185), +// and the dispose seam (#276 Phase 3b). const paramsFor = (sql: string): FieldControl[] => fieldControls(analyzeParameterizedSources([{ id: 't', kind: 'tab', sql, bindPolicy: 'row-returning' }])); const okField = (): PreparedFieldState => ({ state: 'ok' }); @@ -22,26 +23,27 @@ describe('buildFilterBar (shared filter row)', () => { okField, { document, ariaLabel: 'Query filters' }, ); - expect(bar.getAttribute('role')).toBe('group'); - expect(bar.getAttribute('aria-label')).toBe('Query filters'); - expect(bar.querySelectorAll('.var-field').length).toBe(1); - expect(bar.style.display).not.toBe('none'); + expect(bar.el.getAttribute('role')).toBe('group'); + expect(bar.el.getAttribute('aria-label')).toBe('Query filters'); + expect(bar.el.querySelectorAll('.var-field').length).toBe(1); + expect(bar.el.style.display).not.toBe('none'); }); it('renders a hidden-but-labeled empty bar when there are no params', () => { const app = makeApp(); const bar = buildFilterBar(app, [], () => {}, okField, { ariaLabel: 'Query filters' }); - expect(bar.style.display).toBe('none'); - expect(bar.getAttribute('aria-label')).toBe('Query filters'); - expect(bar.querySelectorAll('.var-field').length).toBe(0); + expect(bar.el.style.display).toBe('none'); + expect(bar.el.getAttribute('aria-label')).toBe('Query filters'); + expect(bar.el.querySelectorAll('.var-field').length).toBe(0); + expect(() => bar.dispose()).not.toThrow(); // no fields, no timers — a no-op }); it('defaults to app.document and no group role when no options are passed', () => { const app = makeApp(); const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, okField); - expect(bar.getAttribute('role')).toBeNull(); - expect(bar.getAttribute('aria-label')).toBeNull(); - expect(bar.querySelectorAll('.var-field').length).toBe(1); + expect(bar.el.getAttribute('role')).toBeNull(); + expect(bar.el.getAttribute('aria-label')).toBeNull(); + expect(bar.el.querySelectorAll('.var-field').length).toBe(1); }); it('exposes the shared debounce constant', () => { @@ -54,15 +56,15 @@ describe('buildFilterBar (shared filter row)', () => { const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), onCommit, okField, { curatedFields: { x: { options: [{ value: 'a', label: 'Alpha' }] } }, }); - document.body.appendChild(bar); - bar.querySelector('input')!.dispatchEvent(new Event('focus')); - bar.querySelector('[role="option"]')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + document.body.appendChild(bar.el); + bar.el.querySelector('input')!.dispatchEvent(new Event('focus')); + bar.el.querySelector('[role="option"]')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); expect(app.state.varValues.x).toBe('a'); expect(app.state.filterActive.x).toBe(true); expect(app.saveVarValues).toHaveBeenCalled(); expect(app.saveFilterActive).toHaveBeenCalled(); expect(onCommit).toHaveBeenCalledWith('x'); - bar.remove(); + bar.el.remove(); }); it('marks a curated field is-optional when its param is optional, same as a plain field', () => { @@ -73,7 +75,7 @@ describe('buildFilterBar (shared filter row)', () => { () => {}, okField, { curatedFields: { y: { options: [{ value: 'a', label: 'Alpha' }] }, x: { options: [{ value: 'b', label: 'Beta' }] } } }, ); - const fields = [...bar.querySelectorAll('.var-field')]; + const fields = [...bar.el.querySelectorAll('.var-field')]; expect(fields.map((f) => f.querySelector('.var-name')!.textContent)).toEqual(['y', 'x']); expect(fields.map((f) => f.classList.contains('is-optional'))).toEqual([false, true]); expect(fields.every((f) => f.classList.contains('is-curated'))).toBe(true); @@ -88,7 +90,7 @@ describe('buildFilterBar (shared filter row)', () => { const bar = buildFilterBar(app, params, () => {}, okField, { curatedFields: { x: { options: [{ value: 'a', label: 'Alpha' }] } }, }); - const input = bar.querySelector('input')!; + const input = bar.el.querySelector('input')!; expect(input.classList.contains('is-conflict')).toBe(true); expect(input.title).toContain('Conflicting type declarations: UInt64 vs String'); }); @@ -99,8 +101,25 @@ describe('buildFilterBar (shared filter row)', () => { const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, invalidField, { curatedFields: { x: { options: [{ value: 'a', label: 'Alpha' }] } }, }); - const input = bar.querySelector('input')!; + const input = bar.el.querySelector('input')!; expect(input.classList.contains('is-invalid')).toBe(true); expect((input as HTMLInputElement).title).toBe('Bad value'); }); + + it('dispose() clears a pending debounce timer so a later value edit never fires the stale commit (#276)', () => { + vi.useFakeTimers(); + try { + const app = makeApp(); + const onCommit = vi.fn(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), onCommit, okField); + const input = bar.el.querySelector('input')! as HTMLInputElement; + input.value = 'a'; + input.dispatchEvent(new Event('input', { bubbles: true })); // arms the debounce + bar.dispose(); + vi.advanceTimersByTime(FILTER_DEBOUNCE_MS + 10); + expect(onCommit).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); });