Skip to content

Replace curated Dashboard filters with inferred Variables and batched option queries #447

Closed

Description

@BorisTyshkevich

Depends on: #427

Blocks the Variables assignment part of #428. Panel assignment in #428 may be implemented independently.

Supersedes the curated-filter/provider direction and #450.

Phases

Owner decisions

The existing curated/free filter model is experimental and must be removed rather than adapted.

The replacement is a Dashboard-local Variables model:

  • variables are inferred from {name:Type} placeholders in queries owned by Dashboard panels;
  • the variable name is matched exactly and case-sensitively;
  • all declarations of the same name in one Dashboard must use the same ClickHouse type;
  • a variable may have optional Dashboard-local option SQL;
  • empty option SQL means the variable uses an automatic direct-input control;
  • configured option SQL produces exactly two String columns: value and visible label;
  • configured option queries are compiled into one UNION ALL request per refresh;
  • option SQL may not contain {name:Type} parameters in this issue;
  • cascading/dependent variables are explicitly out of scope;
  • no production-grade migration of experimental curated-filter documents is required.

Phase 1 is intentionally subtractive: remove the complete old curated-filter/provider implementation before building the new runtime, so later agents cannot accidentally extend the abandoned architecture.

Goal

Make Dashboard variables derive directly from the panel SQL that consumes them.

Example panel query:

SELECT
    event_time,
    amount
FROM sales
WHERE country = {country:String}
  AND city = {city:String}
  AND event_date >= {from:Date}
  AND event_date <= {to:Date}

The Dashboard derives:

country : String
city    : String
from    : Date
to      : Date

The user may optionally provide option SQL for country and city. from and to may remain direct inputs.

There are no persisted filter objects, provider objects, target-panel lists, filter-owned saved queries, filter roles, or query-name-based bindings.

Terminology

Inferred variable

A distinct exact variable name found in one or more panel-owned Dashboard queries.

Variable configuration

Dashboard-local optional SQL stored under a variable name. It provides predefined values for that variable.

Active variable

An inferred variable whose declarations have one consistent type.

Conflicted variable

An inferred variable name declared with two or more different types.

Orphaned configuration

Stored variable configuration whose name is no longer referenced by any Dashboard panel query.

Persisted model

Store only Dashboard-local option-query configuration. Exact generated type names may follow repository conventions, but the semantics are:

interface DashboardVariableConfig {
  sql: string;

  // Informational only, used to display an orphan after its last declaration
  // disappears. A newly inferred declaration always wins.
  lastKnownType?: string;
}

interface DashboardDocument {
  // Existing fields...
  variableConfigs?: Record<string, DashboardVariableConfig>;
}

Do not persist:

  • a second variable/filter ID;
  • a label separate from the variable name;
  • panel targets;
  • control type;
  • visualisation type;
  • selection mode;
  • saved-query ID;
  • ownership role;
  • provider/source metadata.

The authoritative variable name and type come from panel query placeholders.

Current selected values are Dashboard runtime/session state, not new document configuration introduced by this issue.

Use a clear next document/schema version. Generated schemas, codecs, validators, fixtures, examples, and types must move together.

Variable inference

For every panel tile in a Dashboard:

  1. resolve its owned query;
  2. parse all {name:Type} placeholders from the executable SQL;
  3. record variable name, declared type, Dashboard ID, tile ID, query ID, and source location where available;
  4. aggregate declarations by exact variable name.

Adding or removing a panel query automatically updates the effective Variables list. Creating a variable row is never a separate user action.

Type consistency

These declarations are compatible:

-- Panel A
WHERE country = {country:String}

-- Panel B
WHERE billing_country = {country:String}

These declarations create a conflict:

-- Panel A
WHERE customer_id = {customer_id:UInt64}

-- Panel B
WHERE customer_id = {customer_id:String}

A conflicted variable:

  • renders as one Variables-tree row;
  • shows every distinct declared type;
  • is marked with error styling, an error icon, and accessible error text;
  • lists the conflicting panel/query usages in its hover diagnostic;
  • does not render a working Dashboard control;
  • is excluded from option-query execution;
  • blocks only panels that require that variable;
  • resolves automatically when panel declarations become consistent.

Do not rely on colour alone.

Variables hierarchy

Replace the old filter hierarchy with a Variables subtree:

Dashboard
  Variables · 5
    country : String
    city : String
    from : Date
    customer_id : String | UInt64   error
    region : String                 unused   [trash]
  Panels · 3

Requirements:

  • show every distinct inferred variable name;
  • show the inferred ClickHouse type next to the name;
  • a conflicted name counts as one row and displays all distinct types;
  • include preserved orphaned configurations;
  • the group count is distinct inferred names plus orphaned configuration names;
  • row search includes variable name, type, and stored SQL where practical;
  • clicking anywhere on a row opens that variable's option SQL (a main-editor tab since Remove the variable drawer and edit option SQL in a dedicated main-editor tab #457);
  • do not add an overflow (...) menu;
  • show a trailing trash icon only for orphaned configurations;
  • clicking trash must not also open the variable's tab;
  • active and conflicted inferred variables never show trash.

Conflict tooltip

Example:

Variable “customer_id” has incompatible types:

Orders panel:  {customer_id:UInt64}
Revenue panel: {customer_id:String}

Orphaned configuration

When the final panel reference to a configured variable disappears:

  • preserve its SQL;
  • retain its last inferred type as lastKnownType for display only;
  • show it in the Variables subtree with warning styling, warning icon, and unused status;
  • hide it from the normal Dashboard controls;
  • exclude it from the generated batch query;
  • allow opening its editor;
  • show a trailing trash icon.

Tooltip:

Variable “region” is not referenced by any Dashboard panel.
Its option SQL is preserved but will not be executed.

Deleting an orphan removes only variableConfigs.region, including SQL and lastKnownType. It does not edit panel queries. Require confirmation because SQL will be lost.

If no inferred declaration and no stored configuration remain, remove the row immediately.

Variable SQL editor

Superseded by #457 (shipped). Phase 1 delivered this as a dedicated right-side
drawer. That was the wrong direction — it duplicated the editing machinery the
application already has — so #457 deleted the drawer and moved option SQL into the
main editor as a dedicated Variable: <name> tab, identified by
(dashboardId, variableName). The requirements below still hold except where
noted; "the editor" now means that tab, and the drawer-local Run/Test action is
gone (see #465).

Clicking any Variables row switches to the Query surface and opens that Dashboard
variable's option SQL in its own main-editor tab.

Requirements:

Option SQL contract

Configured option SQL must be one embeddable read query (SELECT or WITH ... SELECT) and return zero or more rows with exactly two columns:

column 1: String value
column 2: String visible label

Column position defines meaning. Column names do not.

Example:

SELECT
    toString(country_id),
    country_name
FROM countries
ORDER BY country_name

If value and label are the same:

SELECT
    environment,
    environment
FROM environments
ORDER BY environment

Reject or diagnose locally:

  • zero or one result column;
  • three or more result columns;
  • either result column not being String;
  • multiple SQL statements;
  • non-embeddable output clauses such as FORMAT or INTO OUTFILE;
  • any {name:Type} placeholder in option SQL;
  • syntax or execution errors.

Trailing semicolon handling must be deterministic: strip one harmless final semicolon before embedding or reject it consistently.

Duplicate values collapse deterministically in application code, preserving the first returned row and label. Option order follows query result order.

Use the application's bounded result policy; option queries must not create an unbounded client-side result.

No cascading variables in this issue

Option SQL must contain no Dashboard/query parameters.

Reject this for now:

SELECT toString(id), name
FROM cities
WHERE country = {country:String}

Diagnostic:

Variable option queries cannot reference Dashboard variables yet.

This deliberately avoids dependency graphs, cycle detection, upstream waiting states, invalidating downstream selections, and partial batch execution.

Cascading variables may be designed in a separate follow-up issue.

Batched option-query execution

During Dashboard refresh, include every variable that is:

  • inferred;
  • type-consistent;
  • non-orphaned;
  • configured with non-empty, locally acceptable option SQL.

Compile one ClickHouse query:

SELECT 'country' AS __variable_name,
       tupleElement(tuple(*), 1) AS __variable_value,
       tupleElement(tuple(*), 2) AS __variable_label
FROM (
    SELECT toString(country_id), country_name
    FROM countries
) LIMIT 1001

UNION ALL

SELECT 'city' AS __variable_name,
       tupleElement(tuple(*), 1) AS __variable_value,
       tupleElement(tuple(*), 2) AS __variable_label
FROM (
    SELECT toString(city_id), city_name
    FROM cities
) LIMIT 1001

Corrected while shipping phase 2. This snippet originally read
SELECT 'country' AS __variable_name, * FROM ( … ). That cannot be read
correctly: the streaming transport sends each row as a JSON object keyed by
column name, so two identically-named columns collapse on parse — and
SELECT environment, environment FROM environments, this issue's own
value==label example, is exactly that shape. In a UNION ALL the result column
names come from the FIRST branch alone, so one branch with a duplicate name
silently rewrote a LATER branch's value into its label (reproduced on ClickHouse
26.6). Taking the user's columns BY POSITION is what this issue actually
specifies, and it yields names that cannot collide. tuple/tupleElement exist
since ClickHouse 20.4; the positional wire format that would have avoided the
SQL change first shipped in 25.2 and was therefore rejected. The per-branch
LIMIT is the bounded-results policy applied per variable, so one long list
cannot crowd out the variables after it. See the ADR-0003 phase-2 addendum.

Requirements:

  • generate branch tags from exact variable names using safe SQL literal escaping/parameters;
  • branch order is deterministic and follows Variables ordering;
  • user SQL is always nested as a subquery;
  • all branches are compatible because user queries return exactly two String columns;
  • parse the response by column position as variable name, value, label;
  • partition rows by exact variable name;
  • execute no options request when no valid configured variables exist;
  • normal operation uses one request regardless of the number of configured variables.

A combined query failure is a batch-level failure in this issue. Show the failure in the Variables/filter-control area and make option-backed controls unavailable. Running ONE variable's SQL on its own — in its main-editor tab (#457) — is the diagnostic path. Do not automatically fall back to N separate runtime queries in the initial implementation.

Dashboard controls and binding

A Dashboard variable automatically binds to every panel query on the same Dashboard that declares the exact same variable name.

There is no persisted target list.

Variable without option SQL

Render a direct-input control inferred from its ClickHouse type, for example:

  • String -> text input;

  • Date -> date input;

  • DateTime / DateTime64 -> datetime input;

  • integer/float types -> numeric input;

  • Bool -> checkbox;

  • unsupported compound types such as Array, Tuple, Map, or Nested -> local unsupported-type diagnostic in this issue.

    Narrowed by Restore the searchable multi-select for Array(scalar T) Dashboard variables with option SQL #468 (shipped). An Array of a SCALAR that carries option SQL
    now renders the searchable multi-select instead — several option rows combine
    into one bound array. The unsupported-type diagnostic still stands for
    Tuple, Map, Nested, a nested Array(Array(T)), and for an
    Array(scalar T) with no option SQL configured.

Variable with option SQL

Render one single-select control from the batched result rows.

Rules:

  • do not silently select the first row;
  • a variable begins unset unless existing runtime behaviour already supplies a value explicitly;
  • allow clearing a selection back to unset;
  • panels wait rather than execute when a required variable is unset;
  • changing a variable reruns only panels that reference that variable;
  • option-query refresh remains one compiled request for all configured variables in the initial implementation.

Conflicted and orphaned variables do not render active controls.

Copy/paste and #428 integration

Panel assignment in #428 remains unchanged: copying a Library query to Panels creates a panel-owned saved-query copy and tile.

Variable assignment is different:

Library query
  -> drag onto an existing Variables row
  -> copy only its SQL text into dashboard.variableConfigs[variableName].sql

Requirements:

  • the target variable must already exist through panel-query inference;
  • do not create a saved-query clone for option SQL;
  • do not create a filter/query role;
  • do not store a source query ID or live Library reference;
  • later Library edits do not mutate the Dashboard variable SQL;
  • dropping or pasting parameterised SQL produces the no-cascading diagnostic;
  • the same Library query may separately be copied to a panel and copied as SQL text to a variable.

No configuration dialog, parameter picker, target picker, default picker, selection-mode picker, or provider mapping is needed.

Remove the old curated-filter system completely

Phase 1 must remove, not deprecate or wrap, code whose only purpose is the abandoned filter/provider design.

Remove from persisted models, generated schemas, validators, codecs, imports/exports, and migrations:

  • curated/free DashboardFilter definitions;
  • filter IDs and source query IDs;
  • targets and selection-mode fields;
  • filter-owned saved-query references;
  • saved-query filter ownership role;
  • shared filter-source allowances;
  • filter-provider/source metadata.

Remove from runtime and application services:

  • implicit filter synthesis from Library or favourite queries;
  • provider discovery by output-column name;
  • global provider/helper collection;
  • provider merging and duplicate-provider arbitration;
  • identical-provider collapse;
  • source execution deduplication for several filters;
  • one query providing several filter arrays;
  • array/multiselect parsing and state;
  • per-filter panel targets;
  • old filter attribution and ownership commands;
  • old filter import/export dependency closure.

Remove from UI:

  • the old Dashboard curated-filter visualisation/container;
  • rendering filter-role queries as panels or previews;
  • filter-role badges and icons used only by the old model;
  • old add/edit/filter configuration dialogs;
  • old free-filter hierarchy decisions and rows;
  • provider/duplicate/multiselect toasts and diagnostics;
  • any fallback copy telling users to select a Filter panel type.

Remove or rewrite:

  • old examples and demo workspaces;
  • fixtures and snapshots;
  • CSS and menu items;
  • ADR/design text;
  • unit, integration, and E2E tests for the old topology.

There must be no compatibility branch that allows both old filters and new Variables at runtime.

Panel visualisation selector cleanup

Filter is not a panel visualisation and no longer exists as a saved-query role.

Remove from both Dashboard view and the panel-type selector:

  • the Filter option;
  • the Dashboard role category;
  • category headings such as Preview…, Panel, and Dashboard role when they no longer distinguish selectable kinds;
  • any rolePreviewView or equivalent mapping;
  • filter-specific panel previews.

The selector becomes a flat list of actual panel visualisations only.

Migration policy

There is no production legacy to preserve.

Requirements:

  • do not build a complex lossless migration from curated filters/providers;
  • update bundled examples, test documents, and development fixtures to the new schema;
  • experimental documents containing the removed filter representation may be rejected as unsupported and recreated/reset;
  • document the development reset path where needed;
  • do not retain old runtime types merely to decode obsolete test data.

Delivery phases

Phase 1 / PR 1 — Hard removal and Variables foundation

This phase must remove the old architecture before the new option runtime is introduced.

Implement:

  • next document/schema version with variableConfigs;
  • complete removal of curated-filter/provider persisted types and saved-query filter role;
  • complete removal of old filter runtime, visualisation, selector, examples, tests, and documentation;
  • flat panel visualisation selector containing only real visualisations;
  • placeholder parser and variable aggregation from panel-owned queries;
  • exact-name/type consistency validation;
  • active, conflicted, and orphaned variable model;
  • Variables subtree showing name : Type;
  • conflict and orphan styling/tooltips;
  • row click opens the variable SQL editor shell;
  • orphan-only trash icon and atomic configuration deletion;
  • editor supports saving/removing Dashboard-local SQL, but option execution may remain stubbed until Phase 2.

Phase 1 acceptance gate:

No reachable schema, runtime, UI, example, test, documentation, icon, menu, diagnostic, or generated type refers to curated filters, filter providers, filter-role saved queries, shared sources, arrays, or multiselect.

Note (#468). The "arrays, or multiselect" clause was a phase-1 REMOVAL gate
for the curated model, and it held. #468 later re-introduced a multiselect
control for Array(scalar T) variables on the inferred-variable model — no
curated filter, provider, target list or selection mode came back with it.

Phase 2 / PR 2 — Controls and batched option execution

Implement:

  • direct controls inferred from variable types;
  • single-select controls for configured variables;
  • individual editor Run/Test;
  • strict two-String-column validation;
  • parameter-free option-query validation;
  • deterministic UNION ALL compiler;
  • one-request execution and response partitioning;
  • unset/selection state;
  • panel waiting and selective panel refresh;
  • batch-level and per-variable diagnostics;
  • bounded option results.

Phase 3 / PR 3 — Assignment, examples, and integration

Implement:

Tests

Phase 1

  • adding a panel query containing {country:String} creates one country : String tree row;
  • several panels using {country:String} still produce one row;
  • exact names remain distinct (country and Country);
  • conflicting types produce one invalid row with all types and usages;
  • resolving panel SQL clears the conflict automatically;
  • removing the last usage of a configured variable creates an orphan row;
  • removing the last usage of an unconfigured variable removes the row;
  • trash appears only for orphaned configurations;
  • deleting an orphan removes only its stored configuration;
  • row click opens the variable editor;
  • panel selector has no Filter option or category headings;
  • old curated-filter/provider types and code paths are absent.

Phase 2

  • empty SQL renders a direct control inferred from type;
  • configured SQL renders a single-select control;
  • exactly two String columns are accepted;
  • wrong column counts and non-String columns fail locally;
  • parameterised option SQL is rejected;
  • no first-row auto-selection occurs;
  • duplicate values keep the first label and stable order;
  • two or more configured variables produce one deterministic batch request;
  • no configured variables produce no batch request;
  • orphaned and conflicted variables are excluded from the batch;
  • response rows are partitioned by exact variable name;
  • panels wait for required unset variables;
  • changing a value reruns only panels that reference it;
  • batch failure is visible and individual editor Run/Test remains available.

Phase 3

  • dragging a Library query onto a variable copies SQL text only;
  • no saved query, owner role, filter ID, or source reference is created;
  • later Library edits do not change copied variable SQL;
  • the same source can be copied independently to a panel and a variable;
  • parameterised dragged SQL receives the no-cascading diagnostic;
  • examples, import/export, and E2E flows use only the Variables model.

Acceptance criteria

  • Dashboard Variables are derived from panel {name:Type} declarations.
  • Exact variable names bind automatically to every matching panel query in the Dashboard.
  • Same-name type conflicts are visible in the tree and block only affected variables/panels.
  • Optional Dashboard-local SQL provides exactly two String columns per configured variable.
  • All configured option queries execute through one generated UNION ALL request.
  • Variables without option SQL use direct controls inferred from type.
  • Cascading/parameterised option queries are rejected in this issue.
  • Orphaned configurations are preserved, visibly marked, excluded from execution, and deletable with a trash icon.
  • The Variables tree has only two row operations: open by clicking the row and delete for orphans.
  • The old curated-filter/provider architecture is fully removed before the new runtime is implemented.
  • Filter is absent from Dashboard rendering and the panel visualisation selector.
  • Add Library drag assignment to Dashboard panels and variables #428 can copy Library SQL text onto an existing variable without creating another saved-query resource.

Non-goals

  • Cascading/dependent variable option queries.
  • Dependency graphs or cycle detection.
  • Multiselect or Array-valued variable controls. Reversed by Restore the searchable multi-select for Array(scalar T) Dashboard variables with option SQL #468 (shipped)
    for the narrow case of an Array of a SCALAR that carries option SQL; every
    other container type remains a non-goal.
  • One option query producing several variables directly; batching is generated by the application from independent per-variable SQL.
  • Several provider queries merged into one variable.
  • Per-panel target selection.
  • Fuzzy or case-insensitive name matching.
  • Manually creating, renaming, deleting, or changing the type of an active inferred variable from the tree.
  • Production-grade migration of experimental curated-filter documents.
  • Automatic N-query fallback after a combined options-query failure.
  • Touch-specific assignment UI.

Activity

  1. changed the title [-]Decide whether plain (source-less) Dashboard filters are a first-class persisted kind, then require curated sources[/-] [+]Replace Dashboard filter providers with one-query-one-filter controls[/+] on Jul 25, 2026
  2. changed the title [-]Replace Dashboard filter providers with one-query-one-filter controls[/-] [+]Replace curated Dashboard filters with inferred Variables and batched option queries[/+] on Jul 26, 2026
  3. added
    refactorRestructuring without user-facing behavior change
    and removed
    inboxFiled mid-task; not yet triaged into the roadmap
    on Jul 26, 2026
  4. BorisTyshkevich commented on Jul 26, 2026

    @BorisTyshkevich
    CollaboratorAuthor

    Ship log — #447

    Branch model: one PR per phase off main

    Phase Status PR
    1 — Hard removal and Variables foundation shipped #458
    2 — Controls and batched option execution shipped #462
    3 — Assignment, examples, and integration ready (unblocked) —

    Phase 1 — handoff

    Landed (PRs: #458, branch feat/variables-foundation-447p1, commits a87c45a + ac96b4a)

    • src/core/dashboard-variables.ts (+ .types.ts) — pure inference at 100%:
      inferDashboardVariables, bindableVariables, variableTileIds,
      normalizeVariableSql. Ordering is inferred variables in first-declaration
      order, then orphans sorted by name
      , and every consumer (tree, and phase 2's
      batch) must follow it.
    • Variables subtree in dashboard-tree-model.ts / ui/dashboard-tree.ts, and
      ui/variable-editor.ts (the drawer, modelled on ui/doc-pane.ts).
    • The provider layer deleted outright: filter-execution, filter-options,
      filter-selection, dashboard-filters, filter-option-field, filter-preview,
      multi-select-field, the filter role, implicit filter synthesis, provider
      discovery by output-column name, arrays/multiselect, per-filter targets.
    • stored-workspace v5, Dashboard document v2, portable bundle v2, with
      read-time migrations for v2/v3/v4 records and v1 bundles.
    • Dashboard time-range control: token-based From/To picker in the filter bar #335/Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334 re-based onto variables, not deleted.

    Owner decisions taken this run (NOT in the issue body — phase 2/3 must honour them)

    1. The time-range control stays. Removal was scoped to "curated filter
      infrastructure — everything connected to the SQL queries"; simple direct-input
      filter functionality stays in place. Dashboard time-range control: token-based From/To picker in the filter bar #335's compound From/To control and Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334's
      crosshair/brush therefore survive, re-based onto inferred variables.
    2. Examples get the mechanical minimum in phase 1 — only their filter-role
      queries were removed. Their inert filters arrays remain and are dropped by the
      bundle migration on import. Phase 3 owns the real rewrite into
      variableConfigs option SQL plus the demo narrative.
    3. v4→v5 keeps every previously filter-owned query copy. Losing its last owner
      makes it a Library query, so no SQL is destroyed.
    4. A record carrying a filter-role query is REJECTED, not migrated. The role
      left the enum, so it fails its structural pass before migration. Chosen over
      forking query-spec/saved-query to stay lossless. Reset path is in the
      CHANGELOG.
    5. One PR rather than splitting phase 1.

    Decisions I took under ambiguity (no issue text covered them)

    • Runtime terminology deliberately NOT renamed. ViewerFilterState,
      setFilter/applyFilters, DashboardTimeRangeGroup.fromFilterId,
      ui/filter-bar.ts, .dash-filters etc. now denote VARIABLES. Keeping the names
      left time-range-field.ts, dashboard-chart-interaction.ts and two e2e fixtures
      untouched. Phase 2 should not rename them either unless it wants that diff —
      tracked in Remove the variable drawer and edit option SQL in a dedicated main-editor tab #457.
    • DashboardTimeRangeGroup.fromFilterId/toFilterId hold variable names.
    • Time-range pair discovery stays case-INSENSITIVE over the name table while
      variable identity is exact/case-sensitive. Consequence: From and from are two
      variables that both match one table entry, so neither wins and the pair is
      dropped. inferTimeRangePairs' cross-entry ambiguity guard became structurally
      unreachable and was deleted.
    • The viewer session keeps its TWO declaration analyses — analysis blanks text
      tiles (controls/execution); timeRangeAnalysis includes every tile (range
      membership). dashboard-viewer-session.test.ts asserts a group's
      tileIds === ['chart','text']. Do not collapse them.
    • resetFilters/clearAllFilters reset a variable to UNSET ('', inactive);
      there are no persisted defaults.
    • initialFilters (the Dashboard filter values are not persisted across reload (regression from #280 rewrite) #303 per-Dashboard value bag) is now keyed by variable
      NAME.
      The asb:dashFilters storage key is unchanged, so a stale entry surfaces
      only where an old filter id happened to equal a variable name.
    • A {kind:'variable'} focus target is retained UNCONDITIONALLY by
      presentMember — presence needs inference the pure surface model does not hold.
      Different rule from the old filter behaviour.
    • The panel picker keeps a disabled placeholder (Visualise…). Removing it as
      literally instructed left value='' matching no option, so the closed select
      rendered as a bare caret. A placeholder is the empty-state label, not a category
      heading.
    • Renaming the tree group invalidates persisted expandedGroups keys — a
      Dashboard whose Filters group was expanded opens with Variables collapsed once. No
      migration, by design.
    • The editor is a <textarea> behind an injected seam, not CodeMirror.
      app.VariableEditor exists and is honoured; nothing injects it yet. Phase 2 needs
      a real editor for Run/Test — see Remove the variable drawer and edit option SQL in a dedicated main-editor tab #457 item 1.

    Bug found and fixed

    Inference originally scanned raw SQL, so a variable declared only inside a
    /*[ … ]*/ optional block (#165) was invisible and its block could never have been
    activated from the Dashboard. It now scans analysisView — the same all-blocks-active
    materialization analyzeParameterizedSources uses — so the variable set and the
    control set agree by construction. There is a regression test.

    Deferred acceptance criteria — global bullets 4, 5, 6, 7 → phase 2; bullet
    12 → phase 3. Phase 1 claims 1, 2, 3, 8, 9, 10, 11.

    Specifically still to build in phase 2: the typed direct-input matrix and
    unsupported-compound (Array/Tuple/Map/Nested) diagnostics, option-backed
    single-selects, the strict two-String-column validation, parameter-free option-SQL
    validation with the no-cascading diagnostic, the deterministic UNION ALL compiler
    with safe branch tagging, one-request execution and response partitioning by exact
    name, bounded option results, unset/selection state, panel-waiting and selective
    refresh, and batch-level vs per-variable diagnostics.

    Note for phase 2: ViewerFilterState.options is always null and optionsRev
    always 0 today, and ui/dashboard.ts still reads both — they are the slots the
    batched option query should fill. FilterBarHandle.updateStatus currently has no
    production consumer.

    Follow-ups filed: #453 (inbox — split dashboard-viewer-session.ts, best done
    AFTER this lands since phase 1 deletes ~27% of it), #457 (inbox — CodeMirror for
    the variable editor, filter→variable terminology, dead seams, legacy schema note).

    Gate: 169 files / 5274 tests, per-file coverage met, tsc --noEmit /
    check:arch / check:schemas clean, build 1,802,556 bytes. e2e green on chromium +
    webkit (16 firefox failures are this sandbox's unshare CLONE_NEWPID EPERM limit,
    reproduced on main). Real-Chromium layout pass on the subtree and drawer.

    Phase 2 — handoff

    Landed (PR #462, branch feat/variable-controls-447p2, 4 commits)

    • src/core/variable-options.ts (+ .types.ts) at 100%: local validation, the
      deterministic one-request UNION ALL compiler, the response reader/partitioner,
      and compileOptionProbe (the editor's Test artifact, sharing the compiler's own
      nestBounded so Test cannot pass for SQL the batch would reject).
    • src/ui/filter-option-field.ts — recovered from phase 1's deletion, re-based on
      the new option model, plus its .filter-select/.var-combo-clear-inline CSS.
    • filter-bar.ts gains an opt-in variables map + setVariableOptions;
      dashboard-viewer-session.ts gains the batch runtime; variable-editor.ts gains
      Test via one injected app.runOptionQuery wired in app.ts.

    The single most important thing for phase 3 to not undo

    The compiled batch does not use the issue's original
    SELECT 'name' AS __variable_name, * FROM ( … ). It projects
    tupleElement(tuple(*), 1) / tupleElement(tuple(*), 2) instead. Reason: the
    streaming transport keys each row by column NAME, so two identically-named columns
    collapse on parse, and in a UNION ALL the names come from the first branch alone —
    so one branch with a duplicate name silently rewrote a LATER branch's value into its
    label. SELECT environment, environment FROM environments (the issue's own
    value==label example) is exactly that shape. Reproduced on ClickHouse 26.6.

    A positional wire format (JSONCompactStringsEachRowWithProgress) was implemented
    and then reverted: it first shipped in ClickHouse 25.2, which would have
    broken the feature entirely on older servers. tuple/tupleElement date from 20.4.
    Both local dev servers are 26.x, so this was invisible to manual testing — it came
    from review. The issue body's snippet and ADR-0003 are both reconciled; do not
    "simplify" it back.

    Owner decisions taken this run

    1. Bool → true/false suggestions, not a checkbox (owner-selected). A checkbox
      cannot represent UNSET, which "begins unset" / "clearable to unset" / "panels wait
      when unset" all require.
    2. Date-like and numeric controls got ZERO changes (owner-confirmed: "today it
      works fine, no need to invent new control and processing"). The existing
      relative-time combobox IS the app's date control; a native picker cannot express
      -1d/now. Numeric already has Typed validation for variable inputs — range-checked Int/UInt, Float syntax, inline error + run gating #170 validation.
    3. A container type (Array/Tuple/Map/Nested) is MARKED, not stripped. It
      keeps its text input beside a .var-unsupported note — param-serialize binds a
      literal typed there, and removing the field made an existing Dashboard strictly
      less capable (panels permanently unfilled with no way to fill them).

    Decisions taken under ambiguity

    • A configured-but-locally-invalid variable stays a SELECT and reports its own
      reason
      (ViewerFilterState.optionsError), rather than falling back to a text
      box — that fallback is indistinguishable from never having been configured, so the
      stored SQL was being silently ignored. A batch failure never overwrites that more
      specific per-variable reason. filterDiagnostics stays strictly batch-level.
    • configured means "has option SQL at all", not "…and it is valid" — published
      separately from options so a control's TYPE is stable from the first publish
      instead of starting as a text box and changing when the query lands.
    • Local validation is stricter than the issue lists, all for the same reason
      (anything that would corrupt the combined query must never enter it): SELECT/
      WITH only (isRowReturning also admits SHOW/DESC/EXPLAIN, none of which
      nest as a subquery), no unterminated string/comment, no /*[ … ]*/ optional block
      (activated by a parameter, which option SQL cannot have — so its content was
      silently dropped from what ran), and no user column named __variable_name
      (ClickHouse answers AMBIGUOUS_COLUMN_NAME for the whole batch).
    • The placeholder scan reads analysisView of the RAW text. Scanning the
      normalized text let a TRAILING optional block through, because
      normalizeOptionSql strips it as a closed comment.
    • A blank option VALUE is dropped (UNSET is '', so it could never be cleared);
      a blank LABEL is kept, and empty typed text now never matches it.
    • commitVariableConfig fires onWorkspaceExternallyChanged on success. The
      compiled batch is session-fixed and syncDocument never adopts variableConfigs,
      so without this a same-tab Save changed the document while the live controls kept
      running the old configuration. Phase 1 had the same staleness harmlessly.
    • Options never participate in the bar's rebuild signature. They go through
      setVariableOptions (in-place): a rebuild is triggered by a user commit, which is
      inherently typing-ending, but a batch lands asynchronously and would otherwise
      discard in-progress typing. This revived the dead FieldHandle seam and retired
      Allow Filter-role source queries to use root Dashboard parameters (single-layer dependencies) #360's statusSig, dead since phase 1.
    • The unavailable select is readOnly + aria-disabled, not disabled — a
      disabled input is not focusable, so the reason in its title was unreachable. Its
      × stays live so a failed batch does not trap a committed selection.
    • optionsRev bumps only on real content change, including a same-length list
      with different members.

    Deferred acceptance criteria — phase 2 claims global bullets 4, 5, 6, 7;
    bullet 12 (#428 copy Library SQL onto a variable) → phase 3. Bullet 4 is met on
    the Test path only — see #461 for why the batch cannot check column types.

    Still to build in phase 3 (blocked on #428): drag/drop of Library SQL onto a
    Variables row, the paste/copy workflow, the shipped-example rewrite (phase 1 left
    their inert filters arrays to the bundle migration), import/export coverage for
    variableConfigs, docs + E2E, and the final dead-code audit. Note there is still
    no e2e spec for the option runtime — tests/e2e/dashboard-tree.html seeds a
    variableConfigs entry but drives the sidebar only.

    Follow-ups filed: #461 (inbox — option-column type checking is Test-only; dedup
    runs after the server LIMIT). #457 remains open and is now more worth pairing
    with phase 3: the variable editor still mounts a <textarea>, and it now hosts Test.

    Update (#457 shipped, 2026-07-26). #457 resolved the editor question by
    deleting the drawer rather than upgrading it: option SQL is edited in the MAIN
    editor as a dedicated Variable: <name> tab, so it gets the real CodeMirror, the
    tab strip, the toolbar and the ordinary Run action for free. Consequences for
    phase 3:

    Gate: 172 files / 5500 tests, per-file coverage met, tsc --noEmit /
    check:arch / check:schemas clean, build 1,816,747 bytes. e2e 320 passed on
    chromium + webkit (10 firefox failures are this sandbox's unshare CLONE_NEWPID
    limit, reproduced on main). Verified in real Chromium against ClickHouse 26.6.

    Update (#428 merged, 2026-07-27). Phase 3 is no longer blocked. #428's
    PR #484 is merged and ships the Library→Dashboard drag, which discharges this issue's global
    acceptance bullet 12 (copy a Library query's SQL onto a variable). What #428
    did NOT take, and phase 3 therefore still owns:

    • the shipped-example rewrite into variableConfigs option SQL plus the demo
      narrative (phase 1 left their inert filters arrays to the bundle migration);
    • import/export coverage for variableConfigs;
    • docs and the final dead-code audit;
    • an e2e spec for the option runtime — tests/e2e/variable-tab.html mounts
      the whole app and is still the right place to extend.

    Two things #428 added that phase 3 should use rather than rebuild:
    src/application/library-assignment-service.ts (the commit plumbing, including
    the in-transform dirty-variable-tab gate) and
    src/dashboard/application/library-assignment.ts (the pure transforms). The
    keyboard-accessible Add to dashboard… chooser was deferred out of #428 to
    #483 and is not phase 3's to build either.

    Note tests/e2e/dashboard-tree.html now seeds a third, UNTILED query (q-lib)
    so its Library is non-empty — both previous queries were tiled.

  5. added 4 commits that reference this issue on Jul 26, 2026
  6. BorisTyshkevich commented on Jul 27, 2026

    @BorisTyshkevich
    CollaboratorAuthor

    Downstream note (2026-07-27) — no action needed here; recording where phase 1's
    hard removal left stale spec.

    While splitting #429 into phases, phase 1's subtractive removal turned out to have
    invalidated a large part of that issue's body, which still specified the
    curated-filter model in detail. #429 has now been rescoped against the shipped
    dashboard-v2 contract:

    • deleted its curated filter row gesture table, Curated-filter pencil
      action
      (parameter, label, targets, defaultValue, defaultActive,
      selection mode) and Curated-filter trash action;
    • deleted the updateOwnedFilter / removeDashboardFilter planner signatures,
      pointing instead at withVariableConfig +
      src/application/dashboard-variable-config.ts (Remove the variable drawer and edit option SQL in a dedicated main-editor tab #457) as the existing writer for
      the only persisted variable state;
    • corrected its panel-delete transformation, which still had steps to "scrub the
      tile ID from every filter's explicit targets[]" and "preserve absent targets
      as absent" — both meaningless now. Replaced with the actual consequence: if the
      removed tile held the last declaration of a variable name, its
      variableConfigs entry becomes an orphan, preserved and shown but never
      executed, and a tile delete must not silently drop it;
    • corrected "every panel tile and every curated filter references a dedicated
      saved-query copy" to panel-tiles-only, and StoredWorkspaceV3 → V5;
    • recorded that this issue's buildDeleteButton already ships an
      openMenu-anchored destructive confirmation for orphaned option SQL, so Dashboard tree management: direct Dashboard and Panel actions #429
      reuses that pattern rather than inventing a dialog shell.

    Variable-row management is explicitly left with this issue's phase 3 and #465,
    not folded into #429 — a variable is inferred rather than stored, so it has no
    pencil/trash symmetry with a panel tile, and its option-SQL delete already exists
    here.

    Phase 3 remains blocked on #428 (PR #484 open).

  7. added 2 commits that reference this issue on Jul 27, 2026
  8. added 14 commits that reference this issue on Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactorRestructuring without user-facing behavior change

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions