diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ca121c..bee7702d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,199 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Added +- **Dashboard v1 contracts, codecs, canonical encoding, and resource limits** + (#283, phase 1 of #280). New canonical JSON Schemas ship through the existing + manifest pipeline with generated types and Ajv-compiled validators: + `stored-workspace-v1`, `dashboard-v1` (with `DashboardTileV1`, + `DashboardFilterDefinitionV1`, `DashboardLayoutDocumentV1`, and the + `DashboardLayoutFallbackV1 = flow@1` fallback), `dashboard-layout-flow-v1` + (the normative `flow@1` contract with the closed `FlowTilePlacementV1` — + span 1|2|3, height compact|medium|large — and the presets + full-width|report|columns-2|columns-3), and `portable-bundle-v1` + (`queries` + `dashboards`, both required even when empty). `query-spec-v1` + gained the additive `QueryDashboardPresentationV1` (role/defaultVariant/ + variants/sizeHints) plus `QueryPresentationPatchV1` (RFC 7396 merge-patch of + the panel spec) — a revision, not a new Spec version; the schema-driven Spec + editor autocomplete picks the new fields up automatically. Pure `src/` + additions: one canonical deterministic encoder (`dashboard/model/canonical-json.ts`, + shared by export/persistence/hashing/equality), a deterministic + numeric-aware diagnostic sorter, `PORTABLE_LIMITS` enforcement + (schema bounds + codec UTF-8-byte/depth guards + runtime re-checks), and + whole-workspace cross-resource semantic validation. No persistence, no UI, + no import planner yet (later phases of #280). + +- **Atomic `StoredWorkspaceV1` persistence and one-shot legacy migration** + (#284, phase 2 of #280). New pure `src/workspace/` modules: a + `WorkspaceRepository` (`loadCurrent`/`commit`/`clearCurrent`) that + validates the complete candidate through phase-1 whole-workspace validation + **before** writing and then atomically replaces the aggregate in one + transaction — a failed write leaves the previously stored workspace intact, + never increments Dashboard revision, and never produces a partially-mixed + workspace (multi-tab policy: last successful commit wins, atomic replacement, + not compare-and-swap). Persistence is **IndexedDB** (chosen over a single + localStorage key: the 10 MiB `maxDecodedJsonBytes` aggregate exceeds the + ~5 MB localStorage quota, and phase 6 needs IndexedDB anyway) behind an + injected `WorkspaceStore` seam (`env.indexedDB`, mirroring the crypto/storage + seams) so the repository unit-tests against a plain in-memory fake. The + one-shot legacy migration builds one candidate `StoredWorkspaceV1` from the + flat `asb:*` keys — the initial Dashboard from `spec.favorite` (panel-role + favorites become tiles in catalog order), `asb:dashLayout`/`asb:dashCols` + converted to a valid `flow@1` layout — validates the whole candidate, and + persists it atomically; it runs only when no aggregate record exists and + never deletes or modifies legacy keys. Repository + operation semantics only; + no authoring commands, viewer, or import UI yet (later phases of #280). + - **`spec.favorite` dual-write removal path.** During phases 2-4 `spec.favorite` + stays the membership source the existing favorites-driven Dashboard render + reads, while `dashboard.tiles` becomes the canonical membership going + forward; phase 3's star command dual-writes both. Membership **reads** flip + to `dashboard.tiles` when phase 4's `DashboardViewerSession` + `flow@1` + viewer replace `src/ui/dashboard.ts`'s `savedQueries.filter(queryFavorite)` + render; the `spec.favorite` **dual-write** is then deleted in the v1 + Dashboard GA release (the release closing #280), after phase 5 lands — the + schema keeps `favorite` only as an ignored legacy-compat field readable by + old migrations. + +- **Dashboard authoring domain: atomic commands, presentation resolution, and + workspace-wide validation** (#285, phase 3 of #280). New pure/typed modules, + each 100%-covered, keeping the direction model/layouts ← application ← UI: + - `dashboard/model/json-merge-patch.ts` — an in-house RFC 7396 JSON Merge + Patch (no new dependency): objects merge recursively, a `null` member + deletes, arrays and non-objects replace wholesale, and the result shares no + structure with its inputs. + - `dashboard/model/presentation-resolver.ts` — the ONE canonical presentation + resolver (base panel → selected/`defaultVariant` variant patch → tile + override → final validation), shared by the authoring session, saved-query + mutation validation, and tests. Enforces the #280 rules: a missing selected + variant name fails (no silent fallback), neither a variant nor an override + may change `panel.cfg.type`, deleting a required field fails final + validation, and result-column role validation runs only when result + metadata is available. + - `dashboard/layouts/flow-layout.ts` — the minimal `flow@1` layout plugin the + authoring domain needs: placement validation (closed span/height contract), + document normalization (orphan-placement pruning), size-hint-derived initial + placement, and `resolveActiveLayoutPlugin` (flow@1, or an unsupported + primary with a valid flow@1 fallback, else a load failure). + - `dashboard/application/dashboard-authoring-session.ts` + + `dashboard-commands.ts` + `dashboard-query-resolver.ts` — a + `DashboardAuthoringSession` owning one editable draft (a signal), its + in-memory `draftVersion`, dirty/selection state, and commit/export. Every + membership/placement/layout change is a fallible, atomic typed command + (`add-query`, `add-query-instance`, `remove-tile`, `move-tile`, + `update-tile`, `update-placement`, `change-layout`): clone the draft → apply + to an isolated candidate → normalize through the layout plugin → validate + the whole candidate workspace (structure/references/roles/limits, then + presentation resolution) → replace the draft only when valid; a failed + command leaves the previous draft byte-for-byte unchanged. `draftVersion` + guards stale async commands (`dashboard-command-stale`) and is separate from + the persisted `revision`, which increments once per successful repository + commit (never on a command, preview, or export). The **star rewires** to + `toggleMembership`, which maps to an `add-query`/`remove-tile` command and + dual-writes `spec.favorite` — no direct signal/document mutation of + membership anywhere. + - `dashboard/application/saved-query-mutation.ts` — `planSavedQueryMutation` + constructs and validates a complete candidate workspace for every + saved-query mutation (delete or replace), rejecting an invalidating one + unless the caller supplies an atomic repair (remove affected tiles/filters, + switch to another variant, or remap references), then commits mutation + + repair as one candidate. + A new `check:arch` rule keeps `dashboard/application` off the Dashboard UI. + Live Workbench wiring of the session/star UI lands with the phase-4 viewer. + +- **Dashboard read-only viewer runtime + normative `flow@1` layout** (#286, + phase 4 of #280). New pure/application modules, gated at the per-file + thresholds, all constructible and unit-tested with plain fakes — no Workbench, + no full `App`, no `AppState`, no editors: + - `dashboard/application/dashboard-viewer-session.ts` — the standalone + **`DashboardViewerSession`**: it takes an immutable `DashboardDocumentV1` + snapshot plus the workspace queries and runs the Dashboard end-to-end, + reading membership from **`dashboard.tiles[]`** (not `spec.favorite`) and + resolving every tile through the ONE shared `presentation-resolver` + (`resolveDashboardPresentations`/`resolvePresentation` — no copy). It owns + only runtime state — filter values/activation, per-tile results/errors/ + progress, the resolved flow layout — published through one `state` signal a + renderer subscribes to; `start`/`refresh`/`refreshTile`/`setFilter`/ + `cancelTile`/`destroy` plus `clearFilter`/`clearAllFilters`, with bounded + concurrency, per-tile `AbortController` cancellation, stale-wave generation + guards, and `destroy()` teardown. It depends only on narrow injected seams + (a query executor, a token-preflight connection, an optional layout + registry) declared locally, so it imports nothing from `src/application`, + `src/net`, `src/state.ts`, `src/ui`, or `src/editor`. + - **#235 resolved inside the execution planner**: panels whose declared params + cannot be affected by any filter **source** query run in PARALLEL with the + filter wave; panels a source-backed filter targets wait for the wave and see + the correct blanked/active values on the first pass. The overlap is computed + from the explicit `DashboardFilterDefinitionV1.parameter`/`targets` contract. + - **Filter usability semantics** (absorbing #188's kept scope): per-filter + clear that deactivates without discarding the last value (reactivation + restores it, one affected-panel wave), a coalesced clear-all resetting every + filter to its `defaultActive`/`defaultValue` in ONE wave, and an + `activeFilterCount` counting active filter DEFINITIONS. (The per-filter + "required/invalid" blocking badge was dropped as noise by owner decision — + an unfilled required filter just leaves its target tiles unfilled.) + - `dashboard/layouts/flow-layout.ts` **extended** with the normative `flow@1` + render math (pure, 100%): `presetColumns`, `effectiveSpan` + (`min(storedSpan ?? 1, activeColumnCount)` — preset changes never rewrite + stored spans), `resolvePlacement` (defaults span 1 / medium), and + `computeFlowLayout` — deterministic row-major packing (no overlaps), + maximal-consecutive-KPI-run band grouping preserving #240, mobile + one-column normalization that never mutates persistence, and the order + equivalence `dashboard.tiles[] = DOM = keyboard = visual row-major = + print/export`. + - `dashboard/layouts/layout-registry.ts` — a compile-time-registered, + lazy-loadable **layout registry** (`DashboardLayoutRegistration` {id, + versions, load}), never arbitrary remote plugins, with the fallback + contract: a primary engine that cannot load falls back to a valid `flow@1` + fallback, and an unsupported primary without one fails closed. + - Accessible authoring (`moveTileEarlier`/`moveTileLater`/`setTileSpan`/ + `setTileHeight`) is driven by the phase-3 `move-tile`/`update-placement` + commands; pointer drag (#153's reorder half) is an equivalent alternative, + never the only mechanism. + - A strengthened `check:arch` rule (plus a unit `dashboard-boundaries` test) + proves the Dashboard model/application layers — including the viewer session + — import no Workbench UI, `App`, `AppState`, editor, `src/application`, or + `src/net` module. + - **Live read-flip.** `src/ui/dashboard.ts` is rewritten to render from a + `DashboardViewerSession` bound to the persisted `StoredWorkspaceV1` + (`app.loadDashboardWorkspace()` → the phase-2 `WorkspaceRepository`, running + the one-shot legacy migration when no aggregate exists), reading membership + from **`dashboard.tiles[]`** — no longer `savedQueries.filter(queryFavorite)`. + The DOM reconciles from the session's `state` signal: `flow@1` rows/columns, + KPI bands (via `renderKpiCards`, preserving #240), per-tile + loading/unfilled/error/ready with chart-paint-once, mobile one-column + normalization at the 768px breakpoint, and the empty state. A migrated + Dashboard's implicit `{name:Type}` params are surfaced as runtime-only filter + definitions so the filter bar is not lost. The `spec.favorite` dual-WRITE + stays until GA (the Workbench star action); only the READ is flipped. + - **Rich filter bar.** The Dashboard filter bar is the SHARED `buildFilterBar` + — the same rich field family the Workbench var-strip and detached view use + (relative-time presets, recents, enum + curated comboboxes) — driven over + the viewer's filter model: a draft value/active bag the bar mutates, + `session.getFilterField` for live #170 validation, and `session.applyFilter` + on commit (which owns activation). Recents flow through the shim from the + real app; the viewer never imports global `AppState` (check-boundaries keeps + the phase-4 forbidden list intact). Toolbar affordances stay: a coalesced + clear-all (one wave) and an "N active" count. (The per-filter blocking badge + was dropped as noise by owner decision.) + - **Tile reordering is pointer DRAG ONLY** (owner override, final #286 scope). + A drop persists the new `dashboard.tiles[]` order through the `move-tile` + command. Note: #280's accessibility section says drag should not be the only + reorder mechanism — this is a deliberate product-owner override; the per-tile + keyboard Move controls were removed. The in-tile span/height buttons were + also removed (span/height are tuned in the Spec editor); the underlying + `update-placement`/`setTileSpan`/`setTileHeight` authoring commands stay in + the application layer. The per-filter "×" clear affordance was removed too. + This closes **#235** (filter wave / panel parallelism) and the reorder half of + **#153** (open-in-window arrives in Phase 6), and dissolves **#188** into the + viewer's filter contract. + - **Fix (post-review):** the `.dash-grid` container kept its old-model + `grid-template-columns`, so it laid the new flow `.dash-row` wrappers out in + columns — full-width showed multiple rows side by side and 3-column showed + the rows multiplied across the page. The container is now a vertical flex + stack; each `.dash-row` owns its own column count. (Invisible to the unit + suite because happy-dom does not compute CSS layout; verified in real + Chromium.) + ### Changed - **The app.ts → services refactor is complete** (#276, Phase 5). The temporary `App` delegates from Phases 2–4 are deleted — consumers read diff --git a/build/check-boundaries.mjs b/build/check-boundaries.mjs index 6f6a3600..36b060a7 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -49,6 +49,47 @@ const RULES = [ forbidden: ['src/ui/app.ts'], why: 'issue #276 Phase 5: the dashboard shell must not reach back into app.ts (everything it needs is injected)', }, + // Issue #280 phase 1 (#283): the Dashboard module keeps the dependency + // direction `model <- application <- UI adapters`, and neither the model + // nor the workspace aggregate may depend on the App controller, Workbench + // UI, editors, global AppState, or the network layer. + { + dir: 'src/dashboard', + forbidden: ['src/ui', 'src/editor', 'src/application', 'src/state.ts', 'src/net'], + why: 'issue #280 phase 1: Dashboard model/application code is pure and must not depend on App, Workbench UI, editors, global AppState, or the network layer', + }, + { + dir: 'src/dashboard/model', + forbidden: ['src/dashboard/application', 'src/dashboard/layouts', 'src/dashboard/ui'], + why: 'issue #280 phase 1: dependency direction is model <- application <- UI adapters', + }, + { + // Issue #280 phase 3 (#285): the Dashboard authoring/application layer may + // import the model, the layout plugins, and the workspace aggregate, but + // must NOT reach up into any UI adapter (the App, Workbench UI, editors, + // and global AppState are already forbidden by the `src/dashboard` rule + // above). This keeps the direction model/layouts <- application <- UI. + // + // Issue #286 phase 4: the DashboardViewerSession lives here, so this rule + // also names the App/AppState/editor/service/network boundary EXPLICITLY + // (not just transitively via the `src/dashboard` rule) — the viewer session + // must be constructible and testable without the Workbench UI, the full + // `App` controller, global `AppState`, the CodeMirror editors, the + // `src/application` services, or the `src/net` client; it depends only on + // the narrow injected interfaces it declares. `main.ts` (the bootstrap) is + // named too so the application layer can never reach the composition root. + dir: 'src/dashboard/application', + forbidden: [ + 'src/dashboard/ui', 'src/ui', 'src/editor', 'src/application', + 'src/state.ts', 'src/net', 'src/main.ts', + ], + why: 'issue #280 phase 3 / #286 phase 4: application (incl. DashboardViewerSession) must not import Dashboard UI adapters, Workbench UI, the App, global AppState, editors, src/application services, or the network layer', + }, + { + dir: 'src/workspace', + forbidden: ['src/ui', 'src/editor', 'src/application', 'src/state.ts', 'src/net'], + why: 'issue #280 phase 1: the workspace aggregate layer is pure and must not depend on App, Workbench UI, editors, global AppState, or the network layer', + }, ]; function collectFiles(target) { diff --git a/build/emit-schema-types.mjs b/build/emit-schema-types.mjs index c117a585..2569eb82 100644 --- a/build/emit-schema-types.mjs +++ b/build/emit-schema-types.mjs @@ -21,7 +21,7 @@ const IGNORED_KEYWORDS = new Set([ 'minLength', 'maxLength', 'pattern', 'format', 'minItems', 'maxItems', 'uniqueItems', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', - 'minProperties', 'maxProperties', + 'minProperties', 'maxProperties', 'propertyNames', ]); const HANDLED_KEYWORDS = new Set([ 'type', 'const', 'enum', 'anyOf', 'oneOf', 'allOf', 'not', '$ref', diff --git a/build/schema-manifest.mjs b/build/schema-manifest.mjs index d3046612..37468895 100644 --- a/build/schema-manifest.mjs +++ b/build/schema-manifest.mjs @@ -24,6 +24,33 @@ export const SCHEMA_MANIFEST = [ typeExport: 'LibraryV2', bundle: true, }, + // Dashboard v1 contracts (#280 phase 1, #283). The flow@1 layout schema is + // its own manifest root so the compiled validator can also re-validate a + // primary flow@1 layout and every persisted fallback semantically. + { + path: 'schemas/dashboard-layout-flow-v1.schema.json', + schemaExport: 'flowLayoutV1Schema', + validatorExport: 'validateFlowLayoutV1', + typeExport: 'FlowLayoutV1', + }, + { + path: 'schemas/dashboard-v1.schema.json', + schemaExport: 'dashboardV1Schema', + validatorExport: 'validateDashboardV1', + typeExport: 'DashboardDocumentV1', + }, + { + path: 'schemas/stored-workspace-v1.schema.json', + schemaExport: 'storedWorkspaceV1Schema', + validatorExport: 'validateStoredWorkspaceV1', + typeExport: 'StoredWorkspaceV1', + }, + { + path: 'schemas/portable-bundle-v1.schema.json', + schemaExport: 'portableBundleV1Schema', + validatorExport: 'validatePortableBundleV1', + typeExport: 'PortableBundleV1', + }, ]; export const ANNOTATION_KEYWORDS = [ diff --git a/schemas/dashboard-layout-flow-v1.schema.json b/schemas/dashboard-layout-flow-v1.schema.json new file mode 100644 index 00000000..e21d1174 --- /dev/null +++ b/schemas/dashboard-layout-flow-v1.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json", + "title": "Altinity SQL Browser Dashboard flow@1 layout", + "description": "The normative flow@1 Dashboard layout: deterministic row-major packing driven by dashboard.tiles order, one preset, and per-tile span/height placements keyed by tile ID. This document is also the only valid DashboardLayoutFallbackV1 shape.", + "x-altinity-kind": "dashboard-layout-flow", + "x-altinity-version": 1, + "type": "object", + "required": ["type", "version", "preset", "items"], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier; always flow for this contract.", + "type": "string", + "const": "flow" + }, + "version": { + "title": "Layout engine version", + "description": "flow contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "preset": { "$ref": "#/$defs/flowPresetV1" }, + "items": { + "title": "Tile placements", + "description": "Per-tile placement keyed by tile ID. A missing placement uses span 1 and medium height.", + "type": "object", + "maxProperties": 100, + "propertyNames": { "minLength": 1, "maxLength": 256 }, + "additionalProperties": { "$ref": "#/$defs/flowTilePlacementV1" } + } + }, + "additionalProperties": false, + "x-altinity-order": ["type", "version", "preset", "items"], + "$defs": { + "flowPresetV1": { + "title": "Flow preset", + "description": "Desktop column arrangement: full-width and report render one column (report centers a constrained-width column), columns-2 and columns-3 render equal columns.", + "type": "string", + "enum": ["full-width", "report", "columns-2", "columns-3"] + }, + "flowHeightV1": { + "title": "Tile height", + "description": "Normative height ordering is compact < medium < large; exact pixels are renderer-defined.", + "type": "string", + "enum": ["compact", "medium", "large"] + }, + "flowTilePlacementV1": { + "title": "Tile placement", + "description": "Closed placement contract: unknown fields fail validation. Future extension requires flow@2 or an explicit extension namespace.", + "type": "object", + "properties": { + "span": { + "title": "Column span", + "description": "Columns the tile occupies; the effective span is clamped to the active column count.", + "type": "integer", + "enum": [1, 2, 3] + }, + "height": { "$ref": "#/$defs/flowHeightV1" } + }, + "additionalProperties": false, + "x-altinity-order": ["span", "height"] + } + } +} diff --git a/schemas/dashboard-v1.schema.json b/schemas/dashboard-v1.schema.json new file mode 100644 index 00000000..67894f94 --- /dev/null +++ b/schemas/dashboard-v1.schema.json @@ -0,0 +1,226 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json", + "title": "Altinity SQL Browser Dashboard document v1", + "description": "One explicit Dashboard aggregate: tile instances, semantic tile order, layout, filter definitions, and persistence revision. Runtime values and result caches are never persisted here. Unknown future documentVersion values fail closed.", + "x-altinity-kind": "dashboard", + "x-altinity-version": 1, + "type": "object", + "required": ["documentVersion", "id", "title", "revision", "layout", "filters", "tiles"], + "properties": { + "documentVersion": { + "title": "Dashboard document version", + "description": "Dashboard document contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "id": { + "title": "Dashboard identifier", + "description": "Stable application-managed Dashboard identity.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "title": { + "title": "Dashboard title", + "description": "User-visible Dashboard title.", + "type": "string", + "maxLength": 512 + }, + "description": { + "title": "Dashboard description", + "description": "Optional authoring note shown with the Dashboard.", + "type": "string", + "maxLength": 16384 + }, + "revision": { + "title": "Persistence revision", + "description": "Incremented once for each successfully committed Dashboard document mutation. Validation, preview, and export never increment it. Starts at 1.", + "type": "integer", + "minimum": 1 + }, + "layout": { "$ref": "#/$defs/dashboardLayoutDocumentV1" }, + "filters": { + "title": "Filter definitions", + "description": "Dashboard filter definitions in filter order. Required even when empty.", + "type": "array", + "maxItems": 32, + "items": { "$ref": "#/$defs/dashboardFilterDefinitionV1" } + }, + "tiles": { + "title": "Tiles", + "description": "Tile instances in canonical semantic order: execution planning, DOM and keyboard traversal, fallback rendering, print/export, and serialization all follow this order. Required even when empty.", + "type": "array", + "maxItems": 100, + "items": { "$ref": "#/$defs/dashboardTileV1" } + } + }, + "additionalProperties": false, + "x-altinity-order": ["documentVersion", "id", "title", "description", "revision", "layout", "filters", "tiles"], + "$defs": { + "dashboardTilePresentationV1": { + "title": "Tile presentation selection", + "description": "Selected reusable variant and optional small tile-local override patch, both resolved over the saved query's base panel.", + "type": "object", + "properties": { + "variant": { + "title": "Selected variant", + "description": "Name of a variant declared on the tile's saved query. A persisted name that no longer exists fails validation; there is no silent fallback.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "override": { "$ref": "query-spec-v1.schema.json#/$defs/queryPresentationPatchV1" } + }, + "additionalProperties": false, + "x-altinity-order": ["variant", "override"] + }, + "dashboardTileV1": { + "title": "Dashboard tile", + "description": "One query instance placed on this Dashboard: stable instance identity, query reference, selected presentation, and optional local title/description. Actual placement and size live in the layout document.", + "type": "object", + "required": ["id", "queryId"], + "properties": { + "id": { + "title": "Tile identifier", + "description": "Stable tile instance identity within this Dashboard.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "queryId": { + "title": "Saved-query reference", + "description": "ID of the saved query this tile renders. The query must exist and have Dashboard role panel.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "title": { + "title": "Tile title override", + "description": "Optional Dashboard-local title override.", + "type": "string", + "maxLength": 512 + }, + "description": { + "title": "Tile description override", + "description": "Optional Dashboard-local description override.", + "type": "string", + "maxLength": 16384 + }, + "presentation": { "$ref": "#/$defs/dashboardTilePresentationV1" } + }, + "additionalProperties": false, + "x-altinity-order": ["id", "queryId", "title", "description", "presentation"] + }, + "dashboardFilterDefinitionV1": { + "title": "Dashboard filter definition", + "description": "One Dashboard filter: the targeted parameter name, an optional filter-role source query providing options, and optional explicit target tiles. Runtime filter values are never persisted here.", + "type": "object", + "required": ["id", "parameter"], + "properties": { + "id": { + "title": "Filter identifier", + "description": "Stable filter identity within this Dashboard.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "parameter": { + "title": "Parameter name", + "description": "ClickHouse query parameter name this filter supplies. Target queries must declare the parameter with compatible types.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "label": { + "title": "Filter label", + "description": "Optional user-visible filter label.", + "type": "string", + "maxLength": 512 + }, + "sourceQueryId": { + "title": "Option source query", + "description": "ID of a filter-role saved query whose result provides the option list. The source query never creates a tile.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "targets": { + "title": "Target tiles", + "description": "Tile IDs this filter applies to. Absent means every compatible panel tile.", + "type": "array", + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "defaultValue": { + "title": "Default value", + "description": "Optional default parameter value; any JSON value." + }, + "defaultActive": { + "title": "Active by default", + "description": "Whether the filter starts active.", + "type": "boolean" + } + }, + "additionalProperties": false, + "x-altinity-order": ["id", "parameter", "label", "sourceQueryId", "targets", "defaultValue", "defaultActive"] + }, + "dashboardLayoutFallbackV1": { + "title": "Layout fallback", + "description": "A complete valid flow@1 layout rendered when the primary layout engine cannot load. Fallback placement keys resolve against the same dashboard.tiles.", + "$ref": "dashboard-layout-flow-v1.schema.json" + }, + "dashboardLayoutDocumentV1": { + "title": "Dashboard layout document", + "description": "Versioned, engine-specific placement document. An unsupported type/version must carry a valid flow@1 fallback or the Dashboard fails before execution.", + "type": "object", + "required": ["type", "version"], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier, e.g. flow.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "version": { + "title": "Layout engine version", + "description": "Layout engine contract version.", + "type": "integer", + "minimum": 1 + }, + "preset": { + "title": "Layout preset", + "description": "Engine-specific preset identifier.", + "type": "string", + "maxLength": 256 + }, + "config": { + "title": "Engine configuration", + "description": "Open engine-specific configuration object; recursively key-sorted by the canonical encoder.", + "type": "object" + }, + "items": { + "title": "Placement entries", + "description": "Engine-specific placement objects keyed by tile ID. Every key must reference an existing tile and the entry count must not exceed the tile count.", + "type": "object", + "maxProperties": 100, + "propertyNames": { "minLength": 1, "maxLength": 256 }, + "additionalProperties": { "type": "object" } + }, + "fallback": { "$ref": "#/$defs/dashboardLayoutFallbackV1" } + }, + "additionalProperties": false, + "x-altinity-order": ["type", "version", "preset", "config", "items", "fallback"] + } + } +} diff --git a/schemas/generated/library-v2.bundle.schema.json b/schemas/generated/library-v2.bundle.schema.json index bd7db0a2..f83fb337 100644 --- a/schemas/generated/library-v2.bundle.schema.json +++ b/schemas/generated/library-v2.bundle.schema.json @@ -48,7 +48,7 @@ "$ref": "#/$defs/panel" }, "dashboard": { - "$ref": "#/$defs/dashboard" + "$ref": "#/$defs/queryDashboardPresentationV1" } }, "additionalProperties": true, @@ -219,9 +219,54 @@ "columns" ] }, - "dashboard": { + "queryPresentationPatchV1": { + "title": "Presentation patch", + "description": "RFC 7396 JSON Merge Patch applied over the saved query's base panel object. Object members merge recursively, arrays replace the previous array completely, and a null member deletes an optional property. A patch may not change panel.cfg.type, and the final resolved panel must validate like a normal saved-query panel.", + "type": "object", + "additionalProperties": true + }, + "dashboardSizeHints": { + "title": "Size hints", + "description": "Layout-neutral tile size hints. Authoring may derive an initial placement from them; they never own actual Dashboard placement.", + "type": "object", + "properties": { + "preferred": { + "title": "Preferred size", + "description": "Preferred layout-neutral tile size.", + "type": "string", + "enum": [ + "compact", + "medium", + "wide" + ] + }, + "minimum": { + "title": "Minimum size", + "description": "Smallest layout-neutral tile size that still renders usefully.", + "type": "string", + "enum": [ + "compact", + "medium", + "wide" + ] + }, + "aspectRatio": { + "title": "Aspect ratio", + "description": "Preferred width/height ratio hint.", + "type": "number", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": true, + "x-altinity-order": [ + "preferred", + "minimum", + "aspectRatio" + ] + }, + "queryDashboardPresentationV1": { "title": "Dashboard configuration", - "description": "Dashboard participation metadata. Feature-specific extensions remain forward compatible.", + "description": "Reusable Dashboard presentation for this query: role, named presentation variants, and layout-neutral size hints. Actual Dashboard sequence, placement, and tile-local overrides live in the Dashboard document. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { @@ -237,11 +282,37 @@ "examples": [ "filter" ] + }, + "defaultVariant": { + "title": "Default presentation variant", + "description": "Variant applied when a tile does not select one. Must name an existing entry in variants.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "variants": { + "title": "Presentation variants", + "description": "Named reusable presentation patches, each an RFC 7396 JSON Merge Patch over the base panel.", + "type": "object", + "maxProperties": 32, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "$ref": "#/$defs/queryPresentationPatchV1" + } + }, + "sizeHints": { + "$ref": "#/$defs/dashboardSizeHints" } }, "additionalProperties": true, "x-altinity-order": [ - "role" + "role", + "defaultVariant", + "variants", + "sizeHints" ] }, "panel": { @@ -1126,6 +1197,555 @@ } }, "additionalProperties": false + }, + "dashboard-layout-flow-v1": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json", + "title": "Altinity SQL Browser Dashboard flow@1 layout", + "description": "The normative flow@1 Dashboard layout: deterministic row-major packing driven by dashboard.tiles order, one preset, and per-tile span/height placements keyed by tile ID. This document is also the only valid DashboardLayoutFallbackV1 shape.", + "x-altinity-kind": "dashboard-layout-flow", + "x-altinity-version": 1, + "type": "object", + "required": [ + "type", + "version", + "preset", + "items" + ], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier; always flow for this contract.", + "type": "string", + "const": "flow" + }, + "version": { + "title": "Layout engine version", + "description": "flow contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "preset": { + "$ref": "#/$defs/flowPresetV1" + }, + "items": { + "title": "Tile placements", + "description": "Per-tile placement keyed by tile ID. A missing placement uses span 1 and medium height.", + "type": "object", + "maxProperties": 100, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "$ref": "#/$defs/flowTilePlacementV1" + } + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "type", + "version", + "preset", + "items" + ], + "$defs": { + "flowPresetV1": { + "title": "Flow preset", + "description": "Desktop column arrangement: full-width and report render one column (report centers a constrained-width column), columns-2 and columns-3 render equal columns.", + "type": "string", + "enum": [ + "full-width", + "report", + "columns-2", + "columns-3" + ] + }, + "flowHeightV1": { + "title": "Tile height", + "description": "Normative height ordering is compact < medium < large; exact pixels are renderer-defined.", + "type": "string", + "enum": [ + "compact", + "medium", + "large" + ] + }, + "flowTilePlacementV1": { + "title": "Tile placement", + "description": "Closed placement contract: unknown fields fail validation. Future extension requires flow@2 or an explicit extension namespace.", + "type": "object", + "properties": { + "span": { + "title": "Column span", + "description": "Columns the tile occupies; the effective span is clamped to the active column count.", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "height": { + "$ref": "#/$defs/flowHeightV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "span", + "height" + ] + } + } + }, + "dashboard-v1": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json", + "title": "Altinity SQL Browser Dashboard document v1", + "description": "One explicit Dashboard aggregate: tile instances, semantic tile order, layout, filter definitions, and persistence revision. Runtime values and result caches are never persisted here. Unknown future documentVersion values fail closed.", + "x-altinity-kind": "dashboard", + "x-altinity-version": 1, + "type": "object", + "required": [ + "documentVersion", + "id", + "title", + "revision", + "layout", + "filters", + "tiles" + ], + "properties": { + "documentVersion": { + "title": "Dashboard document version", + "description": "Dashboard document contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "id": { + "title": "Dashboard identifier", + "description": "Stable application-managed Dashboard identity.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "title": { + "title": "Dashboard title", + "description": "User-visible Dashboard title.", + "type": "string", + "maxLength": 512 + }, + "description": { + "title": "Dashboard description", + "description": "Optional authoring note shown with the Dashboard.", + "type": "string", + "maxLength": 16384 + }, + "revision": { + "title": "Persistence revision", + "description": "Incremented once for each successfully committed Dashboard document mutation. Validation, preview, and export never increment it. Starts at 1.", + "type": "integer", + "minimum": 1 + }, + "layout": { + "$ref": "#/$defs/dashboardLayoutDocumentV1" + }, + "filters": { + "title": "Filter definitions", + "description": "Dashboard filter definitions in filter order. Required even when empty.", + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/$defs/dashboardFilterDefinitionV1" + } + }, + "tiles": { + "title": "Tiles", + "description": "Tile instances in canonical semantic order: execution planning, DOM and keyboard traversal, fallback rendering, print/export, and serialization all follow this order. Required even when empty.", + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/$defs/dashboardTileV1" + } + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "documentVersion", + "id", + "title", + "description", + "revision", + "layout", + "filters", + "tiles" + ], + "$defs": { + "dashboardTilePresentationV1": { + "title": "Tile presentation selection", + "description": "Selected reusable variant and optional small tile-local override patch, both resolved over the saved query's base panel.", + "type": "object", + "properties": { + "variant": { + "title": "Selected variant", + "description": "Name of a variant declared on the tile's saved query. A persisted name that no longer exists fails validation; there is no silent fallback.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "override": { + "$ref": "query-spec-v1.schema.json#/$defs/queryPresentationPatchV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "variant", + "override" + ] + }, + "dashboardTileV1": { + "title": "Dashboard tile", + "description": "One query instance placed on this Dashboard: stable instance identity, query reference, selected presentation, and optional local title/description. Actual placement and size live in the layout document.", + "type": "object", + "required": [ + "id", + "queryId" + ], + "properties": { + "id": { + "title": "Tile identifier", + "description": "Stable tile instance identity within this Dashboard.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "queryId": { + "title": "Saved-query reference", + "description": "ID of the saved query this tile renders. The query must exist and have Dashboard role panel.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "title": { + "title": "Tile title override", + "description": "Optional Dashboard-local title override.", + "type": "string", + "maxLength": 512 + }, + "description": { + "title": "Tile description override", + "description": "Optional Dashboard-local description override.", + "type": "string", + "maxLength": 16384 + }, + "presentation": { + "$ref": "#/$defs/dashboardTilePresentationV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "id", + "queryId", + "title", + "description", + "presentation" + ] + }, + "dashboardFilterDefinitionV1": { + "title": "Dashboard filter definition", + "description": "One Dashboard filter: the targeted parameter name, an optional filter-role source query providing options, and optional explicit target tiles. Runtime filter values are never persisted here.", + "type": "object", + "required": [ + "id", + "parameter" + ], + "properties": { + "id": { + "title": "Filter identifier", + "description": "Stable filter identity within this Dashboard.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "parameter": { + "title": "Parameter name", + "description": "ClickHouse query parameter name this filter supplies. Target queries must declare the parameter with compatible types.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "label": { + "title": "Filter label", + "description": "Optional user-visible filter label.", + "type": "string", + "maxLength": 512 + }, + "sourceQueryId": { + "title": "Option source query", + "description": "ID of a filter-role saved query whose result provides the option list. The source query never creates a tile.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "targets": { + "title": "Target tiles", + "description": "Tile IDs this filter applies to. Absent means every compatible panel tile.", + "type": "array", + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "defaultValue": { + "title": "Default value", + "description": "Optional default parameter value; any JSON value." + }, + "defaultActive": { + "title": "Active by default", + "description": "Whether the filter starts active.", + "type": "boolean" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "id", + "parameter", + "label", + "sourceQueryId", + "targets", + "defaultValue", + "defaultActive" + ] + }, + "dashboardLayoutFallbackV1": { + "title": "Layout fallback", + "description": "A complete valid flow@1 layout rendered when the primary layout engine cannot load. Fallback placement keys resolve against the same dashboard.tiles.", + "$ref": "dashboard-layout-flow-v1.schema.json" + }, + "dashboardLayoutDocumentV1": { + "title": "Dashboard layout document", + "description": "Versioned, engine-specific placement document. An unsupported type/version must carry a valid flow@1 fallback or the Dashboard fails before execution.", + "type": "object", + "required": [ + "type", + "version" + ], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier, e.g. flow.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "version": { + "title": "Layout engine version", + "description": "Layout engine contract version.", + "type": "integer", + "minimum": 1 + }, + "preset": { + "title": "Layout preset", + "description": "Engine-specific preset identifier.", + "type": "string", + "maxLength": 256 + }, + "config": { + "title": "Engine configuration", + "description": "Open engine-specific configuration object; recursively key-sorted by the canonical encoder.", + "type": "object" + }, + "items": { + "title": "Placement entries", + "description": "Engine-specific placement objects keyed by tile ID. Every key must reference an existing tile and the entry count must not exceed the tile count.", + "type": "object", + "maxProperties": 100, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "type": "object" + } + }, + "fallback": { + "$ref": "#/$defs/dashboardLayoutFallbackV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "type", + "version", + "preset", + "config", + "items", + "fallback" + ] + } + } + }, + "stored-workspace-v1": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json", + "title": "Altinity SQL Browser stored workspace v1", + "description": "The atomic browser-persistence aggregate: one workspace with an ordered saved-query collection and zero or one Dashboard. Internal persistence contract; portable interchange uses portable-bundle-v1 instead.", + "x-altinity-kind": "stored-workspace", + "x-altinity-version": 1, + "type": "object", + "required": [ + "storageVersion", + "id", + "name", + "queries", + "dashboard" + ], + "properties": { + "storageVersion": { + "title": "Storage version", + "description": "Stored-workspace contract version; always 1 for this contract. Unknown future versions fail closed.", + "type": "integer", + "const": 1 + }, + "id": { + "title": "Workspace identifier", + "description": "Stable generated workspace identity. Two files with the same name still produce distinct workspace IDs.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "name": { + "title": "Workspace name", + "description": "User-visible workspace name. Renaming the workspace does not rename its Dashboard.", + "type": "string", + "maxLength": 512 + }, + "queries": { + "title": "Saved queries", + "description": "The ordered saved-query collection in catalog/authoring order, independent of Dashboard tile order. Required even when empty.", + "type": "array", + "maxItems": 1000, + "items": { + "$ref": "saved-query-v2.schema.json" + } + }, + "dashboard": { + "title": "Dashboard", + "description": "The zero-or-one editable Dashboard of this workspace; null when none exists.", + "oneOf": [ + { + "$ref": "dashboard-v1.schema.json" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "storageVersion", + "id", + "name", + "queries", + "dashboard" + ] + }, + "portable-bundle-v1": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "title": "Altinity SQL Browser portable bundle v1", + "description": "The one canonical portable interchange format: saved queries plus zero or more Dashboard documents. All newly written importable/exportable JSON uses this format; legacy Library v1/v2 files remain readable through compatibility decoders.", + "x-altinity-kind": "portable-bundle", + "x-altinity-version": 1, + "type": "object", + "required": [ + "format", + "version", + "exportedAt", + "queries", + "dashboards" + ], + "properties": { + "$schema": { + "title": "Schema identifier", + "description": "Optional schema hint for editors, agents, and third-party tools.", + "const": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json" + }, + "format": { + "title": "Format identifier", + "const": "altinity-sql-browser/portable-bundle" + }, + "version": { + "title": "Bundle format version", + "description": "Portable bundle contract version; always 1 for this contract. Unknown future versions fail closed.", + "type": "integer", + "const": 1 + }, + "exportedAt": { + "title": "Export timestamp", + "description": "RFC 3339 timestamp indicating when this portable bundle was created.", + "type": "string", + "format": "date-time" + }, + "metadata": { + "title": "Bundle metadata", + "description": "Optional human-readable bundle name and description.", + "type": "object", + "properties": { + "name": { + "title": "Bundle name", + "type": "string", + "maxLength": 512 + }, + "description": { + "title": "Bundle description", + "type": "string", + "maxLength": 16384 + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "name", + "description" + ] + }, + "queries": { + "title": "Saved queries", + "description": "Every query referenced by the bundled dashboards plus any standalone queries; each query appears exactly once. Required even when empty.", + "type": "array", + "maxItems": 1000, + "items": { + "$ref": "saved-query-v2.schema.json" + } + }, + "dashboards": { + "title": "Dashboard documents", + "description": "Bundled Dashboard documents. The v1 Workbench manages at most one Dashboard; multi-dashboard bundles are import-resolution input for tooling and forward compatibility. Required even when empty.", + "type": "array", + "maxItems": 32, + "items": { + "$ref": "dashboard-v1.schema.json" + } + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "$schema", + "format", + "version", + "exportedAt", + "metadata", + "queries", + "dashboards" + ] } } } diff --git a/schemas/generated/schema-catalog.json b/schemas/generated/schema-catalog.json index 704f2de0..73caa733 100644 --- a/schemas/generated/schema-catalog.json +++ b/schemas/generated/schema-catalog.json @@ -20,6 +20,30 @@ "id": "https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json", "path": "../library-v2.schema.json", "bundlePath": "library-v2.bundle.schema.json" + }, + { + "kind": "dashboard-layout-flow", + "version": 1, + "id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json", + "path": "../dashboard-layout-flow-v1.schema.json" + }, + { + "kind": "dashboard", + "version": 1, + "id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json", + "path": "../dashboard-v1.schema.json" + }, + { + "kind": "stored-workspace", + "version": 1, + "id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json", + "path": "../stored-workspace-v1.schema.json" + }, + { + "kind": "portable-bundle", + "version": 1, + "id": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "path": "../portable-bundle-v1.schema.json" } ] } diff --git a/schemas/portable-bundle-v1.schema.json b/schemas/portable-bundle-v1.schema.json new file mode 100644 index 00000000..060dda70 --- /dev/null +++ b/schemas/portable-bundle-v1.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "title": "Altinity SQL Browser portable bundle v1", + "description": "The one canonical portable interchange format: saved queries plus zero or more Dashboard documents. All newly written importable/exportable JSON uses this format; legacy Library v1/v2 files remain readable through compatibility decoders.", + "x-altinity-kind": "portable-bundle", + "x-altinity-version": 1, + "type": "object", + "required": ["format", "version", "exportedAt", "queries", "dashboards"], + "properties": { + "$schema": { + "title": "Schema identifier", + "description": "Optional schema hint for editors, agents, and third-party tools.", + "const": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json" + }, + "format": { + "title": "Format identifier", + "const": "altinity-sql-browser/portable-bundle" + }, + "version": { + "title": "Bundle format version", + "description": "Portable bundle contract version; always 1 for this contract. Unknown future versions fail closed.", + "type": "integer", + "const": 1 + }, + "exportedAt": { + "title": "Export timestamp", + "description": "RFC 3339 timestamp indicating when this portable bundle was created.", + "type": "string", + "format": "date-time" + }, + "metadata": { + "title": "Bundle metadata", + "description": "Optional human-readable bundle name and description.", + "type": "object", + "properties": { + "name": { + "title": "Bundle name", + "type": "string", + "maxLength": 512 + }, + "description": { + "title": "Bundle description", + "type": "string", + "maxLength": 16384 + } + }, + "additionalProperties": false, + "x-altinity-order": ["name", "description"] + }, + "queries": { + "title": "Saved queries", + "description": "Every query referenced by the bundled dashboards plus any standalone queries; each query appears exactly once. Required even when empty.", + "type": "array", + "maxItems": 1000, + "items": { "$ref": "saved-query-v2.schema.json" } + }, + "dashboards": { + "title": "Dashboard documents", + "description": "Bundled Dashboard documents. The v1 Workbench manages at most one Dashboard; multi-dashboard bundles are import-resolution input for tooling and forward compatibility. Required even when empty.", + "type": "array", + "maxItems": 32, + "items": { "$ref": "dashboard-v1.schema.json" } + } + }, + "additionalProperties": false, + "x-altinity-order": ["$schema", "format", "version", "exportedAt", "metadata", "queries", "dashboards"] +} diff --git a/schemas/query-spec-v1.schema.json b/schemas/query-spec-v1.schema.json index 03bc556e..836edf56 100644 --- a/schemas/query-spec-v1.schema.json +++ b/schemas/query-spec-v1.schema.json @@ -34,7 +34,7 @@ "default": "table" }, "panel": { "$ref": "#/$defs/panel" }, - "dashboard": { "$ref": "#/$defs/dashboard" } + "dashboard": { "$ref": "#/$defs/queryDashboardPresentationV1" } }, "additionalProperties": true, "x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard"], @@ -113,9 +113,42 @@ "additionalProperties": true, "x-altinity-order": ["defaults", "columns"] }, - "dashboard": { + "queryPresentationPatchV1": { + "title": "Presentation patch", + "description": "RFC 7396 JSON Merge Patch applied over the saved query's base panel object. Object members merge recursively, arrays replace the previous array completely, and a null member deletes an optional property. A patch may not change panel.cfg.type, and the final resolved panel must validate like a normal saved-query panel.", + "type": "object", + "additionalProperties": true + }, + "dashboardSizeHints": { + "title": "Size hints", + "description": "Layout-neutral tile size hints. Authoring may derive an initial placement from them; they never own actual Dashboard placement.", + "type": "object", + "properties": { + "preferred": { + "title": "Preferred size", + "description": "Preferred layout-neutral tile size.", + "type": "string", + "enum": ["compact", "medium", "wide"] + }, + "minimum": { + "title": "Minimum size", + "description": "Smallest layout-neutral tile size that still renders usefully.", + "type": "string", + "enum": ["compact", "medium", "wide"] + }, + "aspectRatio": { + "title": "Aspect ratio", + "description": "Preferred width/height ratio hint.", + "type": "number", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": true, + "x-altinity-order": ["preferred", "minimum", "aspectRatio"] + }, + "queryDashboardPresentationV1": { "title": "Dashboard configuration", - "description": "Dashboard participation metadata. Feature-specific extensions remain forward compatible.", + "description": "Reusable Dashboard presentation for this query: role, named presentation variants, and layout-neutral size hints. Actual Dashboard sequence, placement, and tile-local overrides live in the Dashboard document. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { @@ -125,10 +158,26 @@ "enum": ["panel", "filter", "setup"], "default": "panel", "examples": ["filter"] - } + }, + "defaultVariant": { + "title": "Default presentation variant", + "description": "Variant applied when a tile does not select one. Must name an existing entry in variants.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "variants": { + "title": "Presentation variants", + "description": "Named reusable presentation patches, each an RFC 7396 JSON Merge Patch over the base panel.", + "type": "object", + "maxProperties": 32, + "propertyNames": { "minLength": 1, "maxLength": 256 }, + "additionalProperties": { "$ref": "#/$defs/queryPresentationPatchV1" } + }, + "sizeHints": { "$ref": "#/$defs/dashboardSizeHints" } }, "additionalProperties": true, - "x-altinity-order": ["role"] + "x-altinity-order": ["role", "defaultVariant", "variants", "sizeHints"] }, "panel": { "title": "Panel configuration", diff --git a/schemas/stored-workspace-v1.schema.json b/schemas/stored-workspace-v1.schema.json new file mode 100644 index 00000000..4c2677f0 --- /dev/null +++ b/schemas/stored-workspace-v1.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json", + "title": "Altinity SQL Browser stored workspace v1", + "description": "The atomic browser-persistence aggregate: one workspace with an ordered saved-query collection and zero or one Dashboard. Internal persistence contract; portable interchange uses portable-bundle-v1 instead.", + "x-altinity-kind": "stored-workspace", + "x-altinity-version": 1, + "type": "object", + "required": ["storageVersion", "id", "name", "queries", "dashboard"], + "properties": { + "storageVersion": { + "title": "Storage version", + "description": "Stored-workspace contract version; always 1 for this contract. Unknown future versions fail closed.", + "type": "integer", + "const": 1 + }, + "id": { + "title": "Workspace identifier", + "description": "Stable generated workspace identity. Two files with the same name still produce distinct workspace IDs.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "name": { + "title": "Workspace name", + "description": "User-visible workspace name. Renaming the workspace does not rename its Dashboard.", + "type": "string", + "maxLength": 512 + }, + "queries": { + "title": "Saved queries", + "description": "The ordered saved-query collection in catalog/authoring order, independent of Dashboard tile order. Required even when empty.", + "type": "array", + "maxItems": 1000, + "items": { "$ref": "saved-query-v2.schema.json" } + }, + "dashboard": { + "title": "Dashboard", + "description": "The zero-or-one editable Dashboard of this workspace; null when none exists.", + "oneOf": [ + { "$ref": "dashboard-v1.schema.json" }, + { "type": "null" } + ] + } + }, + "additionalProperties": false, + "x-altinity-order": ["storageVersion", "id", "name", "queries", "dashboard"] +} diff --git a/src/core/json-schema-validation.ts b/src/core/json-schema-validation.ts index 99875f92..814c5e3c 100644 --- a/src/core/json-schema-validation.ts +++ b/src/core/json-schema-validation.ts @@ -31,6 +31,8 @@ export const JSON_SCHEMA_KEYWORD_CODES: Record = { exclusiveMinimum: 'schema-number-range', exclusiveMaximum: 'schema-number-range', minLength: 'schema-invalid-string', maxLength: 'schema-invalid-string', pattern: 'schema-invalid-string', minItems: 'schema-array-size', maxItems: 'schema-array-size', uniqueItems: 'schema-array-duplicate', + minProperties: 'schema-object-size', maxProperties: 'schema-object-size', + propertyNames: 'schema-property-name', oneOf: 'schema-invalid-variant', anyOf: 'schema-invalid-variant', not: 'schema-invalid-variant', additionalProperties: 'schema-unknown-property', unevaluatedProperties: 'schema-unknown-property', format: 'schema-invalid-format', '$ref': 'schema-internal-reference', @@ -93,6 +95,9 @@ function diagnosticMessage( case 'minItems': return `${at} must contain at least ${params.limit} item${params.limit === 1 ? '' : 's'}`; case 'maxItems': return `${at} must contain at most ${params.limit} item${params.limit === 1 ? '' : 's'}`; case 'uniqueItems': return `${at} must not contain duplicate items`; + case 'minProperties': return `${at} must contain at least ${params.limit} propert${params.limit === 1 ? 'y' : 'ies'}`; + case 'maxProperties': return `${at} must contain at most ${params.limit} properties`; + case 'propertyNames': return `${at} is an invalid property name`; case 'oneOf': return `${at} must match exactly one allowed variant`; case 'anyOf': return `${at} must match an allowed variant`; case 'additionalProperties': @@ -129,6 +134,8 @@ export function normalizeJsonSchemaErrors({ path.push(error.params.additionalProperty as string); } else if (error.keyword === 'unevaluatedProperties' && error.params?.unevaluatedProperty != null) { path.push(error.params.unevaluatedProperty as string); + } else if (error.keyword === 'propertyNames' && error.params?.propertyName != null) { + path.push(error.params.propertyName as string); } const diagnosticSchemaId = schemaIdFor(error, schemaId); return { diff --git a/src/core/saved-query.ts b/src/core/saved-query.ts index d39d45e8..e8f0c78b 100644 --- a/src/core/saved-query.ts +++ b/src/core/saved-query.ts @@ -6,7 +6,7 @@ // every helper clones recursively so reads followed by edits cannot alias the // Library entry and unknown objects/arrays survive every known-field patch. -import type { Dashboard, Panel, QuerySpecV1 } from '../generated/json-schema.types.js'; +import type { Panel, QueryDashboardPresentationV1, QuerySpecV1 } from '../generated/json-schema.types.js'; export const SPEC_VERSION = 1; @@ -94,10 +94,10 @@ export function queryPanel(query: unknown): Panel | undefined { return isPlainObject(value) ? (value as Panel) : undefined; } -export function queryDashboard(query: unknown): Dashboard | undefined { +export function queryDashboard(query: unknown): QueryDashboardPresentationV1 | undefined { const value = andGet(andGet(query, 'spec'), 'dashboard'); // Ingress — see queryPanel's comment; same only-isPlainObject-checked read. - return isPlainObject(value) ? (value as Dashboard) : undefined; + return isPlainObject(value) ? (value as QueryDashboardPresentationV1) : undefined; } /** Return a canonical cloned query with `nextSpec` as its complete Spec. */ diff --git a/src/dashboard/application/dashboard-authoring-session.ts b/src/dashboard/application/dashboard-authoring-session.ts new file mode 100644 index 00000000..31434704 --- /dev/null +++ b/src/dashboard/application/dashboard-authoring-session.ts @@ -0,0 +1,262 @@ +// DashboardAuthoringSession (#280 "Fallible, atomic authoring commands", +// "Revision semantics", "DashboardAuthoringSession"). Owns ONE editable +// Dashboard draft (a signal), its in-memory `draftVersion`, dirty/selection +// state, the atomic command executor, and commit/export. Every membership and +// placement change goes through a typed command — nothing mutates the draft +// signal or the document object directly, so Workbench UI and future AI/MCP +// callers share one path. +// +// Atomic command algorithm (per command): +// clone draft -> apply to isolated candidate -> normalize through the active +// layout plugin -> validate structure/references/roles + resolve & validate +// presentations (whole candidate workspace) -> sort diagnostics -> replace +// the draft ONLY when valid. On failure the previous draft is byte-for-byte +// unchanged (the candidate is a clone; the draft signal's document reference +// never changes on a failed command). +// +// draftVersion vs revision: `draftVersion` is the in-memory counter that +// increments by exactly one per successful command and guards stale async +// commands. `revision` is the PERSISTED Dashboard revision — it changes only +// on a successful repository commit (once per commit, regardless of how many +// draft changes it batches), never on a command, preview, or export. + +import { signal, batch } from '@preact/signals-core'; +import type { ReadonlySignal, Signal } from '@preact/signals-core'; +import { cloneJson } from '../../core/saved-query.js'; +import type { JsonSchemaValidationService } from '../../core/json-schema-validation.js'; +import type { SpecSchemaService } from '../../core/spec-schema.js'; +import { diagnostic, sortDiagnostics } from '../model/workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; +import { resolveDashboardPresentations } from '../model/presentation-resolver.js'; +import { dashboardDependencyQueryIds } from '../model/bundle-order.js'; +import { + CURRENT_PORTABLE_BUNDLE_VERSION, PORTABLE_BUNDLE_FORMAT, PORTABLE_BUNDLE_V1_SCHEMA_ID, +} from '../model/portable-bundle-codec.js'; +import { resolveActiveLayoutPlugin } from '../layouts/flow-layout.js'; +import { applyCommand } from './dashboard-commands.js'; +import type { DashboardCommand, DashboardCommandResult } from './dashboard-commands.js'; +import { createQueryResolver } from './dashboard-query-resolver.js'; +import { validateStoredWorkspaceDocument } from '../../workspace/stored-workspace.js'; +import type { WorkspaceRepository, WorkspaceCommitResult } from '../../workspace/workspace-repository.js'; +import type { + DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV1, +} from '../../generated/json-schema.types.js'; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +/** The observable authoring state. `document` is the current draft. */ +export interface DashboardAuthoringState { + document: DashboardDocumentV1; + draftVersion: number; + dirty: boolean; + selectedTileId: string | null; + /** Diagnostics from the most recent command (empty after a success). */ + diagnostics: WorkspaceDiagnostic[]; +} + +export interface DashboardAuthoringSession { + readonly state: ReadonlySignal; + execute( + command: DashboardCommand, options?: { expectedDraftVersion?: number }, + ): Promise>; + /** Star rewire: add/remove one default tile for `queryId` through + * `add-query`/`remove-tile`, then dual-write `spec.favorite` on the + * session's query snapshot to match membership (the documented Phase-4 + * removal path flips reads). */ + toggleMembership( + queryId: string, options?: { expectedDraftVersion?: number }, + ): Promise>; + setSelectedTile(tileId: string | null): void; + commit(): Promise; + createPortableBundle(): PortableBundleV1; + destroy(): void; +} + +export interface DashboardAuthoringSessionDeps { + /** The current workspace. Its Dashboard becomes the initial draft; a `null` + * Dashboard starts an empty flow@1 Dashboard at revision 1. */ + workspace: StoredWorkspaceV1; + repository: WorkspaceRepository; + /** Mints tile IDs (and the new Dashboard ID when the workspace has none). */ + genId: () => string; + validationService?: JsonSchemaValidationService; + schemaService?: SpecSchemaService; + /** Export timestamp source (defaults to `new Date().toISOString()`). */ + nowISO?: () => string; +} + +function createEmptyDashboard(id: string): DashboardDocumentV1 { + return { + documentVersion: 1, id, title: 'Dashboard', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + filters: [], tiles: [], + }; +} + +/** Build a `DashboardAuthoringSession` bound to `deps`. */ +export function createDashboardAuthoringSession( + deps: DashboardAuthoringSessionDeps, +): DashboardAuthoringSession { + const { workspace, repository, genId, validationService, schemaService } = deps; + const nowISO = deps.nowISO ?? (() => new Date().toISOString()); + const codecOptions = validationService ? { validationService } : {}; + + const workspaceId = workspace.id; + const workspaceName = workspace.name; + // A mutable clone of the query collection — the favorite dual-write edits it. + let queries: SavedQueryV2[] = cloneJson(workspace.queries); + // The last persisted Dashboard revision (0 when never committed). + let committedRevision = workspace.dashboard ? workspace.dashboard.revision : 0; + let destroyed = false; + + const initialDocument = workspace.dashboard ? cloneJson(workspace.dashboard) : createEmptyDashboard(genId()); + const stateSignal: Signal = signal({ + document: initialDocument, draftVersion: 0, dirty: false, selectedTileId: null, diagnostics: [], + }); + + /** Validate a candidate dashboard as part of a complete candidate workspace: + * structure + references/roles/limits (stored-workspace pipeline), then — + * only when that passes — resolve and validate every panel presentation. */ + function validateCandidate(dashboard: DashboardDocumentV1): WorkspaceDiagnostic[] { + const candidate: StoredWorkspaceV1 = { + storageVersion: 1, id: workspaceId, name: workspaceName, queries, dashboard, + }; + const structural = validateStoredWorkspaceDocument(candidate, codecOptions); + if (structural.length) return structural; + return resolveDashboardPresentations({ dashboard, queries, schemaService, path: ['dashboard'] }); + } + + function returnFail(diagnostics: WorkspaceDiagnostic[], draftVersion: number): DashboardCommandResult { + const sorted = sortDiagnostics(diagnostics); + // Record the diagnostics WITHOUT touching the draft document/version/dirty. + stateSignal.value = { ...stateSignal.value, diagnostics: sorted }; + return { ok: false, diagnostics: sorted, draftVersion }; + } + + async function execute( + command: DashboardCommand, options?: { expectedDraftVersion?: number }, + ): Promise> { + const current = stateSignal.value; + const baseVersion = current.draftVersion; + if (destroyed) { + return returnFail([diagnostic([], 'dashboard-session-destroyed', + 'This authoring session has been destroyed')], baseVersion); + } + if (options?.expectedDraftVersion !== undefined && options.expectedDraftVersion !== baseVersion) { + return returnFail([diagnostic([], 'dashboard-command-stale', + `Command expected draft version ${options.expectedDraftVersion} but the draft is at ${baseVersion}`)], baseVersion); + } + + // Resolve the active layout plugin — for change-layout, the NEW layout. + const layoutForPlugin = command.type === 'change-layout' ? command.layout : current.document.layout; + const load = resolveActiveLayoutPlugin(layoutForPlugin); + if (!load.ok) return returnFail(load.diagnostics, baseVersion); + + const resolver = createQueryResolver(queries); + const applied = applyCommand(current.document, command, { resolver, genTileId: genId, plugin: load.plugin }); + if (!applied.ok) return returnFail(applied.diagnostics, baseVersion); + + const normalized = load.plugin.normalize(applied.dashboard); + const diagnostics = validateCandidate(normalized); + if (diagnostics.length) return returnFail(diagnostics, baseVersion); + + const draftVersion = baseVersion + 1; + batch(() => { + stateSignal.value = { + document: normalized, draftVersion, dirty: true, + selectedTileId: current.selectedTileId, diagnostics: [], + }; + }); + return { ok: true, value: applied.value as T, document: normalized, draftVersion }; + } + + async function toggleMembership( + queryId: string, options?: { expectedDraftVersion?: number }, + ): Promise> { + const existing = stateSignal.value.document.tiles.find( + (tile) => isObject(tile) && tile.queryId === queryId, + ); + const command: DashboardCommand = existing + ? { type: 'remove-tile', tileId: existing.id } + : { type: 'add-query', queryId }; + const result = await execute(command, options); + if (result.ok) { + // Dual-write spec.favorite to reflect post-command membership. + const member = result.document.tiles.some((tile) => isObject(tile) && tile.queryId === queryId); + queries = queries.map((query) => { + if (!isObject(query) || query.id !== queryId) return query; + const clone = cloneJson(query); + clone.spec = { ...(isObject(clone.spec) ? clone.spec : {}), favorite: member }; + return clone; + }); + } + return result; + } + + function setSelectedTile(tileId: string | null): void { + stateSignal.value = { ...stateSignal.value, selectedTileId: tileId }; + } + + async function commit(): Promise { + const current = stateSignal.value; + if (destroyed) { + return { ok: false, diagnostics: [diagnostic([], 'dashboard-session-destroyed', + 'This authoring session has been destroyed')] }; + } + // One successful commit increments the persisted revision exactly once, + // regardless of how many draft changes it batches. + const candidateDashboard: DashboardDocumentV1 = { + ...cloneJson(current.document), revision: committedRevision + 1, + }; + const candidate: StoredWorkspaceV1 = { + storageVersion: 1, id: workspaceId, name: workspaceName, + queries: cloneJson(queries), dashboard: candidateDashboard, + }; + const result = await repository.commit(candidate); + if (result.ok) { + // The persisted dashboard is `candidateDashboard` (the repository re-parses + // the identical canonical text); adopt it as the clean draft and record + // its revision as the new committed revision. + committedRevision = candidateDashboard.revision; + batch(() => { + stateSignal.value = { ...stateSignal.value, document: candidateDashboard, dirty: false, diagnostics: [] }; + }); + } + // A failed commit leaves the draft dirty and the revision unchanged. + return result; + } + + function createPortableBundle(): PortableBundleV1 { + const dashboard = cloneJson(stateSignal.value.document); + const byId = new Map(); + for (const query of queries) { + if (isObject(query) && typeof query.id === 'string' && !byId.has(query.id)) byId.set(query.id, query); + } + const bundleQueries: SavedQueryV2[] = []; + for (const id of dashboardDependencyQueryIds(dashboard)) { + const query = byId.get(id); + if (query) bundleQueries.push(cloneJson(query)); + } + // Export never mutates workspace identity or revision — nothing here + // touches `committedRevision` or the draft. + return { + $schema: PORTABLE_BUNDLE_V1_SCHEMA_ID as PortableBundleV1['$schema'], + format: PORTABLE_BUNDLE_FORMAT as PortableBundleV1['format'], + version: CURRENT_PORTABLE_BUNDLE_VERSION as PortableBundleV1['version'], + exportedAt: nowISO(), + queries: bundleQueries, + dashboards: [dashboard], + }; + } + + function destroy(): void { + destroyed = true; + stateSignal.value = { ...stateSignal.value, selectedTileId: null, diagnostics: [] }; + } + + return { + state: stateSignal as ReadonlySignal, + execute, toggleMembership, setSelectedTile, commit, createPortableBundle, destroy, + }; +} diff --git a/src/dashboard/application/dashboard-commands.ts b/src/dashboard/application/dashboard-commands.ts new file mode 100644 index 00000000..3947a976 --- /dev/null +++ b/src/dashboard/application/dashboard-commands.ts @@ -0,0 +1,194 @@ +// The typed, fallible Dashboard commands (#280 "Fallible, atomic authoring +// commands"). `applyCommand` is the pure APPLY step of the atomic algorithm: +// it clones the current draft, applies one command to that isolated candidate, +// and returns either the candidate dashboard (plus an optional command value) +// or the command-level diagnostics that make it impossible — WITHOUT mutating +// the input draft. The session (dashboard-authoring-session.ts) then normalizes +// through the layout plugin, runs whole-workspace + presentation validation, +// and only replaces its draft when the candidate is fully valid. +// +// Command-level failures caught here are the ones no downstream validation +// could see because they concern the command itself, not the resulting shape: +// a missing query, a duplicate default instance, a missing tile, an +// out-of-range move index (never silently clamped, per #280), an unsafe tile +// patch, or an invalid placement. Role/limit/reference/presentation failures +// are left to the session's validation stage. + +import { cloneJson } from '../../core/saved-query.js'; +import { diagnostic } from '../model/workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; +import { deriveFlowPlacement, setFlowPlacement } from '../layouts/flow-layout.js'; +import type { DashboardLayoutPlugin } from '../layouts/flow-layout.js'; +import type { QueryResolver } from './dashboard-query-resolver.js'; +import type { + DashboardDocumentV1, DashboardLayoutDocumentV1, DashboardTileV1, +} from '../../generated/json-schema.types.js'; + +/** A tile-local patch for `update-tile` — an RFC 7396 merge patch over the + * tile object. `id`/`queryId` may not be patched (tile identity is stable); + * a `null` member deletes an optional tile field. */ +export type DashboardTilePatch = Record; + +export type DashboardCommand = + | { type: 'add-query'; queryId: string } + | { type: 'add-query-instance'; queryId: string; variant?: string } + | { type: 'remove-tile'; tileId: string } + | { type: 'move-tile'; tileId: string; toIndex: number } + | { type: 'update-tile'; tileId: string; patch: DashboardTilePatch } + | { type: 'update-placement'; tileId: string; placement: Record } + | { type: 'change-layout'; layout: DashboardLayoutDocumentV1 }; + +/** The #280 command-result union, `draftVersion` included on both arms. */ +export type DashboardCommandResult = + | { ok: true; value: T; document: DashboardDocumentV1; draftVersion: number } + | { ok: false; diagnostics: WorkspaceDiagnostic[]; draftVersion: number }; + +export interface ApplyCommandContext { + resolver: QueryResolver; + /** Mint a fresh tile instance ID. */ + genTileId: () => string; + /** The active layout plugin, used to validate `update-placement`. */ + plugin: DashboardLayoutPlugin; +} + +export type ApplyCommandResult = + | { ok: true; dashboard: DashboardDocumentV1; value: unknown } + | { ok: false; diagnostics: WorkspaceDiagnostic[] }; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const failWith = (...diagnostics: WorkspaceDiagnostic[]): ApplyCommandResult => + ({ ok: false, diagnostics }); + +const missingTile = (tileId: string): WorkspaceDiagnostic => + diagnostic(['tiles'], 'dashboard-command-tile-missing', `No tile ${JSON.stringify(tileId)} on this Dashboard`); + +const tileIndex = (tiles: unknown[], tileId: string): number => + tiles.findIndex((tile) => isObject(tile) && tile.id === tileId); + +/** One-level merge of an `update-tile` presentation patch onto the tile's + * existing presentation. A non-object patch replaces the whole field; else + * each patch sub-field replaces (or, when `null`, deletes) the same sub-field + * on a clone of the existing presentation. Sub-field VALUES (notably + * `override`, which is stored RFC 7396 patch data) are cloned verbatim — the + * merge never recurses into them, so their nested `null`s survive. */ +function mergeTilePresentation(existing: unknown, patch: unknown): unknown { + if (!isObject(patch)) return cloneJson(patch); + const result: Record = {}; + if (isObject(existing)) for (const key of Object.keys(existing)) result[key] = cloneJson(existing[key]); + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete result[key]; + else result[key] = cloneJson(value); + } + return result; +} + +/** Apply one command to an ISOLATED clone of `draft`, returning the candidate + * dashboard or command-level diagnostics. The input `draft` is never + * mutated. */ +export function applyCommand( + draft: DashboardDocumentV1, command: DashboardCommand, ctx: ApplyCommandContext, +): ApplyCommandResult { + const dashboard = cloneJson(draft); + const tiles = dashboard.tiles as unknown[]; + + switch (command.type) { + case 'add-query': + case 'add-query-instance': { + const { queryId } = command; + if (!ctx.resolver.has(queryId)) { + return failWith(diagnostic(['tiles'], 'dashboard-command-query-missing', + `No saved query ${JSON.stringify(queryId)} to add`, queryId)); + } + if (command.type === 'add-query' + && tiles.some((tile) => isObject(tile) && tile.queryId === queryId)) { + return failWith(diagnostic(['tiles'], 'dashboard-command-duplicate-instance', + `Query ${JSON.stringify(queryId)} already has a default tile`, queryId)); + } + const id = ctx.genTileId(); + const tile: DashboardTileV1 = { id, queryId }; + if (command.type === 'add-query-instance' && typeof command.variant === 'string') { + tile.presentation = { variant: command.variant }; + } + tiles.push(tile); + const query = ctx.resolver.get(queryId); + const sizeHints = isObject(query) && isObject(query.spec) && isObject(query.spec.dashboard) + ? query.spec.dashboard.sizeHints : undefined; + const placement = deriveFlowPlacement(sizeHints); + if (placement) setFlowPlacement(dashboard.layout, id, placement); + return { ok: true, dashboard, value: { tileId: id } }; + } + + case 'remove-tile': { + const index = tileIndex(tiles, command.tileId); + if (index < 0) return failWith(missingTile(command.tileId)); + tiles.splice(index, 1); + // The removed tile's placement becomes an orphan; layout normalization + // (the session's next step) prunes it. + return { ok: true, dashboard, value: undefined }; + } + + case 'move-tile': { + const index = tileIndex(tiles, command.tileId); + if (index < 0) return failWith(missingTile(command.tileId)); + const { toIndex } = command; + // Out-of-range indexes FAIL; they are never silently clamped (#280). + if (!Number.isInteger(toIndex) || toIndex < 0 || toIndex >= tiles.length) { + return failWith(diagnostic(['tiles'], 'dashboard-command-index-out-of-range', + `Move index ${JSON.stringify(toIndex)} is out of range 0..${tiles.length - 1}`)); + } + const [moved] = tiles.splice(index, 1); + tiles.splice(toIndex, 0, moved); + return { ok: true, dashboard, value: undefined }; + } + + case 'update-tile': { + const index = tileIndex(tiles, command.tileId); + if (index < 0) return failWith(missingTile(command.tileId)); + const { patch } = command; + if (!isObject(patch)) { + return failWith(diagnostic(['tiles', index], 'dashboard-command-invalid-patch', + 'Tile patch must be an object')); + } + if (Object.hasOwn(patch, 'id') || Object.hasOwn(patch, 'queryId')) { + return failWith(diagnostic(['tiles', index], 'dashboard-command-invalid-patch', + 'Tile patch may not change tile id or queryId')); + } + // A field patch on the tile: a top-level `null` member deletes that + // field; any other top-level member replaces it wholesale (cloned). + // `presentation` is the ONE exception — it is merged ONE LEVEL so a + // caller can set/clear `variant` and `override` independently without + // destroying the other: each sub-field is replaced wholesale (a `null` + // sub-field deletes it). Crucially the merge stops there — an `override` + // VALUE is stored verbatim (it is RFC 7396 patch data whose nested + // `null`s are meaningful and must NOT be consumed by a deeper merge). + const patched = { ...(tiles[index] as Record) }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) { delete patched[key]; continue; } + if (key === 'presentation') { patched.presentation = mergeTilePresentation(patched.presentation, value); continue; } + patched[key] = cloneJson(value); + } + tiles[index] = patched; + return { ok: true, dashboard, value: undefined }; + } + + case 'update-placement': { + const index = tileIndex(tiles, command.tileId); + if (index < 0) return failWith(missingTile(command.tileId)); + const placementDiags = ctx.plugin.validatePlacement(command.placement, + ['layout', 'items', command.tileId]); + if (placementDiags.length) return { ok: false, diagnostics: placementDiags }; + setFlowPlacement(dashboard.layout, command.tileId, cloneJson(command.placement)); + return { ok: true, dashboard, value: undefined }; + } + + // change-layout: the load/version/fallback failures are the session's + // `resolveActiveLayoutPlugin` step; a normalization that produces an + // invalid document is the session's validation step. Here we only install + // the (cloned) new layout. + default: + dashboard.layout = cloneJson(command.layout); + return { ok: true, dashboard, value: undefined }; + } +} diff --git a/src/dashboard/application/dashboard-query-resolver.ts b/src/dashboard/application/dashboard-query-resolver.ts new file mode 100644 index 00000000..80adc5ab --- /dev/null +++ b/src/dashboard/application/dashboard-query-resolver.ts @@ -0,0 +1,42 @@ +// Dashboard query resolver (#280 suggested `dashboard-query-resolver`). A +// narrow read-only view over the workspace's saved-query collection that the +// authoring commands and session use to look a query up by ID and read its +// Dashboard role and declared variants — without any command touching the +// query collection shape directly. Pure; first occurrence of a duplicate ID +// wins (the collection's own duplicate-ID check is a validation concern). + +import { queryDashboardRole } from '../model/workspace-semantics.js'; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +export interface QueryResolver { + /** The saved query with this ID, or `undefined`. */ + get(queryId: string): unknown; + /** Whether a query with this ID exists. */ + has(queryId: string): boolean; + /** The query's effective Dashboard role (`panel` when undeclared), or + * `undefined` when no such query exists. */ + role(queryId: string): string | undefined; + /** The query's declared presentation variants, or `undefined`. */ + variants(queryId: string): Record | undefined; +} + +/** Build a `QueryResolver` over a saved-query collection. */ +export function createQueryResolver(queries: readonly unknown[]): QueryResolver { + const byId = new Map(); + for (const query of queries) { + if (isObject(query) && typeof query.id === 'string' && !byId.has(query.id)) byId.set(query.id, query); + } + const get = (queryId: string): unknown => byId.get(queryId); + return { + get, + has: (queryId) => byId.has(queryId), + role: (queryId) => (byId.has(queryId) ? queryDashboardRole(byId.get(queryId)) : undefined), + variants: (queryId) => { + const query = byId.get(queryId); + const dashboard = isObject(query) && isObject(query.spec) ? query.spec.dashboard : undefined; + return isObject(dashboard) && isObject(dashboard.variants) ? dashboard.variants : undefined; + }, + }; +} diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts new file mode 100644 index 00000000..6f356637 --- /dev/null +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -0,0 +1,716 @@ +// DashboardViewerSession (#286 / #280 "Phase 4: viewer and flow layout"). The +// standalone, read-only Dashboard runtime: it takes an immutable +// DashboardDocumentV1 snapshot plus the workspace's saved queries and runs the +// Dashboard end-to-end — resolving each panel tile's presentation (through the +// ONE shared `presentation-resolver`), running the filter wave and the tile +// wave with bounded concurrency, per-tile cancellation, and stale-wave +// protection, and publishing everything through one `state` signal a renderer +// subscribes to. It owns runtime-only state (filter values/activation, tile +// results/errors/progress, the resolved flow layout) and NOTHING persisted. +// +// It depends only on narrow injected interfaces — a query executor, a +// connection (token preflight), a layout registry, and (by import, since they +// are pure) the flow layout math + the shared presentation resolver. It must +// NOT reach into the Workbench UI, the full `App`, global `AppState`, the +// editor adapters, `src/application/**`, or `src/net/**`; `build/ +// check-boundaries.mjs`'s `src/dashboard/**` and `src/dashboard/application` +// rules enforce that at compile time (issue #286 dependency-boundary tests). +// +// #235 resolution lives in the execution planner here: panels whose declared +// params cannot be affected by any filter SOURCE query run in PARALLEL with the +// filter wave; panels a source-backed filter targets wait for the wave and see +// the correct blanked/active values on the first pass. The overlap is computed +// from the explicit DashboardFilterDefinitionV1 `parameter`/`targets` contract. + +import { signal } from '@preact/signals-core'; +import type { ReadonlySignal, Signal } from '@preact/signals-core'; +import { + analyzeParameterizedSources, prepareParameterizedBatch, mergedSourceArgs, mergedSourceSql, fieldControls, +} from '../../core/param-pipeline.js'; +import type { + FieldControl, ParameterAnalysis, PreparedSource, PreparedFieldState, ValidationMode, BoundParamSnapshot, +} from '../../core/param-pipeline.js'; +import { hasOptionalBlocks } from '../../core/optional-blocks.js'; +import { detectSqlFormat } from '../../core/format.js'; +import { DASH_TILE_ROW_CAP, DASH_TILE_BYTE_CAP } from '../../core/dashboard.js'; +import { queryName } from '../../core/saved-query.js'; +import { 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 { FilterProvider, FilterHelperOption, MergeDashboardFilterHelpersResult } from '../../core/dashboard-filters.js'; +import { diagnostic as coreDiagnostic } 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 { resolvePresentation, resolveDashboardPresentations } from '../model/presentation-resolver.js'; +import { diagnostic as wsDiagnostic } from '../model/workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; +import { computeFlowLayout } from '../layouts/flow-layout.js'; +import type { FlowLayoutModel } from '../layouts/flow-layout.js'; +import type { DashboardLayoutRegistry } from '../layouts/layout-registry.js'; +import type { + DashboardDocumentV1, DashboardTileV1, DashboardFilterDefinitionV1, Panel, SavedQueryV2, +} from '../../generated/json-schema.types.js'; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +/** Bounded concurrency across a wave (a large Dashboard must not fire a + * thundering herd). Same cap the #276 phase-3b runtime used. */ +export const VIEWER_TILE_CONCURRENCY = 6; + +// ── State published to a renderer ───────────────────────────────────────────── + +export type ViewerTileStatus = 'idle' | 'loading' | 'unfilled' | 'error' | 'ready'; + +/** One tile's runtime state (read-only view for the renderer). */ +export interface ViewerTileState { + tileId: string; + queryId: string; + title: string; + status: ViewerTileStatus; + isKpi: boolean; + /** The resolved effective panel (base + variant + override), or null when the + * presentation could not resolve (then `status` is 'error'). */ + panel: Record | null; + columns: Column[] | null; + rows: unknown[][] | null; + meta: { rows: number; ms: number; bytes: number; truncated: boolean } | null; + error: string | null; + /** Param names still needing a value (status 'unfilled'). */ + unfilled: string[]; + /** Streamed row count while loading. */ + progressRows: number; +} + +export type ViewerFilterStatus = 'idle' | 'loading' | 'error' | 'success'; + +/** One Dashboard filter's runtime state. */ +export interface ViewerFilterState { + id: string; + parameter: string; + label: string; + active: boolean; + value: unknown; + status: ViewerFilterStatus; + /** Curated options from the filter's source query, when it has one. */ + options: FilterHelperOption[] | null; +} + +export interface DashboardViewState { + tiles: ViewerTileState[]; + filters: ViewerFilterState[]; + layout: FlowLayoutModel; + /** Count of ACTIVE filter DEFINITIONS (not non-empty stored values, #188). */ + activeFilterCount: number; + running: boolean; + updatedAt: number | null; + /** Presentation/structural diagnostics that make a tile invalid. */ + diagnostics: WorkspaceDiagnostic[]; +} + +// ── Narrow injected dependencies (no App / AppState / net imports) ──────────── + +/** The minimal streamed read the viewer needs — structurally the + * `QueryExecutionService.executeRead` seam, declared locally so the viewer + * never imports `src/application/**` (boundary rule). */ +export interface ViewerReadRequest { + sql: string; + format?: string; + rowLimit?: number; + params?: Record; + signal?: AbortSignal; + onChunk?: () => void; +} +export interface ViewerExecutor { + // The real `QueryExecutionService.executeRead` returns the (mutated) result; + // the viewer only reads the `result` it passed in, so the return is ignored. + executeRead(result: StreamResult, request: ViewerReadRequest): Promise; +} + +/** The connection preflight seam (mirrors `ConnectionSession.ensureFreshToken`), + * declared locally for the same boundary reason. */ +export interface ViewerConnection { + ensureFreshToken(): Promise; +} + +export interface DashboardViewerDeps { + /** The immutable Dashboard snapshot this session views. */ + document: DashboardDocumentV1; + /** The workspace saved queries a tile/filter resolves against. */ + queries: readonly SavedQueryV2[]; + exec: ViewerExecutor; + connection: ViewerConnection; + /** Resolves the active layout plugin + fallback. Defaults to none — the + * viewer computes the flow model directly from the document either way; the + * registry is used only to fail closed when the layout cannot load. */ + registry?: DashboardLayoutRegistry; + /** Perf clock (tile footer ms). */ + now(): number; + /** Wall clock (one snapshot per prepared wave). */ + wallNow(): number; + /** True at/below the mobile breakpoint — normalizes the flow to one column. */ + isMobile?(): boolean; + /** Fired when the token preflight fails (the shell wires sign-out). */ + onAuthFailed?(): void; + /** #171 bound-param recording on a successful tile. */ + recordBoundParams?(boundParams: BoundParamSnapshot[]): void; +} + +export interface DashboardViewerSession { + readonly state: ReadonlySignal; + /** The `{name:Type}` field controls the filter bar renders (structure only). */ + readonly controls: FieldControl[]; + /** One field's prepared #170 validation state against the filter bar's DRAFT + * values/active (in-progress typing) — for the shared invalid-field affordance. */ + getFilterField( + name: string, mode: ValidationMode, values: Record, active: Record, + ): PreparedFieldState; + /** Run the whole Dashboard once (token preflight → filter wave + tile waves). */ + start(): Promise; + /** Re-run every tile and the filter wave. */ + refresh(): Promise; + /** Re-run one tile with the current filter values. */ + refreshTile(tileId: string): Promise; + /** Set one filter's value (activates it) and run the one affected-panel wave. */ + setFilter(filterId: string, value: unknown): Promise; + /** Set one filter's value AND activation explicitly (the filter bar's commit, + * which owns activation for optional/curated fields), then run the one + * affected-panel wave. */ + applyFilter(filterId: string, value: unknown, active: boolean): Promise; + /** Deactivate one filter WITHOUT discarding its value (reactivation restores + * it); one affected-panel wave (#188 clear-one). */ + clearFilter(filterId: string): Promise; + /** Reset every filter to its `defaultActive`/`defaultValue`, coalesced into + * ONE affected-panel wave (#188 clear-all). */ + clearAllFilters(): Promise; + /** Abort one tile's in-flight request. */ + cancelTile(tileId: string): void; + /** Adopt a layout/order-edited document (reorder, span/height, preset) WITHOUT + * re-running any tile: existing tile results are preserved by tile ID and the + * flow model is recomputed. The tile SET must be unchanged (a membership + * change rebuilds the session). */ + syncDocument(next: DashboardDocumentV1): void; + /** Cancel all work and turn every later entry point into a no-op. */ + destroy(): void; +} + +// ── Per-tile / per-filter runtime records (never published directly) ────────── + +interface TileRuntime { + tile: DashboardTileV1; + query: SavedQueryV2 | undefined; + panel: Record | null; + explicit: Panel | null; + isKpi: boolean; + isText: boolean; + presentationError: WorkspaceDiagnostic | null; + gen: number; + abortController: AbortController | null; + state: ViewerTileState; +} + +interface FilterRuntime { + def: DashboardFilterDefinitionV1; + source: SavedQueryV2 | undefined; + gen: number; + abortController: AbortController | null; + provider: FilterProvider | null; + state: ViewerFilterState; +} + +const cfgType = (panel: unknown): string | undefined => + (isObject(panel) && isObject(panel.cfg) && typeof panel.cfg.type === 'string' ? panel.cfg.type : undefined); + +const toValueString = (value: unknown): string => + (typeof value === 'string' ? value : value == null ? '' : String(value)); + +/** Local copy of `effectiveFilterActive` (state.ts is off-limits to this + * layer): a param with an explicit activation entry uses it; otherwise a + * non-empty value counts as active. */ +function effectiveActive( + values: Record, active: Record, +): Record { + const out: Record = {}; + for (const [name, value] of Object.entries(values)) out[name] = value != null && value !== ''; + for (const [name, on] of Object.entries(active)) out[name] = !!on; + return out; +} + +/** Bounded-concurrency map preserving append order (results by index). */ +async function runPool(items: T[], limit: number, worker: (item: T) => Promise): Promise { + const results = new Array(items.length); + let next = 0; + const run = async (): Promise => { + while (next < items.length) { + const index = next++; + results[index] = await worker(items[index]); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) || 0 }, () => run())); + return results; +} + +/** Reserve the next generation for a runtime record and abort any in-flight + * request (stale-wave guard). Returns the reserved generation. */ +function supersede(record: { gen: number; abortController: AbortController | null }): number { + const generation = ++record.gen; + if (record.abortController) record.abortController.abort(); + record.abortController = null; + return generation; +} + +/** Build a `DashboardViewerSession`. */ +export function createDashboardViewerSession(deps: DashboardViewerDeps): DashboardViewerSession { + const { queries } = deps; + const registry = deps.registry; + // The active document — layout/order edits (`syncDocument`) replace it without + // re-running tiles; the initial tile SET is fixed for the session's analysis. + let documentRef: DashboardDocumentV1 = deps.document; + let destroyed = false; + + const queryById = new Map(); + for (const query of queries) { + if (isObject(query) && typeof query.id === 'string' && !queryById.has(query.id)) queryById.set(query.id, query); + } + + // Structural presentation validation (the SAME shared resolver) — reported + // up front so an invalid tile presentation is visible without executing. + const presentationDiagnostics = resolveDashboardPresentations({ + dashboard: documentRef, queries, path: ['dashboard'], + }); + + function buildTileRuntime(tile: DashboardTileV1): TileRuntime { + const query = typeof tile.queryId === 'string' ? queryById.get(tile.queryId) : undefined; + let panel: Record | null = null; + let presentationError: WorkspaceDiagnostic | null = null; + if (!query) { + presentationError = wsDiagnostic(['tiles'], 'dashboard-tile-query-missing', + `No saved query ${JSON.stringify(tile.queryId)} for tile ${JSON.stringify(tile.id)}`, tile.id); + } else { + const resolved = resolvePresentation({ query, tile }); + if (resolved.ok) panel = resolved.panel; + else presentationError = resolved.diagnostics[0]; + } + const type = cfgType(panel); + const isKpi = type === 'kpi'; + const isText = type === 'text'; + const explicit: Panel | null = isObject(panel) && isObject(panel.cfg) ? (panel as unknown as Panel) : null; + const title = (typeof tile.title === 'string' && tile.title) || (query ? queryName(query) : tile.queryId) || tile.id; + const state: ViewerTileState = { + tileId: tile.id, queryId: tile.queryId, title, isKpi, panel, + status: presentationError ? 'error' : 'idle', + columns: null, rows: null, meta: null, + error: presentationError ? presentationError.message : null, + unfilled: [], progressRows: 0, + }; + return { tile, query, panel, explicit, isKpi, isText, presentationError, gen: 0, abortController: null, state }; + } + + // One runtime record per tile, in semantic (dashboard.tiles) order. + const tiles: TileRuntime[] = (Array.isArray(documentRef.tiles) ? documentRef.tiles : []).map(buildTileRuntime); + + // Filter runtime records, in filter order. + const filters: FilterRuntime[] = (Array.isArray(documentRef.filters) ? documentRef.filters : []).map((def) => { + const source = typeof def.sourceQueryId === 'string' ? queryById.get(def.sourceQueryId) : undefined; + const active = def.defaultActive ?? (def.defaultValue != null && def.defaultValue !== ''); + const state: ViewerFilterState = { + id: def.id, parameter: def.parameter, label: def.label || def.parameter, + active, value: def.defaultValue ?? '', status: 'idle', options: null, + }; + return { def, source, gen: 0, abortController: null, provider: null, state }; + }); + const filterById = new Map(filters.map((filter) => [filter.def.id, filter])); + + // Parameter analysis over the tile SQL — fixed for the session (structure + // only). Text tiles and missing-query tiles contribute empty SQL. + const analysis: ParameterAnalysis = analyzeParameterizedSources(tiles.map((runtime) => ({ + id: runtime.tile.id, label: runtime.state.title, kind: 'tile', + sql: runtime.query && !runtime.isText ? runtime.query.sql : '', bindPolicy: 'row-returning', + }))); + const controls: FieldControl[] = fieldControls(analysis); + + // #235 overlap: the set of tile IDs a SOURCE-backed filter targets. A filter + // with `targets` names them explicitly; an absent `targets` means every + // panel tile whose query declares the filter's parameter. Only source-backed + // filters gate — a plain value filter's value is already known. + const affectedByFilterWave = new Set(); + for (const filter of filters) { + if (!filter.def.sourceQueryId) continue; + const explicitTargets = Array.isArray(filter.def.targets) ? filter.def.targets : null; + if (explicitTargets) { + for (const id of explicitTargets) affectedByFilterWave.add(id); + continue; + } + const field = analysis.fields[filter.def.parameter]; + if (!field) continue; + for (const sourceId of field.requiredIn.concat(field.optionalIn)) affectedByFilterWave.add(sourceId); + } + + // Curated option bundles from the last filter wave (param name → field). + let curated: MergeDashboardFilterHelpersResult['fields'] = {}; + + const rawValues = (): Record => + Object.fromEntries(filters.map((filter) => [filter.def.parameter, toValueString(filter.state.value)])); + const activeMap = (): Record => + Object.fromEntries(filters.map((filter) => [filter.def.parameter, filter.state.active])); + + // Prepare a batch, optionally against a caller's DRAFT values/active (the + // filter bar's in-progress typing) rather than the committed filter state — + // so live #170 validation can run without mutating committed state. + const prepareBatch = ( + mode: ValidationMode = 'execute', + values: Record = rawValues(), + active: Record = activeMap(), + ) => prepareParameterizedBatch(analysis, { + values: Object.fromEntries(Object.entries(values).map(([name, value]) => + [name, curated[name] && !active[name] ? '' : value])), + active: effectiveActive(values, active), + wallNowMs: deps.wallNow(), validationMode: mode, + }); + + /** One field's prepared #170 state against the caller's draft values/active + * (the filter bar reads this on every keystroke for the invalid affordance). */ + const getFilterField = ( + name: string, mode: ValidationMode, values: Record, active: Record, + ) => prepareBatch(mode, values, active).fields[name]; + + // ── State signal ──────────────────────────────────────────────────────── + const stateSignal: Signal = signal(buildState(false, null)); + + function buildState(running: boolean, updatedAt: number | null): DashboardViewState { + const mobile = !!deps.isMobile?.(); + const visible = tiles.map((runtime) => ({ id: runtime.tile.id, isKpi: runtime.isKpi })); + return { + tiles: tiles.map((runtime) => ({ ...runtime.state })), + filters: filters.map((filter) => ({ ...filter.state })), + layout: computeFlowLayout({ tiles: visible, layout: documentRef.layout, mobile }), + activeFilterCount: filters.filter((filter) => filter.state.active).length, + running, updatedAt, diagnostics: presentationDiagnostics, + }; + } + + function publish(running?: boolean, updatedAt?: number | null): void { + const previous = stateSignal.value; + stateSignal.value = buildState( + running ?? previous.running, + updatedAt === undefined ? previous.updatedAt : updatedAt, + ); + } + + // ── Tile execution ──────────────────────────────────────────────────────── + + function tileResultMeta(result: StreamResult, startedAt: number, finishedAt: number) { + return { + rows: result.rows.length, ms: Math.round(finishedAt - startedAt), + bytes: result.progress.bytes, truncated: result.capped, + }; + } + + async function runTile(runtime: TileRuntime, source: PreparedSource | undefined, generation: number): Promise { + if (runtime.gen !== generation || !source) return; + if (source.missing.length || source.invalid.length) { + runtime.state.status = 'unfilled'; + runtime.state.unfilled = source.missing.concat(source.invalid); + publish(); + return; + } + if (source.errors.length) { + runtime.state.status = 'error'; + runtime.state.error = source.errors[0]; + publish(); + return; + } + // `!`: runtime.query is present for every runnable (non-error) tile. + const querySql = runtime.query!.sql; + const execSql = hasOptionalBlocks(querySql) ? mergedSourceSql(source, querySql) : querySql; + const execution = panelExecution(runtime.explicit, execSql, { + format: 'Table', rowLimit: DASH_TILE_ROW_CAP + 1, + params: { readonly: 2, max_result_bytes: DASH_TILE_BYTE_CAP, ...mergedSourceArgs(source) }, + }); + const checkFormat = !runtime.isKpi; + if (execution.error || (checkFormat && detectSqlFormat(execSql))) { + runtime.state.status = 'error'; + runtime.state.error = execution.error + || 'Dashboard panels require structured streaming results. Remove the explicit FORMAT clause.'; + publish(); + return; + } + runtime.state.status = 'loading'; + runtime.state.progressRows = 0; + publish(); + const controller = new AbortController(); + runtime.abortController = controller; + const startedAt = deps.now(); + const rowCap = runtime.isKpi ? 2 : DASH_TILE_ROW_CAP; + // `!`: panelExecution always resolves a concrete format ('Table' default or 'KPI'). + const result = newResult(execution.format!, rowCap); + await deps.exec.executeRead(result, { + sql: execSql, format: execution.format, rowLimit: execution.rowLimit, + params: execution.params, signal: controller.signal, + onChunk: () => { + if (runtime.gen !== generation) return; + runtime.state.progressRows = result.progress.rows; + publish(); + }, + }); + if (runtime.gen !== generation) return; // superseded mid-stream + runtime.abortController = null; + if (result.error != null || result.cancelled) { + runtime.state.status = 'error'; + runtime.state.error = result.error || 'Cancelled'; + publish(); + return; + } + runtime.state.status = 'ready'; + runtime.state.error = null; + runtime.state.unfilled = []; + runtime.state.columns = result.columns as unknown as Column[]; + runtime.state.rows = result.rows; + runtime.state.meta = tileResultMeta(result, startedAt, deps.now()); + deps.recordBoundParams?.(source.statements.flatMap((statement) => statement.boundParams)); + publish(); + } + + function markTextAndErrorTiles(): void { + for (const runtime of tiles) { + if (runtime.presentationError) { runtime.state.status = 'error'; continue; } + if (runtime.isText) { + runtime.state.status = 'ready'; + runtime.state.columns = []; + runtime.state.rows = []; + } + } + } + + // A tile is runnable when it has a query and is neither a text panel nor a + // presentation error. + const runnableTiles = (): TileRuntime[] => + tiles.filter((runtime) => runtime.query && !runtime.isText && !runtime.presentationError); + + // ── Filter wave ───────────────────────────────────────────────────────── + + async function runFilterSource(filter: FilterRuntime, generation: number): Promise { + // `!`: only called for filters whose source query is present. + const query = filter.source!; + const execution = filterExecution(query.sql); + if (execution.error) { + const provider: FilterProvider = { + sourceId: filter.def.id, sourceName: queryName(query), helpers: [], diagnostics: execution.diagnostics, + }; + if (filter.gen !== generation) return null; + filter.state.status = 'error'; + filter.provider = provider; + return provider; + } + if (filter.gen !== generation) return null; + filter.state.status = 'loading'; + const result = newResult(execution.format, execution.rowLimit); + const controller = new AbortController(); + filter.abortController = controller; + await deps.exec.executeRead(result, { + sql: query.sql, format: execution.format, rowLimit: execution.rowLimit, + params: execution.params, signal: controller.signal, + }); + if (filter.gen !== generation) return null; + filter.abortController = null; + let provider: FilterProvider; + if (result.error || result.cancelled) { + provider = { + sourceId: filter.def.id, sourceName: queryName(query), helpers: [], + diagnostics: [coreDiagnostic('error', 'filter-query-failed', + `${queryName(query)}: ${result.error || 'Filter query was cancelled.'}`, { sourceId: filter.def.id })], + }; + filter.state.status = 'error'; + } else { + const normalized = readFilterOptions({ + columns: result.columns, row: result.rows[0], rowCount: result.rows.length, + }); + provider = { sourceId: filter.def.id, sourceName: queryName(query), ...normalized }; + filter.state.status = normalized.helpers.length ? 'success' : 'error'; + } + filter.provider = provider; + return provider; + } + + function applyFilterProviders(providers: (FilterProvider | null)[]): void { + const merged = mergeDashboardFilterHelpers({ + providers: providers.filter((provider): provider is FilterProvider => provider !== null), + controls, values: rawValues(), active: effectiveActive(rawValues(), activeMap()), + }); + curated = merged.fields; + // Publish curated options onto each filter whose parameter got a field. + for (const filter of filters) { + const field = merged.fields[filter.def.parameter]; + filter.state.options = field ? field.options : filter.state.options; + } + } + + async function runFilterWave(): Promise { + const sourced = filters.filter((filter) => filter.source); + const plan = sourced.map((filter) => ({ filter, generation: supersede(filter) })); + const providers = await runPool(plan, VIEWER_TILE_CONCURRENCY, + ({ filter, generation }) => runFilterSource(filter, generation)); + applyFilterProviders(providers); + } + + + // ── Waves ───────────────────────────────────────────────────────────────── + + async function preflight(): Promise { + if (destroyed) return false; + if (!(await deps.connection.ensureFreshToken())) { + if (!destroyed) deps.onAuthFailed?.(); + return false; + } + return !destroyed; + } + + function sourcesById(prepared: PreparedSource[]): Map { + return new Map(analysis.sources.map((source, index) => [source.id, prepared[index]])); + } + + async function refresh(): Promise { + if (!(await preflight())) return; + markTextAndErrorTiles(); + const runnable = runnableTiles(); + // Reserve every runnable tile's generation up front (stale-wave guard). + const generations = new Map(runnable.map((runtime) => [runtime.tile.id, supersede(runtime)])); + const unaffected = runnable.filter((runtime) => !affectedByFilterWave.has(runtime.tile.id)); + const affected = runnable.filter((runtime) => affectedByFilterWave.has(runtime.tile.id)); + publish(true); + // #235: launch the unaffected tiles NOW (with current values) in parallel + // with the filter wave — they never wait for a source query. + const firstBatch = sourcesById(prepareBatch('execute').sources); + const unaffectedWave = runPool(unaffected, VIEWER_TILE_CONCURRENCY, + (runtime) => runTile(runtime, firstBatch.get(runtime.tile.id), generations.get(runtime.tile.id)!)); + const filterWave = runFilterWave(); + await filterWave; + if (destroyed) { await unaffectedWave; return; } + // Affected tiles run AFTER the filter wave, with the merged/blanked values. + const secondBatch = sourcesById(prepareBatch('execute').sources); + const affectedWave = runPool(affected, VIEWER_TILE_CONCURRENCY, + (runtime) => runTile(runtime, secondBatch.get(runtime.tile.id), generations.get(runtime.tile.id)!)); + await Promise.all([unaffectedWave, affectedWave]); + publish(false, destroyed ? null : deps.now()); + } + + const start = refresh; + + async function refreshTile(tileId: string): Promise { + const runtime = tiles.find((entry) => entry.tile.id === tileId); + if (!runtime || !runtime.query || runtime.isText || runtime.presentationError) return; + if (!(await preflight())) return; + const generation = supersede(runtime); + const prepared = sourcesById(prepareBatch('execute').sources); + await runTile(runtime, prepared.get(tileId), generation); + } + + // Re-run only the tiles some active filter parameter feeds into. + async function runAffectedWave(parameters: string[]): Promise { + if (!(await preflight())) return; + const affectedIds = new Set(); + for (const parameter of parameters) { + const field = analysis.fields[parameter]; + if (!field) continue; + for (const sourceId of field.requiredIn.concat(field.optionalIn)) affectedIds.add(sourceId); + } + const targets = runnableTiles().filter((runtime) => affectedIds.has(runtime.tile.id)); + const generations = new Map(targets.map((runtime) => [runtime.tile.id, supersede(runtime)])); + const prepared = sourcesById(prepareBatch('execute').sources); + publish(); + await runPool(targets, VIEWER_TILE_CONCURRENCY, + (runtime) => runTile(runtime, prepared.get(runtime.tile.id), generations.get(runtime.tile.id)!)); + } + + async function setFilter(filterId: string, value: unknown): Promise { + if (destroyed) return; + const filter = filterById.get(filterId); + if (!filter) return; + filter.state.value = value; + filter.state.active = value != null && value !== ''; + publish(); + await runAffectedWave([filter.def.parameter]); + } + + async function applyFilter(filterId: string, value: unknown, active: boolean): Promise { + if (destroyed) return; + const filter = filterById.get(filterId); + if (!filter) return; + // The filter bar owns activation for optional/curated fields, so value and + // active are set independently (unlike setFilter's value-implies-active). + filter.state.value = value; + filter.state.active = active; + publish(); + await runAffectedWave([filter.def.parameter]); + } + + async function clearFilter(filterId: string): Promise { + if (destroyed) return; + const filter = filterById.get(filterId); + if (!filter) return; + // Deactivate but keep the value so reactivation restores it. + filter.state.active = false; + publish(); + await runAffectedWave([filter.def.parameter]); + } + + async function clearAllFilters(): Promise { + if (destroyed) return; + const changed: string[] = []; + for (const filter of filters) { + const nextActive = filter.def.defaultActive ?? false; + const nextValue = filter.def.defaultValue ?? ''; + if (filter.state.active !== nextActive || filter.state.value !== nextValue) changed.push(filter.def.parameter); + filter.state.active = nextActive; + filter.state.value = nextValue; + } + publish(); + // Coalesce every reset into ONE affected-panel wave (#188 clear-all). + if (changed.length) await runAffectedWave(changed); + } + + function cancelTile(tileId: string): void { + const runtime = tiles.find((entry) => entry.tile.id === tileId); + if (!runtime) return; + runtime.gen++; + if (runtime.abortController) { runtime.abortController.abort(); runtime.abortController = null; } + if (runtime.state.status === 'loading') { runtime.state.status = 'idle'; publish(); } + } + + function syncDocument(next: DashboardDocumentV1): void { + if (destroyed) return; + documentRef = next; + // Reorder the runtime records to the new tile order, preserving each tile's + // results by ID; unknown IDs are dropped (defensive — a membership change + // should rebuild the session, not sync). + const byId = new Map(tiles.map((runtime) => [runtime.tile.id, runtime])); + const reordered: TileRuntime[] = []; + for (const tile of Array.isArray(next.tiles) ? next.tiles : []) { + const runtime = byId.get(tile.id); + if (runtime) { runtime.tile = tile; reordered.push(runtime); } + } + tiles.length = 0; + tiles.push(...reordered); + publish(); + } + + function destroy(): void { + destroyed = true; + for (const runtime of tiles) { + runtime.gen++; + if (runtime.abortController) { runtime.abortController.abort(); runtime.abortController = null; } + } + for (const filter of filters) { + filter.gen++; + if (filter.abortController) { filter.abortController.abort(); filter.abortController = null; } + } + } + + return { + state: stateSignal as ReadonlySignal, + controls, getFilterField, + start, refresh, refreshTile, setFilter, applyFilter, clearFilter, clearAllFilters, cancelTile, syncDocument, destroy, + }; +} diff --git a/src/dashboard/application/saved-query-mutation.ts b/src/dashboard/application/saved-query-mutation.ts new file mode 100644 index 00000000..c3bd4bdf --- /dev/null +++ b/src/dashboard/application/saved-query-mutation.ts @@ -0,0 +1,177 @@ +// Saved-query mutations must preserve workspace validity (#280 "Saved-query +// mutations must preserve workspace validity"). Deleting a query, changing its +// Dashboard role, changing a filter source's role, deleting a selected +// variant, changing parameter declarations used by filters, or changing a base +// panel's type/structure can all invalidate Dashboard references. This pure +// planner constructs and validates a COMPLETE candidate workspace for any such +// mutation and rejects an invalidating one unless the caller supplies an atomic +// repair that produces a valid candidate. The repair + mutation apply to ONE +// candidate workspace, which the caller then commits atomically through the +// Phase-2 repository. Cancelling a mutation is simply not committing the plan. +// +// Every listed mutation reduces to deleting a query or replacing one query with +// a new version (role/variant/parameter/panel edits are all a replace), so the +// mutation surface is two kinds. Repairs mirror the #280 examples: remove the +// affected tiles, remove the affected filter definitions, switch tiles to +// another variant, or remap references to another query. + +import { cloneJson } from '../../core/saved-query.js'; +import type { JsonSchemaValidationService } from '../../core/json-schema-validation.js'; +import type { SpecSchemaService } from '../../core/spec-schema.js'; +import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; +import { resolveDashboardPresentations } from '../model/presentation-resolver.js'; +import { flowLayoutPlugin } from '../layouts/flow-layout.js'; +import { validateStoredWorkspaceDocument } from '../../workspace/stored-workspace.js'; +import type { + DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV1, +} from '../../generated/json-schema.types.js'; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +export type SavedQueryMutation = + | { type: 'delete-query'; queryId: string } + | { type: 'replace-query'; queryId: string; query: SavedQueryV2 }; + +export type SavedQueryRepairKind = + | 'remove-affected-tiles' | 'remove-affected-filters' | 'remove-affected' + | 'switch-variant' | 'remap-query'; + +export type SavedQueryRepair = + | { type: 'remove-affected-tiles' } + | { type: 'remove-affected-filters' } + | { type: 'remove-affected' } + | { type: 'switch-variant'; tileVariants: Record } + | { type: 'remap-query'; to: string }; + +/** The plan for one saved-query mutation. On success `candidate` is the valid + * candidate workspace to commit atomically. On failure `diagnostics` explains + * what the mutation would break and `repairs` lists the atomic repairs a UI + * can offer. */ +export interface SavedQueryMutationPlan { + ok: boolean; + candidate: StoredWorkspaceV1 | null; + diagnostics: WorkspaceDiagnostic[]; + repairs: SavedQueryRepairKind[]; +} + +export interface SavedQueryMutationOptions { + validationService?: JsonSchemaValidationService; + schemaService?: SpecSchemaService; +} + +function applyQueryMutation(queries: readonly SavedQueryV2[], mutation: SavedQueryMutation): SavedQueryV2[] { + if (mutation.type === 'delete-query') { + return queries.filter((query) => !(isObject(query) && query.id === mutation.queryId)); + } + return queries.map((query) => (isObject(query) && query.id === mutation.queryId ? mutation.query : query)); +} + +/** Tile IDs whose tile references the affected query. */ +function affectedTileIds(dashboard: DashboardDocumentV1, affectedId: string): Set { + const ids = new Set(); + for (const tile of dashboard.tiles) { + if (isObject(tile) && tile.queryId === affectedId && typeof tile.id === 'string') ids.add(tile.id); + } + return ids; +} + +function removeAffectedTiles(dashboard: DashboardDocumentV1, affectedId: string): DashboardDocumentV1 { + const removed = affectedTileIds(dashboard, affectedId); + const tiles = dashboard.tiles.filter((tile) => !(isObject(tile) && tile.queryId === affectedId)); + const filters = dashboard.filters.map((filter) => { + if (!isObject(filter) || !Array.isArray(filter.targets)) return filter; + return { ...filter, targets: filter.targets.filter((target) => !removed.has(target as string)) }; + }); + return { ...dashboard, tiles, filters }; +} + +function removeAffectedFilters(dashboard: DashboardDocumentV1, affectedId: string): DashboardDocumentV1 { + const targeted = affectedTileIds(dashboard, affectedId); + const filters = dashboard.filters.filter((filter) => { + if (!isObject(filter)) return true; + if (filter.sourceQueryId === affectedId) return false; + if (Array.isArray(filter.targets) && filter.targets.some((target) => targeted.has(target as string))) return false; + return true; + }); + return { ...dashboard, filters }; +} + +function switchVariants( + dashboard: DashboardDocumentV1, affectedId: string, tileVariants: Record, +): DashboardDocumentV1 { + const tiles = dashboard.tiles.map((tile) => { + if (!isObject(tile) || tile.queryId !== affectedId || typeof tile.id !== 'string') return tile; + const variant = tileVariants[tile.id]; + if (variant === undefined) return tile; + return { ...tile, presentation: { ...(isObject(tile.presentation) ? tile.presentation : {}), variant } }; + }); + return { ...dashboard, tiles }; +} + +function remapQuery(dashboard: DashboardDocumentV1, affectedId: string, to: string): DashboardDocumentV1 { + const tiles = dashboard.tiles.map((tile) => + (isObject(tile) && tile.queryId === affectedId ? { ...tile, queryId: to } : tile)); + const filters = dashboard.filters.map((filter) => + (isObject(filter) && filter.sourceQueryId === affectedId ? { ...filter, sourceQueryId: to } : filter)); + return { ...dashboard, tiles, filters }; +} + +function applyRepair(dashboard: DashboardDocumentV1, affectedId: string, repair: SavedQueryRepair): DashboardDocumentV1 { + switch (repair.type) { + case 'remove-affected-tiles': return removeAffectedTiles(dashboard, affectedId); + case 'remove-affected-filters': return removeAffectedFilters(dashboard, affectedId); + case 'remove-affected': return removeAffectedFilters(removeAffectedTiles(dashboard, affectedId), affectedId); + case 'switch-variant': return switchVariants(dashboard, affectedId, repair.tileVariants); + default: return remapQuery(dashboard, affectedId, repair.to); + } +} + +/** The repairs applicable to a set of diagnostics: a `filters`-scoped + * diagnostic offers filter removal; a `tiles`-scoped one offers tile removal, + * a variant switch, or a remap. */ +export function suggestRepairs(diagnostics: readonly WorkspaceDiagnostic[]): SavedQueryRepairKind[] { + const repairs = new Set(); + for (const diagnostic of diagnostics) { + if (diagnostic.path.includes('filters')) repairs.add('remove-affected-filters'); + else if (diagnostic.path.includes('tiles')) { + repairs.add('remove-affected-tiles'); + repairs.add('switch-variant'); + repairs.add('remap-query'); + } + } + return [...repairs]; +} + +function validateWorkspace( + candidate: StoredWorkspaceV1, options: SavedQueryMutationOptions, +): WorkspaceDiagnostic[] { + const codecOptions = options.validationService ? { validationService: options.validationService } : {}; + const structural = validateStoredWorkspaceDocument(candidate, codecOptions); + if (structural.length) return structural; + if (candidate.dashboard === null) return []; + return resolveDashboardPresentations({ + dashboard: candidate.dashboard, queries: candidate.queries, + schemaService: options.schemaService, path: ['dashboard'], + }); +} + +/** Plan one saved-query mutation against a workspace, optionally applying an + * atomic repair. Returns a valid candidate to commit, or the diagnostics and + * available repairs when the mutation would invalidate the workspace. */ +export function planSavedQueryMutation( + workspace: StoredWorkspaceV1, mutation: SavedQueryMutation, + repair?: SavedQueryRepair, options: SavedQueryMutationOptions = {}, +): SavedQueryMutationPlan { + const queries = applyQueryMutation(workspace.queries, mutation); + let dashboard: DashboardDocumentV1 | null = workspace.dashboard ? cloneJson(workspace.dashboard) : null; + if (dashboard && repair) dashboard = applyRepair(dashboard, mutation.queryId, repair); + if (dashboard) dashboard = flowLayoutPlugin.normalize(dashboard); + const candidate: StoredWorkspaceV1 = { + storageVersion: 1, id: workspace.id, name: workspace.name, + queries: cloneJson(queries), dashboard, + }; + const diagnostics = validateWorkspace(candidate, options); + if (diagnostics.length === 0) return { ok: true, candidate, diagnostics: [], repairs: [] }; + return { ok: false, candidate: null, diagnostics, repairs: suggestRepairs(diagnostics) }; +} diff --git a/src/dashboard/layouts/flow-layout.ts b/src/dashboard/layouts/flow-layout.ts new file mode 100644 index 00000000..b4b518c2 --- /dev/null +++ b/src/dashboard/layouts/flow-layout.ts @@ -0,0 +1,300 @@ +// The minimal flow@1 layout plugin the Phase-3 authoring commands drive +// (#280 "Normative flow@1 contract", "Dashboard layout registry and fallback"). +// Phase 3 needs exactly two capabilities from the active layout plugin: it +// must NORMALIZE a candidate document (prune placements that no longer name a +// tile — the reason `remove-tile` does not leave an orphan) and it must +// VALIDATE one placement for `update-placement`. The full flow@1 viewer/editor +// (packing, KPI bands, keyboard reorder) is Phase 4; this file is only the +// authoring-domain seam. Pure — no DOM, no rendering. +// +// The plugin also serves an unsupported primary layout that carries a valid +// flow@1 fallback: normalization then operates on the fallback's placements, +// exactly as a viewer that cannot load the primary engine would. + +import { diagnostic } from '../model/workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; +import { isSupportedLayout } from '../model/workspace-semantics.js'; +import { cloneJson } from '../../core/saved-query.js'; +import { partitionKpiBands } from '../../core/dashboard.js'; +import type { + DashboardDocumentV1, FlowHeightV1, FlowPresetV1, FlowTilePlacementV1, +} from '../../generated/json-schema.types.js'; + +/** The flow@1 default placement (#280): span 1, medium height. */ +export const DEFAULT_FLOW_PLACEMENT: FlowTilePlacementV1 = { span: 1, height: 'medium' }; + +const VALID_SPANS = new Set([1, 2, 3]); +const VALID_HEIGHTS = new Set(['compact', 'medium', 'large']); +const PLACEMENT_FIELDS = new Set(['span', 'height']); + +type Path = (string | number)[]; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +/** The narrow layout-plugin contract the authoring session/commands use. */ +export interface DashboardLayoutPlugin { + readonly type: string; + readonly version: number; + /** Return a normalized COPY of the document (input never mutated). */ + normalize(dashboard: DashboardDocumentV1): DashboardDocumentV1; + /** Diagnostics for one placement object (empty when valid). */ + validatePlacement(placement: unknown, path?: Path): WorkspaceDiagnostic[]; +} + +/** The object holding the active flow placements — the primary layout's + * `items` when it is flow@1, else a valid flow@1 fallback's `items`, else + * `null` (no flow surface to normalize). */ +function flowItemsHost(layout: unknown): Record | null { + if (!isObject(layout)) return null; + if (isSupportedLayout(layout.type, layout.version)) { + if (!isObject(layout.items)) { layout.items = {}; } + return layout.items as Record; + } + const fallback = layout.fallback; + if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) { + if (!isObject(fallback.items)) { fallback.items = {}; } + return fallback.items as Record; + } + return null; +} + +/** Set one tile's flow placement on a layout document (mutates in place). + * No-op when the layout has no flow surface. */ +export function setFlowPlacement(layout: unknown, tileId: string, placement: unknown): void { + const items = flowItemsHost(layout); + if (items) items[tileId] = placement; +} + +/** Derive an initial flow placement from a query's `sizeHints.preferred` + * (`compact|medium|wide` → span `1|2|3`), or `undefined` when there is no + * usable hint (the tile then renders at the flow default). */ +export function deriveFlowPlacement(sizeHints: unknown): FlowTilePlacementV1 | undefined { + if (!isObject(sizeHints)) return undefined; + const span = sizeHints.preferred === 'wide' ? 3 + : sizeHints.preferred === 'medium' ? 2 + : sizeHints.preferred === 'compact' ? 1 : undefined; + if (span === undefined) return undefined; + return { span, height: 'medium' }; +} + +function normalize(dashboard: DashboardDocumentV1): DashboardDocumentV1 { + const next = cloneJson(dashboard); + const tileIds = new Set(); + for (const tile of Array.isArray(next.tiles) ? next.tiles : []) { + if (isObject(tile) && typeof tile.id === 'string') tileIds.add(tile.id); + } + const items = flowItemsHost(next.layout); + if (items) { + for (const key of Object.keys(items)) { + if (!tileIds.has(key)) delete items[key]; + } + } + return next; +} + +function validatePlacement(placement: unknown, path: Path = []): WorkspaceDiagnostic[] { + if (!isObject(placement)) { + return [diagnostic(path, 'layout-placement-invalid', 'Placement must be an object')]; + } + const out: WorkspaceDiagnostic[] = []; + for (const key of Object.keys(placement)) { + if (!PLACEMENT_FIELDS.has(key)) { + out.push(diagnostic([...path, key], 'layout-placement-unknown-field', + `Unknown flow placement field ${JSON.stringify(key)}`)); + } + } + if (Object.hasOwn(placement, 'span') && !VALID_SPANS.has(placement.span)) { + out.push(diagnostic([...path, 'span'], 'layout-placement-invalid-span', 'Flow placement span must be 1, 2, or 3')); + } + if (Object.hasOwn(placement, 'height') && !VALID_HEIGHTS.has(placement.height)) { + out.push(diagnostic([...path, 'height'], 'layout-placement-invalid-height', + 'Flow placement height must be compact, medium, or large')); + } + return out; +} + +/** The single flow@1 plugin instance (stateless; safe to share). */ +export const flowLayoutPlugin: DashboardLayoutPlugin = { + type: 'flow', version: 1, normalize, validatePlacement, +}; + +export type LoadLayoutPluginResult = + | { ok: true; plugin: DashboardLayoutPlugin } + | { ok: false; diagnostics: WorkspaceDiagnostic[] }; + +// ── Phase 4: the normative flow@1 render model (#280 "Normative flow@1 +// contract") ──────────────────────────────────────────────────────────────── +// Pure layout MATH shared by the viewer, the authoring preview, print/export, +// and the tests — no DOM, no persistence. Exact pixels, gaps, and report width +// stay renderer/theme concerns; this module owns column count, effective span, +// deterministic row-major packing, KPI-band grouping, and mobile normalization. + +/** Desktop column count for each flow preset (#280 "Presets"). full-width and + * report render one column (report centers a constrained-width column); + * columns-2/columns-3 render two/three equal columns. */ +export const FLOW_PRESET_COLUMNS: Record = { + 'full-width': 1, report: 1, 'columns-2': 2, 'columns-3': 3, +}; + +const FLOW_PRESETS = new Set(Object.keys(FLOW_PRESET_COLUMNS)); + +/** The mobile breakpoint (#280 "Responsive/mobile normalization"): at or below + * this width the flow renders a single column with every effective span 1 and + * the persisted preset/span/height untouched. */ +export const FLOW_MOBILE_BREAKPOINT = 768; + +/** The desktop column count for a preset; an unknown/absent preset falls back + * to full-width (1). */ +export function presetColumns(preset: unknown): number { + return typeof preset === 'string' && Object.hasOwn(FLOW_PRESET_COLUMNS, preset) + ? FLOW_PRESET_COLUMNS[preset as FlowPresetV1] : 1; +} + +/** The effective span for one tile (#280 "Preset changes"): `min(storedSpan ?? + * 1, activeColumnCount)`. A stored span that is not 1|2|3 is treated as the + * default 1; changing presets never rewrites the stored span. */ +export function effectiveSpan(storedSpan: unknown, activeColumnCount: number): number { + const span = VALID_SPANS.has(storedSpan) ? (storedSpan as number) : 1; + return Math.min(span, Math.max(1, activeColumnCount)); +} + +/** Merge one stored placement with `DEFAULT_FLOW_PLACEMENT` (#280 "Missing + * placement defaults"): a missing/invalid span or height falls back to the + * default, so the result is always a complete `{span, height}`. */ +export function resolvePlacement(placement: unknown): Required { + const p = isObject(placement) ? placement : {}; + return { + span: VALID_SPANS.has(p.span) ? (p.span as 1 | 2 | 3) : DEFAULT_FLOW_PLACEMENT.span!, + height: VALID_HEIGHTS.has(p.height) ? (p.height as FlowHeightV1) : DEFAULT_FLOW_PLACEMENT.height!, + }; +} + +/** The flow surface a layout document renders through (read-only, no mutation, + * unlike `flowItemsHost`): the primary layout when it is flow@1, else a valid + * flow@1 fallback, else `null`. */ +function flowSurface(layout: unknown): Record | null { + if (!isObject(layout)) return null; + if (isSupportedLayout(layout.type, layout.version)) return layout; + const fallback = layout.fallback; + if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) return fallback; + return null; +} + +/** One tile as placed for one render pass. `span` is already clamped to the + * active column count (and 1 on mobile); `index` is the tile's position in the + * visible-tiles input, which equals `dashboard.tiles[]` semantic order. */ +export interface FlowTileRender { + tileId: string; + index: number; + span: number; + height: FlowHeightV1; + isKpi: boolean; +} + +/** One packed flow row: an ordinary run of tiles filling the active columns, or + * a KPI band that starts its own full-width row (#280 "KPI bands"). */ +export interface FlowRow { + kind: 'tiles' | 'kpi-band'; + columns: number; + tiles: FlowTileRender[]; +} + +/** The computed flow render model — enough for a renderer to lay tiles out and + * for tests to assert the order equivalence, with no DOM involved. */ +export interface FlowLayoutModel { + preset: FlowPresetV1; + /** Effective columns: the preset's desktop columns, or 1 on mobile. */ + columns: number; + mobile: boolean; + rows: FlowRow[]; + /** Visible tile IDs in semantic order — equals DOM = keyboard traversal = + * visual row-major = print/export order (#280 order equivalence). */ + order: string[]; +} + +/** One visible tile the flow lays out, in `dashboard.tiles[]` semantic order. + * `isKpi` marks an explicitly-configured KPI panel (band member). */ +export interface FlowVisibleTile { + id: string; + isKpi?: boolean; +} + +export interface ComputeFlowLayoutInput { + tiles: readonly FlowVisibleTile[]; + layout: unknown; + /** True at/below `FLOW_MOBILE_BREAKPOINT` — one column, every span 1, order + * unchanged, persistence untouched. */ + mobile?: boolean; +} + +/** + * Compute the deterministic flow render model (#280 "Packing and collision + * rules", "KPI bands", "Responsive/mobile normalization"). Row-major packing: + * read the visible tiles in `dashboard.tiles[]` order, resolve each effective + * span, place it in the current row when it fits, else start the next row — + * tiles never overlap. A maximal consecutive run of KPI tiles becomes one + * full-width band row that occupies all active columns; the members keep + * semantic order. Mobile forces one column and every span to 1 WITHOUT + * touching the persisted preset/span/height. Pure and non-mutating. + */ +export function computeFlowLayout(input: ComputeFlowLayoutInput): FlowLayoutModel { + const { tiles, layout, mobile = false } = input; + const surface = flowSurface(layout); + const rawPreset = surface && typeof surface.preset === 'string' ? surface.preset : undefined; + const preset: FlowPresetV1 = rawPreset && FLOW_PRESETS.has(rawPreset) ? rawPreset as FlowPresetV1 : 'full-width'; + const items = surface && isObject(surface.items) ? surface.items as Record : {}; + const columns = mobile ? 1 : presetColumns(preset); + + const renders: FlowTileRender[] = tiles.map((tile, index) => { + const placement = resolvePlacement(items[tile.id]); + return { + tileId: tile.id, index, isKpi: !!tile.isKpi, height: placement.height, + span: mobile ? 1 : effectiveSpan(placement.span, columns), + }; + }); + + // Reuse the #240 consecutive-KPI-run partition, then row-pack the ordinary + // tiles between bands. A band flushes the current tile row and starts fresh. + const rows: FlowRow[] = []; + let current: FlowRow | null = null; + let remaining = 0; + for (const item of partitionKpiBands(tiles.map((t) => !!t.isKpi))) { + if (item.kind === 'kpi-band') { + current = null; + remaining = 0; + rows.push({ kind: 'kpi-band', columns, tiles: item.indices.map((i) => renders[i]) }); + continue; + } + const render = renders[item.index]; + if (!current || remaining < render.span) { + current = { kind: 'tiles', columns, tiles: [] }; + rows.push(current); + remaining = columns; + } + current.tiles.push(render); + remaining -= render.span; + } + + return { preset, columns, mobile, rows, order: tiles.map((tile) => tile.id) }; +} + +/** Resolve the active layout plugin for one layout document. flow@1 primary → + * the flow plugin; an unsupported primary WITH a valid flow@1 fallback → the + * flow plugin (operating on the fallback); otherwise a load failure — the + * `change-layout` "plugin cannot load / unsupported version without a valid + * fallback" path. */ +export function resolveActiveLayoutPlugin(layout: unknown, path: Path = ['layout']): LoadLayoutPluginResult { + if (isObject(layout)) { + if (isSupportedLayout(layout.type, layout.version)) return { ok: true, plugin: flowLayoutPlugin }; + const fallback = layout.fallback; + if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) { + return { ok: true, plugin: flowLayoutPlugin }; + } + } + return { + ok: false, + diagnostics: [diagnostic(path, 'dashboard-layout-load-failed', + 'Dashboard layout cannot be loaded and has no valid flow@1 fallback')], + }; +} diff --git a/src/dashboard/layouts/layout-registry.ts b/src/dashboard/layouts/layout-registry.ts new file mode 100644 index 00000000..88d2f07e --- /dev/null +++ b/src/dashboard/layouts/layout-registry.ts @@ -0,0 +1,118 @@ +// The Dashboard layout registry (#280 "Dashboard layout registry and +// fallback"). Compile-time-registered, lazy-loadable layout modules — never +// arbitrary remote JavaScript plugins. A registration names one layout `id`, +// the contract `versions` it can load, and an async `load(version)` that +// resolves the concrete plugin (so a rarely-used engine's code can split out of +// the initial artifact without changing this contract). +// +// Fallback contract: `DashboardLayoutFallbackV1 = FlowLayoutV1`. When the +// primary layout cannot load (unknown engine, unsupported version, or a +// `load()` that throws), a consumer validates and renders the layout's flow@1 +// fallback instead. An unsupported primary WITHOUT a valid flow@1 fallback +// fails before any execution. Fallback placement keys resolve against the same +// `dashboard.tiles[]`, so the caller simply computes the flow model from the +// same layout document (see `computeFlowLayout`, which reads a fallback host). +// Pure application logic — no DOM, no network. + +import { diagnostic } from '../model/workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; +import { isSupportedLayout } from '../model/workspace-semantics.js'; +import { flowLayoutPlugin } from './flow-layout.js'; +import type { DashboardLayoutPlugin } from './flow-layout.js'; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +/** One compile-time layout registration (#280). `load` is async so an engine's + * implementation can be code-split; it must reject/throw only for a genuine + * load failure (a caller then falls back). */ +export interface DashboardLayoutRegistration { + id: string; + versions: readonly number[]; + load(version: number): Promise; +} + +/** The outcome of resolving a layout document against the registry. `plugin` is + * always a loaded plugin; `usedFallback` is true when the primary engine could + * not load and the flow@1 fallback was resolved instead. */ +export type ResolveLayoutResult = + | { ok: true; plugin: DashboardLayoutPlugin; usedFallback: boolean } + | { ok: false; diagnostics: WorkspaceDiagnostic[] }; + +export interface DashboardLayoutRegistry { + /** True when some registration can load `id` at `version`. */ + supports(id: unknown, version: unknown): boolean; + /** Load one plugin by id+version, or `null` when unsupported or `load` threw. */ + load(id: string, version: number): Promise; + /** Resolve a layout document: the primary engine when supported and loadable, + * else a valid flow@1 fallback, else a `dashboard-layout-load-failed` + * failure (#280 "unsupported primary layout without a valid flow@1 fallback + * fails before execution"). */ + resolve(layout: unknown, path?: (string | number)[]): Promise; +} + +/** The always-available flow@1 registration — its `load` returns the shared, + * stateless plugin instance (no code-splitting needed for the built-in). */ +export const flowLayoutRegistration: DashboardLayoutRegistration = { + id: 'flow', + versions: [1], + load: () => Promise.resolve(flowLayoutPlugin), +}; + +/** Build a registry from a set of registrations. flow@1 is always present (a + * passed `flow` registration is ignored in favour of the built-in, so the + * fallback engine can never be shadowed). */ +export function createLayoutRegistry( + registrations: readonly DashboardLayoutRegistration[] = [], +): DashboardLayoutRegistry { + const byId = new Map(); + byId.set('flow', flowLayoutRegistration); + for (const registration of registrations) { + if (registration.id !== 'flow' && !byId.has(registration.id)) byId.set(registration.id, registration); + } + + const supports = (id: unknown, version: unknown): boolean => { + if (typeof id !== 'string' || typeof version !== 'number') return false; + const registration = byId.get(id); + return !!registration && registration.versions.includes(version); + }; + + const load = async (id: string, version: number): Promise => { + if (!supports(id, version)) return null; + try { + // `!`: supports() confirmed the registration exists. + return await byId.get(id)!.load(version); + } catch { + // A load failure is a fallback trigger, never a thrown error to the caller. + return null; + } + }; + + const flowFallbackPlugin = async (layout: Record): Promise => { + const fallback = layout.fallback; + if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) { + return load('flow', 1); + } + return null; + }; + + const resolve = async (layout: unknown, path: (string | number)[] = ['layout']): Promise => { + if (isObject(layout)) { + const primary = supports(layout.type, layout.version) ? await load(layout.type as string, layout.version as number) : null; + if (primary) return { ok: true, plugin: primary, usedFallback: false }; + const fallback = await flowFallbackPlugin(layout); + if (fallback) return { ok: true, plugin: fallback, usedFallback: true }; + } + return { + ok: false, + diagnostics: [diagnostic(path, 'dashboard-layout-load-failed', + 'Dashboard layout cannot be loaded and has no valid flow@1 fallback')], + }; + }; + + return { supports, load, resolve }; +} + +/** The default registry: only the built-in flow@1 engine (v1 ships no other + * layout). */ +export const defaultLayoutRegistry: DashboardLayoutRegistry = createLayoutRegistry(); diff --git a/src/dashboard/model/bundle-order.ts b/src/dashboard/model/bundle-order.ts new file mode 100644 index 00000000..7bb4f6fb --- /dev/null +++ b/src/dashboard/model/bundle-order.ts @@ -0,0 +1,100 @@ +// Canonical resource ordering for portable bundles (#280 "Canonical output +// ordering"): Dashboard dependency-closure query order, multi-Dashboard +// tooling order, and the lexicographic Dashboard sort. Pure structural +// helpers — the canonical encoder handles key ordering, these handle which +// resources appear where. A full current-workspace export deliberately has +// no helper here: it preserves catalog query order and emits the zero-or-one +// current Dashboard as-is. + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const idOf = (value: unknown): string | undefined => { + if (!isObject(value)) return undefined; + return typeof value.id === 'string' ? value.id : undefined; +}; + +const compareStrings = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** Referenced query IDs of one Dashboard in canonical dependency order: + * tiles in semantic order first (first occurrence wins), then filter + * sources in filter order; every ID once. Setup references are absent in + * v1 by contract, so nothing here special-cases them. */ +export function dashboardDependencyQueryIds(dashboard: unknown): string[] { + if (!isObject(dashboard)) return []; + const seen = new Set(); + const out: string[] = []; + const add = (id: unknown): void => { + if (typeof id !== 'string' || seen.has(id)) return; + seen.add(id); + out.push(id); + }; + const tiles = Array.isArray(dashboard.tiles) ? dashboard.tiles : []; + for (const tile of tiles) { + if (isObject(tile)) add(tile.queryId); + } + const filters = Array.isArray(dashboard.filters) ? dashboard.filters : []; + for (const filter of filters) { + if (isObject(filter)) add(filter.sourceQueryId); + } + return out; +} + +/** Sort dashboards lexicographically by normalized (NFC) Dashboard ID — + * multi-Dashboard bundle array order carries no catalog meaning in v1. + * Returns a new array; a Dashboard without a string ID sorts last in its + * original relative order. */ +export function sortDashboardsCanonically(dashboards: readonly T[]): T[] { + return dashboards + .map((dashboard, index) => ({ dashboard, index, id: idOf(dashboard)?.normalize('NFC') })) + .sort((a, b) => { + if (a.id === undefined || b.id === undefined) { + return a.id === b.id ? a.index - b.index : (a.id === undefined ? 1 : -1); + } + return compareStrings(a.id, b.id) || a.index - b.index; + }) + .map((entry) => entry.dashboard); +} + +/** Order `queries` for a multi-Dashboard bundle produced by tooling: first + * reference across the given Dashboard order (tile order, then filter + * order, per Dashboard), then unreferenced queries in their original + * catalog order; every query once. */ +export function orderBundleQueries(queries: readonly T[], dashboards: readonly unknown[]): T[] { + const byId = new Map(); + for (const query of queries) { + const id = idOf(query); + if (id !== undefined && !byId.has(id)) byId.set(id, query); + } + const emittedIds = new Set(); + const out: T[] = []; + const emit = (query: T, id: string | undefined): void => { + if (id !== undefined) { + if (emittedIds.has(id)) return; + emittedIds.add(id); + } + out.push(query); + }; + for (const dashboard of dashboards) { + for (const id of dashboardDependencyQueryIds(dashboard)) { + const query = byId.get(id); + if (query !== undefined) emit(query, id); + } + } + // Unreferenced queries follow in catalog order; a duplicate catalog entry + // for an already-emitted id is dropped so every query id appears once. + for (const query of queries) emit(query, idOf(query)); + return out; +} + +/** Apply the complete multi-Dashboard tooling arrangement: canonical + * Dashboard sort plus first-reference query order. */ +export function arrangeBundleResources( + { queries, dashboards }: { queries: readonly Q[]; dashboards: readonly D[] }, +): { queries: Q[]; dashboards: D[] } { + const orderedDashboards = sortDashboardsCanonically(dashboards); + return { + dashboards: orderedDashboards, + queries: orderBundleQueries(queries, orderedDashboards), + }; +} diff --git a/src/dashboard/model/canonical-json.ts b/src/dashboard/model/canonical-json.ts new file mode 100644 index 00000000..859e3286 --- /dev/null +++ b/src/dashboard/model/canonical-json.ts @@ -0,0 +1,158 @@ +// The one canonical deterministic JSON encoder (#280 "Canonical output +// ordering") used for export, persistence snapshots, hashing input, equality +// checks, and snapshot tests. Rules: +// - arrays retain semantic order; +// - schema-defined objects use one documented canonical field order (the +// shape tables below mirror the schemas' x-altinity-order annotations); +// - map-like keys (layout.items, variant names, column maps) sort +// lexicographically by code unit; +// - open config/extension objects (and any field without a declared shape) +// are recursively key-sorted. +// Output formatting matches `JSON.stringify(value, null, 2)` byte for byte, +// but object key traversal is explicit, so integer-like map keys can never +// fall into JavaScript's numeric-first property order. Pure. + +/** Canonical ordering description for one JSON node. `order` lists the + * schema-defined field order (unknown extras follow, key-sorted); `fields` + * names known fields' shapes; `map` marks a map-like object whose keys sort + * lexicographically and whose every value shares one shape; `items` shapes + * array elements. A node without a shape is open: recursively key-sorted. */ +export interface CanonicalShape { + readonly order?: readonly string[]; + readonly fields?: Readonly>; + readonly map?: CanonicalShape; + readonly items?: CanonicalShape; +} + +function orderedKeys(value: Record, shape?: CanonicalShape): string[] { + const keys = Object.keys(value); + if (shape?.order) { + const declared = new Set(shape.order); + return [ + ...shape.order.filter((key) => Object.hasOwn(value, key)), + ...keys.filter((key) => !declared.has(key)).sort(), + ]; + } + return keys.sort(); +} + +function encodeValue(value: unknown, shape: CanonicalShape | undefined, pad: string): string | undefined { + if (value === null) return 'null'; + const kind = typeof value; + if (kind === 'string' || kind === 'number' || kind === 'boolean') return JSON.stringify(value); + if (kind !== 'object') return undefined; // undefined/function/symbol — mirror JSON.stringify + const inner = pad + ' '; + if (Array.isArray(value)) { + if (!value.length) return '[]'; + const parts = value.map((item) => encodeValue(item, shape?.items, inner) ?? 'null'); + return `[\n${inner}${parts.join(`,\n${inner}`)}\n${pad}]`; + } + const object = value as Record; + const parts: string[] = []; + for (const key of orderedKeys(object, shape)) { + const encoded = encodeValue(object[key], shape?.map ?? shape?.fields?.[key], inner); + if (encoded !== undefined) parts.push(`${JSON.stringify(key)}: ${encoded}`); + } + return parts.length ? `{\n${inner}${parts.join(`,\n${inner}`)}\n${pad}}` : '{}'; +} + +/** Deterministically encode a JSON value. Throws only for a non-JSON root + * (undefined/function); non-JSON members are skipped like JSON.stringify. */ +export function canonicalJson(value: unknown, shape?: CanonicalShape): string { + const encoded = encodeValue(value, shape, ''); + if (encoded === undefined) throw new Error('Cannot canonically encode a non-JSON root value'); + return encoded; +} + +/** Canonical equality: equal exactly when the canonical encodings match. */ +export function canonicalEqual(a: unknown, b: unknown, shape?: CanonicalShape): boolean { + return canonicalJson(a, shape) === canonicalJson(b, shape); +} + +// --- Documented canonical field orders ------------------------------------- +// These tables mirror the schemas' x-altinity-order annotations (the schema +// files are the source of truth; change both together). Style objects, +// presentation patches, layout config, and filter default values are open +// documents and stay recursively key-sorted. + +const FIELD_CONFIG_VALUE_SHAPE: CanonicalShape = { + order: ['displayName', 'description', 'unit', 'decimals', 'color', 'noValue', 'hidden', 'delta'], + fields: { delta: { order: ['displayName', 'unit', 'decimals', 'positiveIsGood', 'show'] } }, +}; + +const PANEL_SHAPE: CanonicalShape = { + order: ['cfg', 'key', 'fieldConfig'], + fields: { + cfg: { order: ['type', 'style', 'x', 'y', 'series'] }, + fieldConfig: { + order: ['defaults', 'columns'], + fields: { + defaults: FIELD_CONFIG_VALUE_SHAPE, + columns: { map: FIELD_CONFIG_VALUE_SHAPE }, + }, + }, + }, +}; + +export const QUERY_SPEC_SHAPE: CanonicalShape = { + order: ['name', 'description', 'favorite', 'view', 'panel', 'dashboard'], + fields: { + panel: PANEL_SHAPE, + dashboard: { + order: ['role', 'defaultVariant', 'variants', 'sizeHints'], + fields: { + variants: { map: {} }, + sizeHints: { order: ['preferred', 'minimum', 'aspectRatio'] }, + }, + }, + }, +}; + +export const SAVED_QUERY_SHAPE: CanonicalShape = { + order: ['id', 'sql', 'specVersion', 'spec'], + fields: { spec: QUERY_SPEC_SHAPE }, +}; + +const FLOW_PLACEMENT_SHAPE: CanonicalShape = { order: ['span', 'height'] }; + +export const FLOW_LAYOUT_SHAPE: CanonicalShape = { + order: ['type', 'version', 'preset', 'items'], + fields: { items: { map: FLOW_PLACEMENT_SHAPE } }, +}; + +// Generic layout placements share the flow placement order so one placement +// object encodes identically as a primary flow@1 item and as a fallback item; +// a non-flow engine's placement fields simply fall through to key sorting. +const LAYOUT_DOCUMENT_SHAPE: CanonicalShape = { + order: ['type', 'version', 'preset', 'config', 'items', 'fallback'], + fields: { + items: { map: FLOW_PLACEMENT_SHAPE }, + fallback: FLOW_LAYOUT_SHAPE, + }, +}; + +export const DASHBOARD_DOCUMENT_SHAPE: CanonicalShape = { + order: ['documentVersion', 'id', 'title', 'description', 'revision', 'layout', 'filters', 'tiles'], + fields: { + layout: LAYOUT_DOCUMENT_SHAPE, + filters: { items: { order: ['id', 'parameter', 'label', 'sourceQueryId', 'targets', 'defaultValue', 'defaultActive'] } }, + tiles: { items: { order: ['id', 'queryId', 'title', 'description', 'presentation'], fields: { presentation: { order: ['variant', 'override'] } } } }, + }, +}; + +export const STORED_WORKSPACE_SHAPE: CanonicalShape = { + order: ['storageVersion', 'id', 'name', 'queries', 'dashboard'], + fields: { + queries: { items: SAVED_QUERY_SHAPE }, + dashboard: DASHBOARD_DOCUMENT_SHAPE, + }, +}; + +export const PORTABLE_BUNDLE_SHAPE: CanonicalShape = { + order: ['$schema', 'format', 'version', 'exportedAt', 'metadata', 'queries', 'dashboards'], + fields: { + metadata: { order: ['name', 'description'] }, + queries: { items: SAVED_QUERY_SHAPE }, + dashboards: { items: DASHBOARD_DOCUMENT_SHAPE }, + }, +}; diff --git a/src/dashboard/model/json-limits.ts b/src/dashboard/model/json-limits.ts new file mode 100644 index 00000000..a3daa656 --- /dev/null +++ b/src/dashboard/model/json-limits.ts @@ -0,0 +1,93 @@ +// Codec-level JSON resource guards (#280 "Resource limits"): UTF-8 byte size +// is enforced BEFORE parsing, maximum container depth is enforced by a +// string scan BEFORE parsing (so a nesting bomb never reaches JSON.parse), +// and the same byte counter re-checks normalized serialized sizes after +// encoding. Pure — no TextEncoder or other environment globals. + +import { PORTABLE_LIMITS } from './portable-limits.js'; +import { diagnostic } from './workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from './workspace-diagnostics.js'; + +/** UTF-8 byte length of `text`, matching the WHATWG TextEncoder contract + * (an unpaired surrogate encodes as U+FFFD — three bytes). */ +export function utf8ByteLength(text: string): number { + let bytes = 0; + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index); + if (code < 0x80) bytes += 1; + else if (code < 0x800) bytes += 2; + else if (code >= 0xd800 && code < 0xdc00) { + const next = index + 1 < text.length ? text.charCodeAt(index + 1) : 0; + if (next >= 0xdc00 && next < 0xe000) { + bytes += 4; + index += 1; + } else bytes += 3; // unpaired high surrogate → U+FFFD + } else bytes += 3; // BMP ≥ U+0800, incl. unpaired low surrogates → U+FFFD + } + return bytes; +} + +/** Maximum container ({}/[]) nesting depth of raw JSON text, string-aware + * (braces inside string literals are data). The scan is resilient to + * malformed text — syntax errors are JSON.parse's job, not this counter's. */ +export function scanJsonDepth(text: string): number { + let depth = 0; + let maxDepth = 0; + let inString = false; + let escaped = false; + for (let index = 0; index < text.length; index++) { + const ch = text[index]; + if (inString) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === '"') inString = false; + } else if (ch === '"') inString = true; + else if (ch === '{' || ch === '[') { + depth += 1; + if (depth > maxDepth) maxDepth = depth; + } else if (ch === '}' || ch === ']') depth -= 1; + } + return maxDepth; +} + +export interface JsonLimitOptions { + maxBytes?: number; + maxDepth?: number; +} + +export type ParseJsonResult = + | { ok: true; value: unknown } + | { ok: false; diagnostics: WorkspaceDiagnostic[] }; + +/** Parse untrusted JSON text with the #280 codec guards applied in order: + * UTF-8 byte size before parsing, container depth before parsing, then + * JSON.parse. */ +export function parseJsonWithLimits(text: unknown, { + maxBytes = PORTABLE_LIMITS.maxDecodedJsonBytes, + maxDepth = PORTABLE_LIMITS.maxJsonDepth, +}: JsonLimitOptions = {}): ParseJsonResult { + if (typeof text !== 'string') { + return { ok: false, diagnostics: [diagnostic([], 'json-syntax', 'Not a valid JSON file')] }; + } + const bytes = utf8ByteLength(text); + if (bytes > maxBytes) { + return { + ok: false, + diagnostics: [diagnostic([], 'limit-json-bytes', + `Document is ${bytes} UTF-8 bytes; the maximum is ${maxBytes}`)], + }; + } + const depth = scanJsonDepth(text); + if (depth > maxDepth) { + return { + ok: false, + diagnostics: [diagnostic([], 'limit-json-depth', + `Document nests ${depth} levels deep; the maximum is ${maxDepth}`)], + }; + } + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false, diagnostics: [diagnostic([], 'json-syntax', 'Not a valid JSON file')] }; + } +} diff --git a/src/dashboard/model/json-merge-patch.ts b/src/dashboard/model/json-merge-patch.ts new file mode 100644 index 00000000..33c31849 --- /dev/null +++ b/src/dashboard/model/json-merge-patch.ts @@ -0,0 +1,33 @@ +// RFC 7396 JSON Merge Patch (#280 "Presentation variants and tile overrides"). +// Net-new, in-house, pure — no dependency (hard rule 4). The exact RFC 7396 +// semantics that presentation variants and tile overrides rely on: +// - a non-object patch (array, primitive, or null) REPLACES the target +// wholesale — arrays are never index-merged or concatenated; +// - an object patch merges recursively into an object target (a non-object +// target is treated as an empty object first); +// - a `null` member DELETES that property from the result; +// - every other member is applied recursively. +// The result never shares structure with either input: `cloneJson` deep-copies +// every retained/replacement value so a resolved panel can be mutated freely. + +import { cloneJson } from '../../core/saved-query.js'; + +/** A merge-patch "object" is a non-null, non-array object; arrays and + * primitives are opaque replacement values under RFC 7396. */ +export function isMergePatchObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Apply one RFC 7396 JSON Merge Patch. Returns a fresh value; neither `target` + * nor `patch` is mutated, and the result shares no structure with either. */ +export function applyMergePatch(target: unknown, patch: unknown): unknown { + if (!isMergePatchObject(patch)) return cloneJson(patch); + const base = isMergePatchObject(target) ? target : {}; + const result: Record = {}; + for (const key of Object.keys(base)) result[key] = cloneJson(base[key]); + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete result[key]; + else result[key] = applyMergePatch(result[key], value); + } + return result; +} diff --git a/src/dashboard/model/portable-bundle-codec.ts b/src/dashboard/model/portable-bundle-codec.ts new file mode 100644 index 00000000..e93256a3 --- /dev/null +++ b/src/dashboard/model/portable-bundle-codec.ts @@ -0,0 +1,141 @@ +// Canonical PortableBundleV1 parsing, validation, decoding, and encoding +// (#280 "PortableBundleV1"). Validation order matches the #280 pipeline: +// codec resource guards (bytes/depth) → format/version identification (fail +// closed with one precise diagnostic) → structural schema validation → +// whole-bundle cross-resource semantics → sorted diagnostics. Encoding uses +// the one canonical encoder and re-checks the normalized serialized size. +// Pure: the compiled validation service is injected with a generated default. + +import { PORTABLE_LIMITS } from './portable-limits.js'; +import { parseJsonWithLimits, utf8ByteLength } from './json-limits.js'; +import type { JsonLimitOptions } from './json-limits.js'; +import { canonicalJson, PORTABLE_BUNDLE_SHAPE } from './canonical-json.js'; +import { diagnostic, sortDiagnostics } from './workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from './workspace-diagnostics.js'; +import { + unsupportedDashboardVersionDiagnostics, + unsupportedSpecVersionDiagnostics, + validateDashboardCollectionSemantics, + validateQueryCollectionSemantics, +} from './workspace-semantics.js'; +import { jsonSchemaValidationService } from '../../core/library-codec.js'; +import type { JsonSchemaValidationService } from '../../core/json-schema-validation.js'; +import type { PortableBundleV1 } from '../../generated/json-schema.types.js'; + +export const PORTABLE_BUNDLE_FORMAT = 'altinity-sql-browser/portable-bundle'; +export const CURRENT_PORTABLE_BUNDLE_VERSION = 1; +export const PORTABLE_BUNDLE_V1_SCHEMA_ID = + 'https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json'; + +export type BundleFailResult = { ok: false; diagnostics: WorkspaceDiagnostic[] }; + +export interface BundleCodecOptions { + validationService?: JsonSchemaValidationService; +} + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +function identifyPortableBundle(document: unknown): WorkspaceDiagnostic[] { + if (!isObject(document)) return [diagnostic([], 'bundle-invalid-root', 'Unrecognized file format')]; + if (document.format !== PORTABLE_BUNDLE_FORMAT) { + return [diagnostic(['format'], 'bundle-invalid-format', 'Unrecognized file format')]; + } + if (!Object.hasOwn(document, 'version')) { + return [diagnostic(['version'], 'bundle-version-missing', 'Missing portable bundle version')]; + } + if (!Number.isInteger(document.version)) { + return [diagnostic(['version'], 'bundle-version-invalid', 'Invalid portable bundle version')]; + } + if (document.version !== CURRENT_PORTABLE_BUNDLE_VERSION) { + return [diagnostic(['version'], 'bundle-version-unsupported', + `Unsupported portable bundle version ${document.version}`)]; + } + return []; +} + +/** Complete deterministic validation of one parsed portable bundle document. */ +export function validatePortableBundleDocument( + document: unknown, { validationService = jsonSchemaValidationService }: BundleCodecOptions = {}, +): WorkspaceDiagnostic[] { + const identity = identifyPortableBundle(document); + if (identity.length) return sortDiagnostics(identity); + const doc = document as Record; + const queries = Array.isArray(doc.queries) ? doc.queries : []; + const dashboards = Array.isArray(doc.dashboards) ? doc.dashboards : []; + const versionDiagnostics = [ + ...unsupportedSpecVersionDiagnostics(queries, ['queries']), + ...unsupportedDashboardVersionDiagnostics(dashboards, ['dashboards']), + ]; + const skip = new Set(versionDiagnostics.map((item) => JSON.stringify([item.path[0], item.path[1]]))); + const structural = validationService.validate(PORTABLE_BUNDLE_V1_SCHEMA_ID, document) + .filter((item) => !skip.has(JSON.stringify([item.path[0], item.path[1]]))); + if (versionDiagnostics.length || structural.length) { + return sortDiagnostics([...versionDiagnostics, ...structural]); + } + return sortDiagnostics([ + ...validateQueryCollectionSemantics(queries), + ...validateDashboardCollectionSemantics(dashboards, { queries, validationService }), + ]); +} + +export type DecodePortableBundleResult = { ok: true; value: PortableBundleV1 } | BundleFailResult; + +/** Parse and fully validate untrusted portable-bundle JSON text. */ +export function decodePortableBundleJson( + text: unknown, options: BundleCodecOptions & JsonLimitOptions = {}, +): DecodePortableBundleResult { + const parsed = parseJsonWithLimits(text, options); + if (!parsed.ok) return parsed; + const diagnostics = validatePortableBundleDocument(parsed.value, options); + if (diagnostics.length) return { ok: false, diagnostics }; + return { ok: true, value: parsed.value as PortableBundleV1 }; +} + +export interface EncodePortableBundleInput { + queries: unknown; + dashboards: unknown; + metadata?: unknown; + nowISO?: string; + includeSchemaHint?: boolean; +} + +export type EncodePortableBundleResult = { ok: true; value: string } | BundleFailResult; + +/** Build, validate, and canonically encode one portable bundle. Callers own + * resource arrangement (bundle-order.ts); arrays are encoded in the given + * semantic order. Export never mutates workspace identity or revision. */ +export function encodePortableBundleJson({ + queries, dashboards, metadata, nowISO, includeSchemaHint = true, +}: EncodePortableBundleInput, options: BundleCodecOptions = {}): EncodePortableBundleResult { + if (!Array.isArray(queries)) { + return { ok: false, diagnostics: [diagnostic(['queries'], 'schema-invalid-type', 'queries must be array')] }; + } + if (!Array.isArray(dashboards)) { + return { ok: false, diagnostics: [diagnostic(['dashboards'], 'schema-invalid-type', 'dashboards must be array')] }; + } + if (typeof nowISO !== 'string' || !nowISO) { + return { ok: false, diagnostics: [diagnostic(['exportedAt'], 'schema-required', 'exportedAt is required for new exports')] }; + } + const document: Record = { + ...(includeSchemaHint ? { $schema: PORTABLE_BUNDLE_V1_SCHEMA_ID } : {}), + format: PORTABLE_BUNDLE_FORMAT, + version: CURRENT_PORTABLE_BUNDLE_VERSION, + exportedAt: nowISO, + ...(metadata === undefined ? {} : { metadata }), + queries, + dashboards, + }; + const diagnostics = validatePortableBundleDocument(document, options); + if (diagnostics.length) return { ok: false, diagnostics }; + const encoded = canonicalJson(document, PORTABLE_BUNDLE_SHAPE); + const bytes = utf8ByteLength(encoded); + if (bytes > PORTABLE_LIMITS.maxDecodedJsonBytes) { + return { + ok: false, + diagnostics: [diagnostic([], 'limit-json-bytes', + `Encoded document is ${bytes} UTF-8 bytes; the maximum is ${PORTABLE_LIMITS.maxDecodedJsonBytes}`)], + }; + } + return { ok: true, value: encoded }; +} diff --git a/src/dashboard/model/portable-limits.ts b/src/dashboard/model/portable-limits.ts new file mode 100644 index 00000000..0f2e0206 --- /dev/null +++ b/src/dashboard/model/portable-limits.ts @@ -0,0 +1,33 @@ +// Concrete v1 resource limits for portable bundles, stored workspaces, and +// Dashboard documents — verbatim from issue #280 "Resource limits". The JSON +// Schemas enforce the item/property/string-length bounds that are expressible +// there; the codec layer enforces the byte/depth bounds before parsing; the +// semantic validator re-checks the security-relevant limits after parsing. +// +// Pinned decision (#283): the Phase-2 WorkspaceRepository is IndexedDB-backed, +// so `maxDecodedJsonBytes` stays at 10 MiB exactly as specced in #280 rather +// than shrinking to a localStorage-sized quota. + +export const PORTABLE_LIMITS = { + maxDecodedJsonBytes: 10 * 1024 * 1024, + maxJsonDepth: 64, + + maxQueries: 1000, + maxDashboards: 32, + maxTilesPerDashboard: 100, + maxFiltersPerDashboard: 32, + maxLayoutItemsPerDashboard: 100, + maxVariantsPerQuery: 32, + + maxIdLength: 256, + maxNameLength: 512, + maxTitleLength: 512, + maxDescriptionLength: 16 * 1024, + maxSqlLength: 1024 * 1024, + + maxSerializedQuerySpecBytes: 1024 * 1024, + maxSerializedLayoutConfigBytes: 256 * 1024, + maxSerializedFilterDefaultBytes: 64 * 1024, +} as const; + +export type PortableLimits = typeof PORTABLE_LIMITS; diff --git a/src/dashboard/model/presentation-resolver.ts b/src/dashboard/model/presentation-resolver.ts new file mode 100644 index 00000000..f5148c28 --- /dev/null +++ b/src/dashboard/model/presentation-resolver.ts @@ -0,0 +1,161 @@ +// The one canonical presentation resolver (#280 "Presentation variants and +// tile overrides"). Shared by Workbench authoring preview, the Dashboard +// viewer, import validation, tests, and future AI/MCP callers — every consumer +// resolves a tile's effective panel through THIS function so the RFC 7396 +// rules live in exactly one place. Pure: the Spec schema service is injected +// with the generated default. +// +// Resolution order is exact (#280): +// SavedQuery base panel +// -> apply selected named variant patch +// -> apply tile-local override patch +// -> validate final resolved panel (structural, plus result-column role +// validation WHEN result metadata is available). +// +// Normative rules enforced here: arrays replace atomically and `null` deletes +// (both from `applyMergePatch`); deleting a REQUIRED property fails final +// validation; neither a variant nor an override may change `panel.cfg.type`; +// a selected variant NAME must exist (no silent fallback); a MISSING selected +// variant uses `defaultVariant` when valid, else the base panel only. + +import { applyMergePatch } from './json-merge-patch.js'; +import { queryDashboardRole } from './workspace-semantics.js'; +import { diagnostic, sortDiagnostics } from './workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from './workspace-diagnostics.js'; +import { cloneJson } from '../../core/saved-query.js'; +import { querySpecSchemaService } from '../../core/spec-schema.js'; +import type { SpecSchemaService } from '../../core/spec-schema.js'; +import { panelCfgValid } from '../../core/panel-cfg.js'; +import type { Column } from '../../core/panel-cfg.js'; + +type Path = (string | number)[]; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const cfgType = (panel: unknown): string | undefined => { + if (!isObject(panel) || !isObject(panel.cfg)) return undefined; + return typeof panel.cfg.type === 'string' ? panel.cfg.type : undefined; +}; + +/** `resolvePresentation`'s input. `resultColumns` enables the semantic + * result-column role check; omit it (authoring without a live result) for a + * structural-only validation. `path` prefixes the diagnostics so a caller can + * place them inside a larger document (e.g. `['dashboard','tiles',3]`). */ +export interface ResolvePresentationInput { + query: unknown; + tile: unknown; + resultColumns?: Column[] | null; + schemaService?: SpecSchemaService; + path?: Path; +} + +export type ResolvePresentationResult = + | { ok: true; panel: Record } + | { ok: false; diagnostics: WorkspaceDiagnostic[] }; + +/** Resolve one tile's effective panel from its saved query, or return the + * sorted diagnostics that make the resolution invalid. */ +export function resolvePresentation(input: ResolvePresentationInput): ResolvePresentationResult { + const { query, tile, resultColumns, path = [] } = input; + const schemaService = input.schemaService ?? querySpecSchemaService; + const resource = isObject(tile) && typeof tile.id === 'string' ? tile.id : undefined; + const fail = (diagnostics: WorkspaceDiagnostic[]): ResolvePresentationResult => + ({ ok: false, diagnostics: sortDiagnostics(diagnostics) }); + + const spec = isObject(query) ? query.spec : undefined; + const basePanel = isObject(spec) && isObject(spec.panel) ? cloneJson(spec.panel) : {}; + const baseType = cfgType(basePanel); + const dashboard = isObject(spec) && isObject(spec.dashboard) ? spec.dashboard : undefined; + const variants = dashboard && isObject(dashboard.variants) ? dashboard.variants : undefined; + + const presentation = isObject(tile) && isObject(tile.presentation) ? tile.presentation : undefined; + const selectedVariant = presentation && typeof presentation.variant === 'string' ? presentation.variant : undefined; + + let variantPatch: unknown; + if (selectedVariant !== undefined) { + // A persisted variant name that no longer exists FAILS — no silent fallback. + if (!(variants && Object.hasOwn(variants, selectedVariant))) { + return fail([diagnostic([...path, 'presentation', 'variant'], 'presentation-variant-missing', + `Selected variant ${JSON.stringify(selectedVariant)} is not declared by the query`, resource)]); + } + variantPatch = variants[selectedVariant]; + } else { + // No variant selected: use defaultVariant when it names a real variant. + const defaultVariant = dashboard && typeof dashboard.defaultVariant === 'string' ? dashboard.defaultVariant : undefined; + if (defaultVariant !== undefined && variants && Object.hasOwn(variants, defaultVariant)) { + variantPatch = variants[defaultVariant]; + } + } + + let resolved: unknown = basePanel; + if (variantPatch !== undefined) resolved = applyMergePatch(resolved, variantPatch); + if (presentation && Object.hasOwn(presentation, 'override')) { + resolved = applyMergePatch(resolved, presentation.override); + } + + // Neither a variant nor an override may change the base renderer type. A + // patch that set a different `cfg.type` — or deleted it — fails here. + const resolvedType = cfgType(resolved); + if (baseType !== undefined && resolvedType !== baseType) { + return fail([diagnostic([...path, 'presentation', 'cfg', 'type'], 'presentation-renderer-type-change', + `Resolved panel changes the renderer type from ${JSON.stringify(baseType)} to ${JSON.stringify(resolvedType)}`, resource)]); + } + + // Final resolved-panel validation — the same structural check a normal + // saved-query panel gets. Only the `panel.*` diagnostics are relevant. + const structural = schemaService.validate({ panel: resolved }) + .filter((error) => error.path[0] === 'panel') + .map((error): WorkspaceDiagnostic => ({ + path: [...path, 'presentation', 'resolved', ...error.path], + severity: 'error', code: error.code, message: error.message, + ...(resource === undefined ? {} : { resource }), + })); + if (structural.length) return fail(structural); + + // Result-column role validation, only when result metadata is available. + if (resultColumns && isObject(resolved) && isObject(resolved.cfg) + && !panelCfgValid(resolved.cfg, resultColumns, schemaService)) { + return fail([diagnostic([...path, 'presentation', 'resolved', 'cfg'], 'presentation-resolved-invalid-roles', + 'Resolved panel roles are not valid for the available result columns', resource)]); + } + + return { ok: true, panel: resolved as Record }; +} + +/** `resolveDashboardPresentations`'s input. */ +export interface ResolveDashboardPresentationsInput { + dashboard: unknown; + queries: readonly unknown[]; + schemaService?: SpecSchemaService; + path?: Path; + resultColumnsByTileId?: Record; +} + +/** Resolve and validate every panel tile's presentation, returning the sorted + * diagnostics (empty when all resolve). Non-panel tiles and tiles whose query + * does not resolve are skipped — those are reported by structural/reference + * validation, not here. */ +export function resolveDashboardPresentations( + input: ResolveDashboardPresentationsInput, +): WorkspaceDiagnostic[] { + const { dashboard, queries, schemaService, path = [], resultColumnsByTileId } = input; + if (!isObject(dashboard)) return []; + const byId = new Map(); + for (const query of queries) { + if (isObject(query) && typeof query.id === 'string' && !byId.has(query.id)) byId.set(query.id, query); + } + const tiles = Array.isArray(dashboard.tiles) ? dashboard.tiles : []; + const out: WorkspaceDiagnostic[] = []; + tiles.forEach((tile, index) => { + if (!isObject(tile) || typeof tile.queryId !== 'string') return; + const query = byId.get(tile.queryId); + if (!isObject(query) || queryDashboardRole(query) !== 'panel') return; + const result = resolvePresentation({ + query, tile, schemaService, path: [...path, 'tiles', index], + resultColumns: typeof tile.id === 'string' ? resultColumnsByTileId?.[tile.id] : undefined, + }); + if (!result.ok) out.push(...result.diagnostics); + }); + return sortDiagnostics(out); +} diff --git a/src/dashboard/model/workspace-diagnostics.ts b/src/dashboard/model/workspace-diagnostics.ts new file mode 100644 index 00000000..b2e78453 --- /dev/null +++ b/src/dashboard/model/workspace-diagnostics.ts @@ -0,0 +1,76 @@ +// Workspace/Dashboard/bundle diagnostic contract and the one deterministic +// diagnostic sorter (#280 "Diagnostic ordering"): severity (error, warning, +// information), then numeric-aware path segment comparison, then diagnostic +// code, then resource ID, then message. Every Phase-1 call site returns +// diagnostics through `sortDiagnostics` so concurrent validation can never +// make the order nondeterministic. Pure. + +/** Diagnostic severity in canonical order of importance. */ +export type WorkspaceSeverity = 'error' | 'warning' | 'information'; + +/** One workspace/Dashboard/bundle diagnostic with a stable application code + * and an exact document path. `resource` carries the owning resource ID + * (query, Dashboard, tile, or filter ID) where one exists. The index + * signature admits the schema-layer diagnostics (`SchemaDiagnostic`, which + * adds `keyword`/`schemaId`) without a copy. */ +export interface WorkspaceDiagnostic { + path: (string | number)[]; + severity: WorkspaceSeverity; + code: string; + message: string; + resource?: string; + [key: string]: unknown; +} + +const SEVERITY_RANK: Record = { error: 0, warning: 1, information: 2 }; + +/** Build one error diagnostic (the common case in Phase 1). */ +export function diagnostic( + path: (string | number)[], code: string, message: string, resource?: string, +): WorkspaceDiagnostic { + return { path, severity: 'error', code, message, ...(resource === undefined ? {} : { resource }) }; +} + +// Numeric-aware segment comparison: numeric segments (array indexes — number +// typed, or all-digit strings from foreign tooling) compare numerically and +// sort before string segments; string segments compare by code unit so the +// order is locale-independent. +const numericValue = (segment: string | number): number | null => { + if (typeof segment === 'number') return segment; + return /^\d+$/.test(segment) ? Number(segment) : null; +}; + +export function comparePathSegments(a: string | number, b: string | number): number { + const an = numericValue(a); + const bn = numericValue(b); + if (an !== null && bn !== null) return an - bn; + if (an !== null) return -1; + if (bn !== null) return 1; + const as = String(a); + const bs = String(b); + return as < bs ? -1 : as > bs ? 1 : 0; +} + +export function comparePaths(a: readonly (string | number)[], b: readonly (string | number)[]): number { + const shared = Math.min(a.length, b.length); + for (let index = 0; index < shared; index++) { + const order = comparePathSegments(a[index], b[index]); + if (order !== 0) return order; + } + return a.length - b.length; +} + +const compareStrings = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +export function compareDiagnostics(a: WorkspaceDiagnostic, b: WorkspaceDiagnostic): number { + return (SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]) + || comparePaths(a.path, b.path) + || compareStrings(a.code, b.code) + || compareStrings(a.resource ?? '', b.resource ?? '') + || compareStrings(a.message, b.message); +} + +/** Return a new deterministically sorted copy; the input is never mutated. */ +export function sortDiagnostics(diagnostics: readonly T[]): T[] { + return [...diagnostics].sort(compareDiagnostics); +} diff --git a/src/dashboard/model/workspace-semantics.ts b/src/dashboard/model/workspace-semantics.ts new file mode 100644 index 00000000..a49a49af --- /dev/null +++ b/src/dashboard/model/workspace-semantics.ts @@ -0,0 +1,437 @@ +// Whole-workspace cross-resource semantic validation (#280 "Cross-resource +// semantic validation") plus the runtime re-checks of security-relevant +// resource limits that JSON Schema cannot express (or that must hold even +// when a caller bypasses schema validation). Operates on already-parsed +// documents; structural schema validation runs first in the codecs, so this +// layer narrows defensively instead of asserting shapes. Pure — the compiled +// schema validation service is injected with a generated default. + +import { PORTABLE_LIMITS } from './portable-limits.js'; +import { canonicalJson, QUERY_SPEC_SHAPE } from './canonical-json.js'; +import { utf8ByteLength } from './json-limits.js'; +import { diagnostic, sortDiagnostics } from './workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from './workspace-diagnostics.js'; +import { jsonSchemaValidationService, SPEC_CODECS } from '../../core/library-codec.js'; +import type { JsonSchemaValidationService } from '../../core/json-schema-validation.js'; +import { scanParamDeclarations } from '../../core/param-scan.js'; + +export const FLOW_LAYOUT_V1_SCHEMA_ID = + 'https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json'; + +/** The layout engines this build can render. flow@1 is the only v1 engine; + * anything else must carry a valid flow@1 fallback or fail before execution. */ +export const isSupportedLayout = (type: unknown, version: unknown): boolean => + type === 'flow' && version === 1; + +type Path = (string | number)[]; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); + +const stringId = (value: unknown): string | undefined => + (typeof value === 'string' ? value : undefined); + +/** The saved query's effective Dashboard role (`panel` when undeclared). */ +export function queryDashboardRole(query: unknown): string { + if (!isObject(query) || !isObject(query.spec)) return 'panel'; + const dashboard = query.spec.dashboard; + if (!isObject(dashboard)) return 'panel'; + return typeof dashboard.role === 'string' ? dashboard.role : 'panel'; +} + +const queryVariants = (query: unknown): Record | undefined => { + if (!isObject(query) || !isObject(query.spec) || !isObject(query.spec.dashboard)) return undefined; + const variants = query.spec.dashboard.variants; + return isObject(variants) ? variants : undefined; +}; + +const basePanelType = (spec: unknown): string | undefined => { + if (!isObject(spec) || !isObject(spec.panel) || !isObject(spec.panel.cfg)) return undefined; + return typeof spec.panel.cfg.type === 'string' ? spec.panel.cfg.type : undefined; +}; + +// Static half of the #280 renderer-type rule (a patch may not change +// panel.cfg.type): flag a patch whose cfg.type is a string different from +// the declared base type. Full RFC 7396 resolution lands in Phase 3. +const patchRendererType = (patch: unknown): string | undefined => { + if (!isObject(patch) || !isObject(patch.cfg)) return undefined; + return typeof patch.cfg.type === 'string' ? patch.cfg.type : undefined; +}; + +const normalizeParamType = (type: string): string => type.replace(/\s+/g, ' ').trim(); + +// --- fail-closed version pre-scans ------------------------------------------ +// Unknown future resource versions fail closed with ONE precise diagnostic. +// The codecs run these before structural schema validation and suppress the +// schema branch noise for the same resource index, exactly like +// library-codec's unsupported-specVersion handling. + +/** Queries whose integer `specVersion` names no supported Spec codec. */ +export function unsupportedSpecVersionDiagnostics( + queries: readonly unknown[], path: Path = ['queries'], +): WorkspaceDiagnostic[] { + const out: WorkspaceDiagnostic[] = []; + for (const [index, query] of queries.entries()) { + if (!isObject(query) || !Number.isInteger(query.specVersion) || SPEC_CODECS.has(query.specVersion as number)) continue; + out.push(diagnostic([...path, index, 'specVersion'], 'spec-version-unsupported', + `queries[${index}] uses unsupported saved-query Spec version ${query.specVersion}`, stringId(query.id))); + } + return out; +} + +/** Dashboards whose integer `documentVersion` is not the supported version 1. */ +export function unsupportedDashboardVersionDiagnostics( + dashboards: readonly unknown[], path: Path = ['dashboards'], +): WorkspaceDiagnostic[] { + const out: WorkspaceDiagnostic[] = []; + for (const [index, dashboard] of dashboards.entries()) { + if (!isObject(dashboard) || !Number.isInteger(dashboard.documentVersion) || dashboard.documentVersion === 1) continue; + out.push(diagnostic([...path, index, 'documentVersion'], 'dashboard-version-unsupported', + `Unsupported Dashboard document version ${JSON.stringify(dashboard.documentVersion)}`, stringId(dashboard.id))); + } + return out; +} + +export interface QueryCollectionOptions { + path?: Path; +} + +/** Query-collection rules shared by bundles and stored workspaces: unique + * query IDs, runtime limit re-checks (ID/SQL/name lengths, serialized Spec + * bytes, variant count), defaultVariant existence, and the static + * renderer-type check for every declared variant patch. */ +export function validateQueryCollectionSemantics( + queries: readonly unknown[], { path = ['queries'] }: QueryCollectionOptions = {}, +): WorkspaceDiagnostic[] { + const out: WorkspaceDiagnostic[] = []; + if (queries.length > PORTABLE_LIMITS.maxQueries) { + out.push(diagnostic(path, 'limit-query-count', + `queries contains ${queries.length} items; the maximum is ${PORTABLE_LIMITS.maxQueries}`)); + } + const firstIndexById = new Map(); + for (const [index, query] of queries.entries()) { + if (!isObject(query)) continue; // structurally invalid — the schema layer reports it + const id = stringId(query.id); + if (id !== undefined) { + if (firstIndexById.has(id)) { + out.push(diagnostic([...path, index, 'id'], 'workspace-duplicate-query-id', + `Saved-query id ${JSON.stringify(id)} duplicates queries[${firstIndexById.get(id)}].id`, id)); + } else firstIndexById.set(id, index); + if (id.length > PORTABLE_LIMITS.maxIdLength) { + out.push(diagnostic([...path, index, 'id'], 'limit-id-length', + `Saved-query id is ${id.length} characters; the maximum is ${PORTABLE_LIMITS.maxIdLength}`, id)); + } + } + if (typeof query.sql === 'string' && query.sql.length > PORTABLE_LIMITS.maxSqlLength) { + out.push(diagnostic([...path, index, 'sql'], 'limit-sql-length', + `SQL is ${query.sql.length} characters; the maximum is ${PORTABLE_LIMITS.maxSqlLength}`, id)); + } + const spec = query.spec; + if (!isObject(spec)) continue; + if (typeof spec.name === 'string' && spec.name.length > PORTABLE_LIMITS.maxNameLength) { + out.push(diagnostic([...path, index, 'spec', 'name'], 'limit-name-length', + `Query name is ${spec.name.length} characters; the maximum is ${PORTABLE_LIMITS.maxNameLength}`, id)); + } + if (typeof spec.description === 'string' && spec.description.length > PORTABLE_LIMITS.maxDescriptionLength) { + out.push(diagnostic([...path, index, 'spec', 'description'], 'limit-description-length', + `Query description is ${spec.description.length} characters; the maximum is ${PORTABLE_LIMITS.maxDescriptionLength}`, id)); + } + const specBytes = utf8ByteLength(canonicalJson(spec, QUERY_SPEC_SHAPE)); + if (specBytes > PORTABLE_LIMITS.maxSerializedQuerySpecBytes) { + out.push(diagnostic([...path, index, 'spec'], 'limit-spec-bytes', + `Serialized Spec is ${specBytes} UTF-8 bytes; the maximum is ${PORTABLE_LIMITS.maxSerializedQuerySpecBytes}`, id)); + } + const presentation = isObject(spec.dashboard) ? spec.dashboard : undefined; + if (!presentation) continue; + const variants = isObject(presentation.variants) ? presentation.variants : undefined; + const variantNames = variants ? Object.keys(variants) : []; + if (variantNames.length > PORTABLE_LIMITS.maxVariantsPerQuery) { + out.push(diagnostic([...path, index, 'spec', 'dashboard', 'variants'], 'limit-variant-count', + `variants declares ${variantNames.length} entries; the maximum is ${PORTABLE_LIMITS.maxVariantsPerQuery}`, id)); + } + if (typeof presentation.defaultVariant === 'string' + && !(variants && Object.hasOwn(variants, presentation.defaultVariant))) { + out.push(diagnostic([...path, index, 'spec', 'dashboard', 'defaultVariant'], 'query-default-variant-missing', + `defaultVariant ${JSON.stringify(presentation.defaultVariant)} names no declared variant`, id)); + } + const baseType = basePanelType(spec); + for (const name of variantNames) { + const patchType = patchRendererType(variants![name]); + if (patchType !== undefined && baseType !== undefined && patchType !== baseType) { + out.push(diagnostic( + [...path, index, 'spec', 'dashboard', 'variants', name, 'cfg', 'type'], + 'presentation-renderer-type-change', + `Variant ${JSON.stringify(name)} changes the renderer type from ${JSON.stringify(baseType)} to ${JSON.stringify(patchType)}`, + id, + )); + } + } + } + return sortDiagnostics(out); +} + +export interface DashboardSemanticsOptions { + queries?: readonly unknown[]; + path?: Path; + validationService?: JsonSchemaValidationService; +} + +interface TileEntry { + index: number; + queryId: string | undefined; +} + +/** Every #280 per-Dashboard cross-resource rule: unique tile/filter IDs, + * tile query resolution and role compatibility, variant existence, the + * static renderer-type override check, layout support/fallback/orphan/count + * rules (flow@1 re-validated through the compiled schema validator), filter + * source/target resolution with role and parameter checks, Setup execution + * rejection, and the per-Dashboard runtime limit re-checks. */ +export function validateDashboardSemantics(dashboard: unknown, { + queries = [], + path = [], + validationService = jsonSchemaValidationService, +}: DashboardSemanticsOptions = {}): WorkspaceDiagnostic[] { + if (!isObject(dashboard)) return []; + const dashboardId = stringId(dashboard.id); + if (dashboard.documentVersion !== 1) { + // Unknown future versions fail closed before any other rule runs. + return [diagnostic([...path, 'documentVersion'], 'dashboard-version-unsupported', + `Unsupported Dashboard document version ${JSON.stringify(dashboard.documentVersion)}`, dashboardId)]; + } + const out: WorkspaceDiagnostic[] = []; + const emit = (at: Path, code: string, message: string): void => { + out.push(diagnostic(at, code, message, dashboardId)); + }; + + const queriesById = new Map(); + for (const query of queries) { + if (!isObject(query)) continue; + const id = stringId(query.id); + if (id !== undefined && !queriesById.has(id)) queriesById.set(id, query); + } + const declarationsFor = (query: unknown): { name: string; type: string }[] => + (isObject(query) && typeof query.sql === 'string' ? scanParamDeclarations(query.sql) : []); + + // --- tiles --------------------------------------------------------------- + const tiles = asArray(dashboard.tiles); + if (tiles.length > PORTABLE_LIMITS.maxTilesPerDashboard) { + emit([...path, 'tiles'], 'limit-tile-count', + `tiles contains ${tiles.length} items; the maximum is ${PORTABLE_LIMITS.maxTilesPerDashboard}`); + } + const tilesById = new Map(); + const tileQueryIds = new Set(); + for (const [index, tile] of tiles.entries()) { + if (!isObject(tile)) continue; + const tileId = stringId(tile.id); + const queryId = stringId(tile.queryId); + if (queryId !== undefined) tileQueryIds.add(queryId); + if (tileId !== undefined) { + if (tilesById.has(tileId)) { + emit([...path, 'tiles', index, 'id'], 'dashboard-duplicate-tile-id', + `Tile id ${JSON.stringify(tileId)} duplicates tiles[${tilesById.get(tileId)!.index}].id`); + } else tilesById.set(tileId, { index, queryId }); + } + const query = queryId === undefined ? undefined : queriesById.get(queryId); + if (queryId !== undefined && query === undefined) { + emit([...path, 'tiles', index, 'queryId'], 'dashboard-tile-query-missing', + `Tile references unknown saved query ${JSON.stringify(queryId)}`); + } + if (query !== undefined) { + const role = queryDashboardRole(query); + if (role === 'setup') { + emit([...path, 'tiles', index, 'queryId'], 'dashboard-setup-reference', + `Tile references Setup-role query ${JSON.stringify(queryId)}; Dashboard v1 never executes Setup queries`); + } else if (role !== 'panel') { + emit([...path, 'tiles', index, 'queryId'], 'dashboard-tile-role-incompatible', + `Tile references ${JSON.stringify(role)}-role query ${JSON.stringify(queryId)}; tiles require role panel`); + } + } + const presentation = isObject(tile.presentation) ? tile.presentation : undefined; + if (!presentation) continue; + if (typeof presentation.variant === 'string' && query !== undefined) { + const variants = queryVariants(query); + if (!(variants && Object.hasOwn(variants, presentation.variant))) { + emit([...path, 'tiles', index, 'presentation', 'variant'], 'dashboard-variant-missing', + `Selected variant ${JSON.stringify(presentation.variant)} is not declared by query ${JSON.stringify(queryId)}`); + } + } + const overrideType = patchRendererType(presentation.override); + if (overrideType !== undefined && query !== undefined) { + const baseType = basePanelType(isObject(query) ? query.spec : undefined); + if (baseType !== undefined && overrideType !== baseType) { + emit([...path, 'tiles', index, 'presentation', 'override', 'cfg', 'type'], + 'presentation-renderer-type-change', + `Tile override changes the renderer type from ${JSON.stringify(baseType)} to ${JSON.stringify(overrideType)}`); + } + } + } + + // --- layout -------------------------------------------------------------- + const layout = isObject(dashboard.layout) ? dashboard.layout : undefined; + if (layout) { + const layoutPath: Path = [...path, 'layout']; + const checkItems = (items: unknown, itemsPath: Path): void => { + if (!isObject(items)) return; + const keys = Object.keys(items); + if (keys.length > PORTABLE_LIMITS.maxLayoutItemsPerDashboard) { + emit(itemsPath, 'limit-layout-item-count', + `Layout declares ${keys.length} placements; the maximum is ${PORTABLE_LIMITS.maxLayoutItemsPerDashboard}`); + } + if (keys.length > tiles.length) { + emit(itemsPath, 'layout-items-exceed-tiles', + `Layout declares ${keys.length} placements for ${tiles.length} tiles`); + } + for (const key of keys) { + if (!tilesById.has(key)) { + emit([...itemsPath, key], 'layout-orphan-placement', + `Placement ${JSON.stringify(key)} references no tile`); + } + } + }; + checkItems(layout.items, [...layoutPath, 'items']); + if (isSupportedLayout(layout.type, layout.version)) { + for (const schemaError of validationService.validate(FLOW_LAYOUT_V1_SCHEMA_ID, layout)) { + out.push({ + ...schemaError, + path: [...layoutPath, ...schemaError.path], + ...(dashboardId === undefined ? {} : { resource: dashboardId }), + }); + } + } else { + const fallback = layout.fallback; + if (fallback === undefined || fallback === null) { + emit(layoutPath, 'layout-unsupported-without-fallback', + `Layout ${JSON.stringify(layout.type)}@${JSON.stringify(layout.version)} is unsupported and has no flow@1 fallback`); + } else { + for (const schemaError of validationService.validate(FLOW_LAYOUT_V1_SCHEMA_ID, fallback)) { + out.push({ + ...schemaError, + path: [...layoutPath, 'fallback', ...schemaError.path], + ...(dashboardId === undefined ? {} : { resource: dashboardId }), + }); + } + if (isObject(fallback)) checkItems(fallback.items, [...layoutPath, 'fallback', 'items']); + } + } + if (layout.config !== undefined) { + const configBytes = utf8ByteLength(canonicalJson(layout.config)); + if (configBytes > PORTABLE_LIMITS.maxSerializedLayoutConfigBytes) { + emit([...layoutPath, 'config'], 'limit-layout-config-bytes', + `Serialized layout config is ${configBytes} UTF-8 bytes; the maximum is ${PORTABLE_LIMITS.maxSerializedLayoutConfigBytes}`); + } + } + } + + // --- filters --------------------------------------------------------------- + const filters = asArray(dashboard.filters); + if (filters.length > PORTABLE_LIMITS.maxFiltersPerDashboard) { + emit([...path, 'filters'], 'limit-filter-count', + `filters contains ${filters.length} items; the maximum is ${PORTABLE_LIMITS.maxFiltersPerDashboard}`); + } + const filterFirstIndexById = new Map(); + for (const [index, filter] of filters.entries()) { + if (!isObject(filter)) continue; + const filterPath: Path = [...path, 'filters', index]; + const filterId = stringId(filter.id); + if (filterId !== undefined) { + if (filterFirstIndexById.has(filterId)) { + emit([...filterPath, 'id'], 'dashboard-duplicate-filter-id', + `Filter id ${JSON.stringify(filterId)} duplicates filters[${filterFirstIndexById.get(filterId)}].id`); + } else filterFirstIndexById.set(filterId, index); + } + const sourceQueryId = stringId(filter.sourceQueryId); + if (sourceQueryId !== undefined) { + const source = queriesById.get(sourceQueryId); + if (source === undefined) { + emit([...filterPath, 'sourceQueryId'], 'filter-source-missing', + `Filter references unknown source query ${JSON.stringify(sourceQueryId)}`); + } else { + const role = queryDashboardRole(source); + if (role === 'setup') { + emit([...filterPath, 'sourceQueryId'], 'dashboard-setup-reference', + `Filter references Setup-role query ${JSON.stringify(sourceQueryId)}; Dashboard v1 never executes Setup queries`); + } else if (role !== 'filter') { + emit([...filterPath, 'sourceQueryId'], 'filter-source-role', + `Filter source query ${JSON.stringify(sourceQueryId)} has role ${JSON.stringify(role)}; sources require role filter`); + } + if (tileQueryIds.has(sourceQueryId)) { + emit([...filterPath, 'sourceQueryId'], 'filter-source-is-tile', + `Filter source query ${JSON.stringify(sourceQueryId)} also creates a tile; filter sources never create tiles`); + } + } + } + const parameter = typeof filter.parameter === 'string' ? filter.parameter : undefined; + if (Array.isArray(filter.targets)) { + // Absent targets resolve to every compatible panel tile; explicit + // targets must each exist and declare the parameter compatibly. + const declaredTypes = new Map(); + for (const [targetIndex, target] of filter.targets.entries()) { + const targetId = stringId(target); + const tileEntry = targetId === undefined ? undefined : tilesById.get(targetId); + if (tileEntry === undefined) { + emit([...filterPath, 'targets', targetIndex], 'filter-target-missing', + `Filter target ${JSON.stringify(target)} references no tile`); + continue; + } + if (parameter === undefined || tileEntry.queryId === undefined) continue; + const targetQuery = queriesById.get(tileEntry.queryId); + if (targetQuery === undefined) continue; // already reported at the tile + const declared = declarationsFor(targetQuery).find((entry) => entry.name === parameter); + if (!declared) { + emit([...filterPath, 'targets', targetIndex], 'filter-parameter-undeclared', + `Target tile ${JSON.stringify(targetId)}'s query does not declare parameter ${JSON.stringify(parameter)}`); + } else declaredTypes.set(tileEntry.queryId, normalizeParamType(declared.type)); + } + if (new Set(declaredTypes.values()).size > 1) { + emit([...filterPath, 'parameter'], 'filter-parameter-type-conflict', + `Parameter ${JSON.stringify(parameter)} is declared with conflicting types across filter targets: ${[...new Set(declaredTypes.values())].sort().join(', ')}`); + } + } + if (Object.hasOwn(filter, 'defaultValue')) { + const defaultBytes = utf8ByteLength(canonicalJson(filter.defaultValue)); + if (defaultBytes > PORTABLE_LIMITS.maxSerializedFilterDefaultBytes) { + emit([...filterPath, 'defaultValue'], 'limit-filter-default-bytes', + `Serialized default value is ${defaultBytes} UTF-8 bytes; the maximum is ${PORTABLE_LIMITS.maxSerializedFilterDefaultBytes}`); + } + } + } + + return sortDiagnostics(out); +} + +export interface DashboardCollectionOptions { + queries?: readonly unknown[]; + path?: Path; + validationService?: JsonSchemaValidationService; +} + +/** Bundle-side Dashboard collection rules: unique Dashboard IDs, the + * Dashboard-count limit re-check, and every per-Dashboard rule. */ +export function validateDashboardCollectionSemantics(dashboards: readonly unknown[], { + queries = [], + path = ['dashboards'], + validationService = jsonSchemaValidationService, +}: DashboardCollectionOptions = {}): WorkspaceDiagnostic[] { + const out: WorkspaceDiagnostic[] = []; + if (dashboards.length > PORTABLE_LIMITS.maxDashboards) { + out.push(diagnostic(path, 'limit-dashboard-count', + `dashboards contains ${dashboards.length} items; the maximum is ${PORTABLE_LIMITS.maxDashboards}`)); + } + const firstIndexById = new Map(); + for (const [index, dashboard] of dashboards.entries()) { + if (isObject(dashboard)) { + const id = stringId(dashboard.id); + if (id !== undefined) { + if (firstIndexById.has(id)) { + out.push(diagnostic([...path, index, 'id'], 'workspace-duplicate-dashboard-id', + `Dashboard id ${JSON.stringify(id)} duplicates dashboards[${firstIndexById.get(id)}].id`, id)); + } else firstIndexById.set(id, index); + } + } + out.push(...validateDashboardSemantics(dashboard, { queries, path: [...path, index], validationService })); + } + return sortDiagnostics(out); +} diff --git a/src/env.types.ts b/src/env.types.ts index 24b54eb6..b51fc3c3 100644 --- a/src/env.types.ts +++ b/src/env.types.ts @@ -19,6 +19,13 @@ export interface CreateAppEnv { fetch?: typeof fetch; crypto?: Crypto; sessionStorage?: Storage; + /** IndexedDB factory seam (#280 Phase 2 / #284) — injected like crypto/ + * sessionStorage so the atomic `WorkspaceRepository` (app.workspace) has a + * real backing store in the browser and an in-memory fake in tests. + * Resolves to `win.indexedDB` when omitted; may be absent entirely on a + * platform without IndexedDB, in which case workspace operations reject + * (caught by the repository) rather than throwing at construction. */ + indexedDB?: IDBFactory; root?: Element | null; Chart?: unknown; // Chart.js constructor — new app.Chart(canvas, cfg) cssVar?: (name: string) => string; diff --git a/src/generated/json-schema-validators.js b/src/generated/json-schema-validators.js index f18ca288..c2150bca 100644 --- a/src/generated/json-schema-validators.js +++ b/src/generated/json-schema-validators.js @@ -285,8 +285,7 @@ var require_formats = __commonJS({ // json-schema-standalone.js var validateQuerySpecV1 = validate20; -var schema31 = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://altinity.com/schemas/altinity-sql-browser/query-spec-v1.schema.json", "title": "Altinity SQL Browser saved-query Spec v1", "description": "The user-authored query.spec document. Saved-query envelope fields are intentionally outside this schema.", "x-altinity-kind": "query-spec", "x-altinity-version": 1, "type": "object", "properties": { "name": { "title": "Name", "description": "Panel, tile, and Library title.", "type": "string", "minLength": 1, "pattern": "\\S", "examples": ["Revenue by country"] }, "description": { "title": "Description", "description": "Optional authoring note shown with the saved query.", "type": "string" }, "favorite": { "title": "Favorite", "description": "Whether the query is included in favorite-driven surfaces.", "type": "boolean", "default": false }, "view": { "title": "Preferred result view", "description": "The result representation restored when the saved query opens.", "type": "string", "enum": ["table", "json", "panel"], "default": "table" }, "panel": { "$ref": "#/$defs/panel" }, "dashboard": { "$ref": "#/$defs/dashboard" } }, "additionalProperties": true, "x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard"], "$defs": { "columnName": { "title": "Result column", "description": "Exact top-level ClickHouse result-column name.", "type": "string", "minLength": 1, "x-altinity-completion": { "source": "resultColumns" } }, "resultColumnIndex": { "title": "Result column index", "description": "Zero-based index of a ClickHouse result column.", "type": "integer", "minimum": 0, "x-altinity-completion": { "source": "resultColumnIndexes" } }, "deltaPresentation": { "title": "Delta presentation", "description": "Display metadata for a runtime KPI delta value.", "type": "object", "properties": { "displayName": { "title": "Delta label", "description": "Optional visible label for the delta.", "type": "string" }, "unit": { "title": "Delta unit", "description": "Display-only suffix appended to the delta.", "type": "string" }, "decimals": { "title": "Delta decimal places", "description": "Requested display rounding for the delta.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [1] }, "positiveIsGood": { "title": "Positive is good", "description": "Whether a positive runtime delta has good semantics.", "type": "boolean" }, "show": { "title": "Show delta", "description": "Whether a present runtime delta is rendered.", "type": "boolean", "default": true } }, "additionalProperties": true, "x-altinity-order": ["displayName", "unit", "decimals", "positiveIsGood", "show"] }, "fieldConfigValue": { "title": "Field presentation metadata", "description": "Known presentation metadata for one result column. Unknown renderer extensions are retained.", "type": "object", "properties": { "displayName": { "title": "Display name", "description": "Rendered label for the field.", "type": "string" }, "decimals": { "title": "Decimal places", "description": "Requested number of decimal places for numeric display.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [2] }, "description": { "title": "Description", "description": "Supporting display text for the field.", "type": "string" }, "unit": { "title": "Unit", "description": "Display-only suffix appended to the value.", "type": "string", "examples": ["%"] }, "color": { "title": "Color", "description": "Theme token or CSS color hint interpreted by the renderer.", "type": "string" }, "noValue": { "title": "No-value text", "description": "Text shown for NULL or unavailable values.", "type": "string", "default": "\u2014" }, "hidden": { "title": "Hidden", "description": "Suppress this otherwise eligible result field.", "type": "boolean", "default": false }, "delta": { "$ref": "#/$defs/deltaPresentation" } }, "additionalProperties": true, "x-altinity-order": ["displayName", "description", "unit", "decimals", "color", "noValue", "hidden", "delta"] }, "fieldConfig": { "title": "Panel field configuration", "description": "Default and per-column display metadata.", "type": "object", "properties": { "defaults": { "$ref": "#/$defs/fieldConfigValue" }, "columns": { "title": "Column overrides", "description": "Display metadata keyed by exact result-column name.", "type": "object", "additionalProperties": { "$ref": "#/$defs/fieldConfigValue" }, "x-altinity-key-completion": { "source": "resultColumns" } } }, "additionalProperties": true, "x-altinity-order": ["defaults", "columns"] }, "dashboard": { "title": "Dashboard configuration", "description": "Dashboard participation metadata. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { "title": "Dashboard role (Panel, Filter, or Setup)", "description": "How the saved query participates in a dashboard. panel (the default) creates a visualization tile. filter returns exactly one row whose supported top-level Array, named Tuple Array, or Map columns provide option lists for parameters with the same exact names; it creates no tile and its SQL cannot declare parameters. setup is reserved for serialized Dashboard setup execution.", "type": "string", "enum": ["panel", "filter", "setup"], "default": "panel", "examples": ["filter"] } }, "additionalProperties": true, "x-altinity-order": ["role"] }, "panel": { "title": "Panel configuration", "description": "Visualization and field metadata for the saved query.", "type": "object", "properties": { "cfg": { "$ref": "#/$defs/panelCfg" }, "key": { "title": "Result schema key", "description": "Saved result-column signature used to detect stale positional roles.", "type": ["string", "null"] }, "fieldConfig": { "$ref": "#/$defs/fieldConfig" } }, "additionalProperties": true, "x-altinity-order": ["cfg", "key", "fieldConfig"] }, "lineChartStyle": { "title": "Line style", "description": "Renderer-independent line presentation. Unknown fields and future string values remain storable.", "type": "object", "properties": { "curve": { "title": "Line curve", "description": "linear draws straight segments, smooth uses monotone interpolation, and stepped draws step-after segments.", "anyOf": [{ "type": "string", "enum": ["linear", "smooth", "stepped"] }, { "type": "string" }], "default": "linear" }, "points": { "title": "Point markers", "description": "auto shows markers only for sparse results, show always displays them, and hide retains hover targets without visible markers.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "scale": { "title": "Value scale", "description": "zero anchors the value axis at zero, data uses the data range, and auto uses the chart-type default.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "data" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["curve", "points", "scale", "legend", "grid", "axes"] }, "areaChartStyle": { "title": "Area style", "description": "Curve, marker, and additive stacking presentation for Area charts.", "allOf": [{ "$ref": "#/$defs/lineChartStyle" }, { "type": "object", "properties": { "stack": { "title": "Area stacking", "description": "overlay draws series independently; stacked uses one shared additive stack without normalization.", "anyOf": [{ "type": "string", "enum": ["overlay", "stacked"] }, { "type": "string" }], "default": "overlay" } }, "additionalProperties": true }], "x-altinity-order": ["curve", "points", "stack", "scale", "legend", "grid", "axes"] }, "barChartStyle": { "title": "Bar and Column style", "description": "Grouping and category-spacing presentation shared by horizontal Bar and vertical Column charts.", "type": "object", "properties": { "mode": { "title": "Bar grouping", "description": "grouped draws measures side by side; stacked adds them on one shared value stack.", "anyOf": [{ "type": "string", "enum": ["grouped", "stacked"] }, { "type": "string" }], "default": "grouped" }, "density": { "title": "Category spacing", "description": "normal uses standard spacing, compact reduces gaps, and joined removes category gaps.", "anyOf": [{ "type": "string", "enum": ["normal", "compact", "joined"] }, { "type": "string" }], "default": "normal" }, "scale": { "title": "Value scale", "description": "zero and auto anchor Bar/Column at zero; data uses the data range.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "zero" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["mode", "density", "scale", "legend", "grid", "axes"] }, "pieChartStyle": { "title": "Pie style", "description": "Pie or Donut shape presentation.", "type": "object", "properties": { "shape": { "title": "Pie shape", "description": "pie fills the center; donut uses a fixed 60% cutout.", "anyOf": [{ "type": "string", "enum": ["pie", "donut"] }, { "type": "string" }], "default": "pie" }, "legend": { "title": "Legend visibility", "description": "show renders the slice legend; hide relies on tooltips.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "show" }, "frame": { "title": "Chart frame", "description": "compact reduces Pie layout padding; normal retains the standard frame.", "anyOf": [{ "type": "string", "enum": ["normal", "compact"] }, { "type": "string" }], "default": "normal" } }, "additionalProperties": true, "x-altinity-order": ["shape", "legend", "frame"] }, "chartCfg": { "type": "object", "properties": { "x": { "$ref": "#/$defs/resultColumnIndex", "default": 0 }, "y": { "title": "Measure columns", "description": "One or more zero-based result-column indexes used as measures.", "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/resultColumnIndex" } }, "series": { "title": "Series column", "description": "Optional zero-based result-column index used to split series.", "oneOf": [{ "$ref": "#/$defs/resultColumnIndex" }, { "type": "null" }], "default": null, "x-altinity-completion": { "source": "resultColumnIndexes" } } }, "required": ["x", "y"], "additionalProperties": true, "x-altinity-order": ["type", "x", "y", "series"] }, "styledChartCfg": { "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "type": "object", "properties": { "style": { "type": "object" } } }], "x-altinity-order": ["type", "style", "x", "y", "series"] }, "panelCfg": { "title": "Panel type configuration", "description": "Discriminated visualization configuration. Unknown types remain storable for forward compatibility.", "type": "object", "required": ["type"], "properties": { "type": { "title": "Panel type", "description": "Visualization renderer identifier.", "type": "string", "minLength": 1 } }, "additionalProperties": true, "x-altinity-discriminator": "type", "x-altinity-order": ["type"], "oneOf": [{ "title": "Column chart", "description": "Vertical columns using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "bar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "bar" } }, "required": ["type"] }] }, { "title": "Horizontal bar chart", "description": "Horizontal bars using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "hbar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "hbar" } }, "required": ["type"] }] }, { "title": "Line chart", "description": "Line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "line", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/lineChartStyle" } } }, { "properties": { "type": { "const": "line" } }, "required": ["type"] }] }, { "title": "Area chart", "description": "Filled line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "area", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/areaChartStyle" } } }, { "properties": { "type": { "const": "area" } }, "required": ["type"] }] }, { "title": "Pie chart", "description": "Pie slices using one positional measure role.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "pie", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/pieChartStyle" } } }, { "properties": { "type": { "const": "pie" }, "y": { "type": "array", "maxItems": 1 } }, "required": ["type"] }] }, { "title": "KPI", "description": "One-row scalar and named-tuple KPI cards.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "kpi" }, "properties": { "type": { "const": "kpi" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type"] }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "table" }, "properties": { "type": { "const": "table" } }, "required": ["type"], "additionalProperties": true }, { "title": "Logs", "description": "Timestamped log messages with optional explicit result-column roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "logs", "time": "event_time", "msg": "message", "level": "level" }, "properties": { "type": { "const": "logs" }, "time": { "$ref": "#/$defs/columnName" }, "msg": { "$ref": "#/$defs/columnName" }, "level": { "$ref": "#/$defs/columnName" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "time", "msg", "level"] }, { "title": "Markdown text", "description": "Safe Markdown content that does not require a SQL result.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "text", "content": "# Heading\n\nMarkdown content." }, "properties": { "type": { "const": "text" }, "content": { "title": "Markdown content", "description": "Source text for the safe Markdown renderer.", "type": "string", "default": "" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "content"] }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", "x-altinity-status": "planned", "x-altinity-snippet": { "type": "future-panel" }, "properties": { "type": { "type": "string", "minLength": 1, "not": { "enum": ["bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text"] } } }, "required": ["type"], "additionalProperties": true }] } } }; -var schema51 = { "title": "Dashboard configuration", "description": "Dashboard participation metadata. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { "title": "Dashboard role (Panel, Filter, or Setup)", "description": "How the saved query participates in a dashboard. panel (the default) creates a visualization tile. filter returns exactly one row whose supported top-level Array, named Tuple Array, or Map columns provide option lists for parameters with the same exact names; it creates no tile and its SQL cannot declare parameters. setup is reserved for serialized Dashboard setup execution.", "type": "string", "enum": ["panel", "filter", "setup"], "default": "panel", "examples": ["filter"] } }, "additionalProperties": true, "x-altinity-order": ["role"] }; +var schema31 = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://altinity.com/schemas/altinity-sql-browser/query-spec-v1.schema.json", "title": "Altinity SQL Browser saved-query Spec v1", "description": "The user-authored query.spec document. Saved-query envelope fields are intentionally outside this schema.", "x-altinity-kind": "query-spec", "x-altinity-version": 1, "type": "object", "properties": { "name": { "title": "Name", "description": "Panel, tile, and Library title.", "type": "string", "minLength": 1, "pattern": "\\S", "examples": ["Revenue by country"] }, "description": { "title": "Description", "description": "Optional authoring note shown with the saved query.", "type": "string" }, "favorite": { "title": "Favorite", "description": "Whether the query is included in favorite-driven surfaces.", "type": "boolean", "default": false }, "view": { "title": "Preferred result view", "description": "The result representation restored when the saved query opens.", "type": "string", "enum": ["table", "json", "panel"], "default": "table" }, "panel": { "$ref": "#/$defs/panel" }, "dashboard": { "$ref": "#/$defs/queryDashboardPresentationV1" } }, "additionalProperties": true, "x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard"], "$defs": { "columnName": { "title": "Result column", "description": "Exact top-level ClickHouse result-column name.", "type": "string", "minLength": 1, "x-altinity-completion": { "source": "resultColumns" } }, "resultColumnIndex": { "title": "Result column index", "description": "Zero-based index of a ClickHouse result column.", "type": "integer", "minimum": 0, "x-altinity-completion": { "source": "resultColumnIndexes" } }, "deltaPresentation": { "title": "Delta presentation", "description": "Display metadata for a runtime KPI delta value.", "type": "object", "properties": { "displayName": { "title": "Delta label", "description": "Optional visible label for the delta.", "type": "string" }, "unit": { "title": "Delta unit", "description": "Display-only suffix appended to the delta.", "type": "string" }, "decimals": { "title": "Delta decimal places", "description": "Requested display rounding for the delta.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [1] }, "positiveIsGood": { "title": "Positive is good", "description": "Whether a positive runtime delta has good semantics.", "type": "boolean" }, "show": { "title": "Show delta", "description": "Whether a present runtime delta is rendered.", "type": "boolean", "default": true } }, "additionalProperties": true, "x-altinity-order": ["displayName", "unit", "decimals", "positiveIsGood", "show"] }, "fieldConfigValue": { "title": "Field presentation metadata", "description": "Known presentation metadata for one result column. Unknown renderer extensions are retained.", "type": "object", "properties": { "displayName": { "title": "Display name", "description": "Rendered label for the field.", "type": "string" }, "decimals": { "title": "Decimal places", "description": "Requested number of decimal places for numeric display.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [2] }, "description": { "title": "Description", "description": "Supporting display text for the field.", "type": "string" }, "unit": { "title": "Unit", "description": "Display-only suffix appended to the value.", "type": "string", "examples": ["%"] }, "color": { "title": "Color", "description": "Theme token or CSS color hint interpreted by the renderer.", "type": "string" }, "noValue": { "title": "No-value text", "description": "Text shown for NULL or unavailable values.", "type": "string", "default": "\u2014" }, "hidden": { "title": "Hidden", "description": "Suppress this otherwise eligible result field.", "type": "boolean", "default": false }, "delta": { "$ref": "#/$defs/deltaPresentation" } }, "additionalProperties": true, "x-altinity-order": ["displayName", "description", "unit", "decimals", "color", "noValue", "hidden", "delta"] }, "fieldConfig": { "title": "Panel field configuration", "description": "Default and per-column display metadata.", "type": "object", "properties": { "defaults": { "$ref": "#/$defs/fieldConfigValue" }, "columns": { "title": "Column overrides", "description": "Display metadata keyed by exact result-column name.", "type": "object", "additionalProperties": { "$ref": "#/$defs/fieldConfigValue" }, "x-altinity-key-completion": { "source": "resultColumns" } } }, "additionalProperties": true, "x-altinity-order": ["defaults", "columns"] }, "queryPresentationPatchV1": { "title": "Presentation patch", "description": "RFC 7396 JSON Merge Patch applied over the saved query's base panel object. Object members merge recursively, arrays replace the previous array completely, and a null member deletes an optional property. A patch may not change panel.cfg.type, and the final resolved panel must validate like a normal saved-query panel.", "type": "object", "additionalProperties": true }, "dashboardSizeHints": { "title": "Size hints", "description": "Layout-neutral tile size hints. Authoring may derive an initial placement from them; they never own actual Dashboard placement.", "type": "object", "properties": { "preferred": { "title": "Preferred size", "description": "Preferred layout-neutral tile size.", "type": "string", "enum": ["compact", "medium", "wide"] }, "minimum": { "title": "Minimum size", "description": "Smallest layout-neutral tile size that still renders usefully.", "type": "string", "enum": ["compact", "medium", "wide"] }, "aspectRatio": { "title": "Aspect ratio", "description": "Preferred width/height ratio hint.", "type": "number", "exclusiveMinimum": 0 } }, "additionalProperties": true, "x-altinity-order": ["preferred", "minimum", "aspectRatio"] }, "queryDashboardPresentationV1": { "title": "Dashboard configuration", "description": "Reusable Dashboard presentation for this query: role, named presentation variants, and layout-neutral size hints. Actual Dashboard sequence, placement, and tile-local overrides live in the Dashboard document. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { "title": "Dashboard role (Panel, Filter, or Setup)", "description": "How the saved query participates in a dashboard. panel (the default) creates a visualization tile. filter returns exactly one row whose supported top-level Array, named Tuple Array, or Map columns provide option lists for parameters with the same exact names; it creates no tile and its SQL cannot declare parameters. setup is reserved for serialized Dashboard setup execution.", "type": "string", "enum": ["panel", "filter", "setup"], "default": "panel", "examples": ["filter"] }, "defaultVariant": { "title": "Default presentation variant", "description": "Variant applied when a tile does not select one. Must name an existing entry in variants.", "type": "string", "minLength": 1, "maxLength": 256 }, "variants": { "title": "Presentation variants", "description": "Named reusable presentation patches, each an RFC 7396 JSON Merge Patch over the base panel.", "type": "object", "maxProperties": 32, "propertyNames": { "minLength": 1, "maxLength": 256 }, "additionalProperties": { "$ref": "#/$defs/queryPresentationPatchV1" } }, "sizeHints": { "$ref": "#/$defs/dashboardSizeHints" } }, "additionalProperties": true, "x-altinity-order": ["role", "defaultVariant", "variants", "sizeHints"] }, "panel": { "title": "Panel configuration", "description": "Visualization and field metadata for the saved query.", "type": "object", "properties": { "cfg": { "$ref": "#/$defs/panelCfg" }, "key": { "title": "Result schema key", "description": "Saved result-column signature used to detect stale positional roles.", "type": ["string", "null"] }, "fieldConfig": { "$ref": "#/$defs/fieldConfig" } }, "additionalProperties": true, "x-altinity-order": ["cfg", "key", "fieldConfig"] }, "lineChartStyle": { "title": "Line style", "description": "Renderer-independent line presentation. Unknown fields and future string values remain storable.", "type": "object", "properties": { "curve": { "title": "Line curve", "description": "linear draws straight segments, smooth uses monotone interpolation, and stepped draws step-after segments.", "anyOf": [{ "type": "string", "enum": ["linear", "smooth", "stepped"] }, { "type": "string" }], "default": "linear" }, "points": { "title": "Point markers", "description": "auto shows markers only for sparse results, show always displays them, and hide retains hover targets without visible markers.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "scale": { "title": "Value scale", "description": "zero anchors the value axis at zero, data uses the data range, and auto uses the chart-type default.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "data" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["curve", "points", "scale", "legend", "grid", "axes"] }, "areaChartStyle": { "title": "Area style", "description": "Curve, marker, and additive stacking presentation for Area charts.", "allOf": [{ "$ref": "#/$defs/lineChartStyle" }, { "type": "object", "properties": { "stack": { "title": "Area stacking", "description": "overlay draws series independently; stacked uses one shared additive stack without normalization.", "anyOf": [{ "type": "string", "enum": ["overlay", "stacked"] }, { "type": "string" }], "default": "overlay" } }, "additionalProperties": true }], "x-altinity-order": ["curve", "points", "stack", "scale", "legend", "grid", "axes"] }, "barChartStyle": { "title": "Bar and Column style", "description": "Grouping and category-spacing presentation shared by horizontal Bar and vertical Column charts.", "type": "object", "properties": { "mode": { "title": "Bar grouping", "description": "grouped draws measures side by side; stacked adds them on one shared value stack.", "anyOf": [{ "type": "string", "enum": ["grouped", "stacked"] }, { "type": "string" }], "default": "grouped" }, "density": { "title": "Category spacing", "description": "normal uses standard spacing, compact reduces gaps, and joined removes category gaps.", "anyOf": [{ "type": "string", "enum": ["normal", "compact", "joined"] }, { "type": "string" }], "default": "normal" }, "scale": { "title": "Value scale", "description": "zero and auto anchor Bar/Column at zero; data uses the data range.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "zero" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["mode", "density", "scale", "legend", "grid", "axes"] }, "pieChartStyle": { "title": "Pie style", "description": "Pie or Donut shape presentation.", "type": "object", "properties": { "shape": { "title": "Pie shape", "description": "pie fills the center; donut uses a fixed 60% cutout.", "anyOf": [{ "type": "string", "enum": ["pie", "donut"] }, { "type": "string" }], "default": "pie" }, "legend": { "title": "Legend visibility", "description": "show renders the slice legend; hide relies on tooltips.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "show" }, "frame": { "title": "Chart frame", "description": "compact reduces Pie layout padding; normal retains the standard frame.", "anyOf": [{ "type": "string", "enum": ["normal", "compact"] }, { "type": "string" }], "default": "normal" } }, "additionalProperties": true, "x-altinity-order": ["shape", "legend", "frame"] }, "chartCfg": { "type": "object", "properties": { "x": { "$ref": "#/$defs/resultColumnIndex", "default": 0 }, "y": { "title": "Measure columns", "description": "One or more zero-based result-column indexes used as measures.", "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/resultColumnIndex" } }, "series": { "title": "Series column", "description": "Optional zero-based result-column index used to split series.", "oneOf": [{ "$ref": "#/$defs/resultColumnIndex" }, { "type": "null" }], "default": null, "x-altinity-completion": { "source": "resultColumnIndexes" } } }, "required": ["x", "y"], "additionalProperties": true, "x-altinity-order": ["type", "x", "y", "series"] }, "styledChartCfg": { "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "type": "object", "properties": { "style": { "type": "object" } } }], "x-altinity-order": ["type", "style", "x", "y", "series"] }, "panelCfg": { "title": "Panel type configuration", "description": "Discriminated visualization configuration. Unknown types remain storable for forward compatibility.", "type": "object", "required": ["type"], "properties": { "type": { "title": "Panel type", "description": "Visualization renderer identifier.", "type": "string", "minLength": 1 } }, "additionalProperties": true, "x-altinity-discriminator": "type", "x-altinity-order": ["type"], "oneOf": [{ "title": "Column chart", "description": "Vertical columns using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "bar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "bar" } }, "required": ["type"] }] }, { "title": "Horizontal bar chart", "description": "Horizontal bars using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "hbar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "hbar" } }, "required": ["type"] }] }, { "title": "Line chart", "description": "Line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "line", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/lineChartStyle" } } }, { "properties": { "type": { "const": "line" } }, "required": ["type"] }] }, { "title": "Area chart", "description": "Filled line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "area", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/areaChartStyle" } } }, { "properties": { "type": { "const": "area" } }, "required": ["type"] }] }, { "title": "Pie chart", "description": "Pie slices using one positional measure role.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "pie", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/pieChartStyle" } } }, { "properties": { "type": { "const": "pie" }, "y": { "type": "array", "maxItems": 1 } }, "required": ["type"] }] }, { "title": "KPI", "description": "One-row scalar and named-tuple KPI cards.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "kpi" }, "properties": { "type": { "const": "kpi" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type"] }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "table" }, "properties": { "type": { "const": "table" } }, "required": ["type"], "additionalProperties": true }, { "title": "Logs", "description": "Timestamped log messages with optional explicit result-column roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "logs", "time": "event_time", "msg": "message", "level": "level" }, "properties": { "type": { "const": "logs" }, "time": { "$ref": "#/$defs/columnName" }, "msg": { "$ref": "#/$defs/columnName" }, "level": { "$ref": "#/$defs/columnName" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "time", "msg", "level"] }, { "title": "Markdown text", "description": "Safe Markdown content that does not require a SQL result.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "text", "content": "# Heading\n\nMarkdown content." }, "properties": { "type": { "const": "text" }, "content": { "title": "Markdown content", "description": "Source text for the safe Markdown renderer.", "type": "string", "default": "" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "content"] }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", "x-altinity-status": "planned", "x-altinity-snippet": { "type": "future-panel" }, "properties": { "type": { "type": "string", "minLength": 1, "not": { "enum": ["bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text"] } } }, "required": ["type"], "additionalProperties": true }] } } }; var func1 = require_ucs2length().default; var pattern4 = new RegExp("\\S", "u"); var schema32 = { "title": "Panel configuration", "description": "Visualization and field metadata for the saved query.", "type": "object", "properties": { "cfg": { "$ref": "#/$defs/panelCfg" }, "key": { "title": "Result schema key", "description": "Saved result-column signature used to detect stale positional roles.", "type": ["string", "null"] }, "fieldConfig": { "$ref": "#/$defs/fieldConfig" } }, "additionalProperties": true, "x-altinity-order": ["cfg", "key", "fieldConfig"] }; @@ -3101,6 +3100,229 @@ function validate21(data, { instancePath = "", parentData, parentDataProperty, r return errors === 0; } validate21.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var schema51 = { "title": "Dashboard configuration", "description": "Reusable Dashboard presentation for this query: role, named presentation variants, and layout-neutral size hints. Actual Dashboard sequence, placement, and tile-local overrides live in the Dashboard document. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { "title": "Dashboard role (Panel, Filter, or Setup)", "description": "How the saved query participates in a dashboard. panel (the default) creates a visualization tile. filter returns exactly one row whose supported top-level Array, named Tuple Array, or Map columns provide option lists for parameters with the same exact names; it creates no tile and its SQL cannot declare parameters. setup is reserved for serialized Dashboard setup execution.", "type": "string", "enum": ["panel", "filter", "setup"], "default": "panel", "examples": ["filter"] }, "defaultVariant": { "title": "Default presentation variant", "description": "Variant applied when a tile does not select one. Must name an existing entry in variants.", "type": "string", "minLength": 1, "maxLength": 256 }, "variants": { "title": "Presentation variants", "description": "Named reusable presentation patches, each an RFC 7396 JSON Merge Patch over the base panel.", "type": "object", "maxProperties": 32, "propertyNames": { "minLength": 1, "maxLength": 256 }, "additionalProperties": { "$ref": "#/$defs/queryPresentationPatchV1" } }, "sizeHints": { "$ref": "#/$defs/dashboardSizeHints" } }, "additionalProperties": true, "x-altinity-order": ["role", "defaultVariant", "variants", "sizeHints"] }; +var schema53 = { "title": "Size hints", "description": "Layout-neutral tile size hints. Authoring may derive an initial placement from them; they never own actual Dashboard placement.", "type": "object", "properties": { "preferred": { "title": "Preferred size", "description": "Preferred layout-neutral tile size.", "type": "string", "enum": ["compact", "medium", "wide"] }, "minimum": { "title": "Minimum size", "description": "Smallest layout-neutral tile size that still renders usefully.", "type": "string", "enum": ["compact", "medium", "wide"] }, "aspectRatio": { "title": "Aspect ratio", "description": "Preferred width/height ratio hint.", "type": "number", "exclusiveMinimum": 0 } }, "additionalProperties": true, "x-altinity-order": ["preferred", "minimum", "aspectRatio"] }; +function validate40(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + let vErrors = null; + let errors = 0; + const evaluated0 = validate40.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.role !== void 0) { + let data0 = data.role; + if (typeof data0 !== "string") { + const err0 = { instancePath: instancePath + "/role", schemaPath: "#/properties/role/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + if (!(data0 === "panel" || data0 === "filter" || data0 === "setup")) { + const err1 = { instancePath: instancePath + "/role", schemaPath: "#/properties/role/enum", keyword: "enum", params: { allowedValues: schema51.properties.role.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + } + if (data.defaultVariant !== void 0) { + let data1 = data.defaultVariant; + if (typeof data1 === "string") { + if (func1(data1) > 256) { + const err2 = { instancePath: instancePath + "/defaultVariant", schemaPath: "#/properties/defaultVariant/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + if (func1(data1) < 1) { + const err3 = { instancePath: instancePath + "/defaultVariant", schemaPath: "#/properties/defaultVariant/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + } else { + const err4 = { instancePath: instancePath + "/defaultVariant", schemaPath: "#/properties/defaultVariant/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + } + if (data.variants !== void 0) { + let data2 = data.variants; + if (data2 && typeof data2 == "object" && !Array.isArray(data2)) { + if (Object.keys(data2).length > 32) { + const err5 = { instancePath: instancePath + "/variants", schemaPath: "#/properties/variants/maxProperties", keyword: "maxProperties", params: { limit: 32 }, message: "must NOT have more than 32 properties" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + for (const key0 in data2) { + const _errs8 = errors; + if (typeof key0 === "string") { + if (func1(key0) > 256) { + const err6 = { instancePath: instancePath + "/variants", schemaPath: "#/properties/variants/propertyNames/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters", propertyName: key0 }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + if (func1(key0) < 1) { + const err7 = { instancePath: instancePath + "/variants", schemaPath: "#/properties/variants/propertyNames/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters", propertyName: key0 }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + } + var valid1 = _errs8 === errors; + if (!valid1) { + const err8 = { instancePath: instancePath + "/variants", schemaPath: "#/properties/variants/propertyNames", keyword: "propertyNames", params: { propertyName: key0 }, message: "property name must be valid" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + } + for (const key1 in data2) { + let data3 = data2[key1]; + if (data3 && typeof data3 == "object" && !Array.isArray(data3)) { + } else { + const err9 = { instancePath: instancePath + "/variants/" + key1.replace(/~/g, "~0").replace(/\//g, "~1"), schemaPath: "#/$defs/queryPresentationPatchV1/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + } + } else { + const err10 = { instancePath: instancePath + "/variants", schemaPath: "#/properties/variants/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + } + if (data.sizeHints !== void 0) { + let data4 = data.sizeHints; + if (data4 && typeof data4 == "object" && !Array.isArray(data4)) { + if (data4.preferred !== void 0) { + let data5 = data4.preferred; + if (typeof data5 !== "string") { + const err11 = { instancePath: instancePath + "/sizeHints/preferred", schemaPath: "#/$defs/dashboardSizeHints/properties/preferred/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + if (!(data5 === "compact" || data5 === "medium" || data5 === "wide")) { + const err12 = { instancePath: instancePath + "/sizeHints/preferred", schemaPath: "#/$defs/dashboardSizeHints/properties/preferred/enum", keyword: "enum", params: { allowedValues: schema53.properties.preferred.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + } + if (data4.minimum !== void 0) { + let data6 = data4.minimum; + if (typeof data6 !== "string") { + const err13 = { instancePath: instancePath + "/sizeHints/minimum", schemaPath: "#/$defs/dashboardSizeHints/properties/minimum/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + if (!(data6 === "compact" || data6 === "medium" || data6 === "wide")) { + const err14 = { instancePath: instancePath + "/sizeHints/minimum", schemaPath: "#/$defs/dashboardSizeHints/properties/minimum/enum", keyword: "enum", params: { allowedValues: schema53.properties.minimum.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err14]; + } else { + vErrors.push(err14); + } + errors++; + } + } + if (data4.aspectRatio !== void 0) { + let data7 = data4.aspectRatio; + if (typeof data7 == "number" && isFinite(data7)) { + if (data7 <= 0 || isNaN(data7)) { + const err15 = { instancePath: instancePath + "/sizeHints/aspectRatio", schemaPath: "#/$defs/dashboardSizeHints/properties/aspectRatio/exclusiveMinimum", keyword: "exclusiveMinimum", params: { comparison: ">", limit: 0 }, message: "must be > 0" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + } else { + const err16 = { instancePath: instancePath + "/sizeHints/aspectRatio", schemaPath: "#/$defs/dashboardSizeHints/properties/aspectRatio/type", keyword: "type", params: { type: "number" }, message: "must be number" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } + } + } else { + const err17 = { instancePath: instancePath + "/sizeHints", schemaPath: "#/$defs/dashboardSizeHints/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err17]; + } else { + vErrors.push(err17); + } + errors++; + } + } + } else { + const err18 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err18]; + } else { + vErrors.push(err18); + } + errors++; + } + validate40.errors = vErrors; + return errors === 0; +} +validate40.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; function validate20(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; @@ -3194,45 +3416,17 @@ function validate20(data, { instancePath = "", parentData, parentDataProperty, r } } if (data.dashboard !== void 0) { - let data5 = data.dashboard; - if (data5 && typeof data5 == "object" && !Array.isArray(data5)) { - if (data5.role !== void 0) { - let data6 = data5.role; - if (typeof data6 !== "string") { - const err7 = { instancePath: instancePath + "/dashboard/role", schemaPath: "#/$defs/dashboard/properties/role/type", keyword: "type", params: { type: "string" }, message: "must be string" }; - if (vErrors === null) { - vErrors = [err7]; - } else { - vErrors.push(err7); - } - errors++; - } - if (!(data6 === "panel" || data6 === "filter" || data6 === "setup")) { - const err8 = { instancePath: instancePath + "/dashboard/role", schemaPath: "#/$defs/dashboard/properties/role/enum", keyword: "enum", params: { allowedValues: schema51.properties.role.enum }, message: "must be equal to one of the allowed values" }; - if (vErrors === null) { - vErrors = [err8]; - } else { - vErrors.push(err8); - } - errors++; - } - } - } else { - const err9 = { instancePath: instancePath + "/dashboard", schemaPath: "#/$defs/dashboard/type", keyword: "type", params: { type: "object" }, message: "must be object" }; - if (vErrors === null) { - vErrors = [err9]; - } else { - vErrors.push(err9); - } - errors++; + if (!validate40(data.dashboard, { instancePath: instancePath + "/dashboard", parentData: data, parentDataProperty: "dashboard", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate40.errors : vErrors.concat(validate40.errors); + errors = vErrors.length; } } } else { - const err10 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err7 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err10]; + vErrors = [err7]; } else { - vErrors.push(err10); + vErrors.push(err7); } errors++; } @@ -3240,12 +3434,12 @@ function validate20(data, { instancePath = "", parentData, parentDataProperty, r return errors === 0; } validate20.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -var validateSavedQueryV2 = validate40; -function validate40(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +var validateSavedQueryV2 = validate42; +function validate42(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate40.evaluated; + const evaluated0 = validate42.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -3451,17 +3645,17 @@ function validate40(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate40.errors = vErrors; + validate42.errors = vErrors; return errors === 0; } -validate40.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -var validateLibraryV2 = validate42; +validate42.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validateLibraryV2 = validate44; var formats0 = require_formats().fullFormats["date-time"]; -function validate42(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +function validate44(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate42.evaluated; + const evaluated0 = validate44.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -3586,8 +3780,8 @@ function validate42(data, { instancePath = "", parentData, parentDataProperty, r } const len0 = data4.length; for (let i0 = 0; i0 < len0; i0++) { - if (!validate40(data4[i0], { instancePath: instancePath + "/queries/" + i0, parentData: data4, parentDataProperty: i0, rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate40.errors : vErrors.concat(validate40.errors); + if (!validate42(data4[i0], { instancePath: instancePath + "/queries/" + i0, parentData: data4, parentDataProperty: i0, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate42.errors : vErrors.concat(validate42.errors); errors = vErrors.length; } } @@ -3610,18 +3804,2113 @@ function validate42(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate42.errors = vErrors; + validate44.errors = vErrors; return errors === 0; } -validate42.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +validate44.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validateFlowLayoutV1 = validate46; +var schema57 = { "title": "Flow preset", "description": "Desktop column arrangement: full-width and report render one column (report centers a constrained-width column), columns-2 and columns-3 render equal columns.", "type": "string", "enum": ["full-width", "report", "columns-2", "columns-3"] }; +var schema58 = { "title": "Tile placement", "description": "Closed placement contract: unknown fields fail validation. Future extension requires flow@2 or an explicit extension namespace.", "type": "object", "properties": { "span": { "title": "Column span", "description": "Columns the tile occupies; the effective span is clamped to the active column count.", "type": "integer", "enum": [1, 2, 3] }, "height": { "$ref": "#/$defs/flowHeightV1" } }, "additionalProperties": false, "x-altinity-order": ["span", "height"] }; +var schema59 = { "title": "Tile height", "description": "Normative height ordering is compact < medium < large; exact pixels are renderer-defined.", "type": "string", "enum": ["compact", "medium", "large"] }; +function validate47(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + let vErrors = null; + let errors = 0; + const evaluated0 = validate47.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + for (const key0 in data) { + if (!(key0 === "span" || key0 === "height")) { + const err0 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + } + if (data.span !== void 0) { + let data0 = data.span; + if (!(typeof data0 == "number" && (!(data0 % 1) && !isNaN(data0)) && isFinite(data0))) { + const err1 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (!(data0 === 1 || data0 === 2 || data0 === 3)) { + const err2 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/enum", keyword: "enum", params: { allowedValues: schema58.properties.span.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + } + if (data.height !== void 0) { + let data1 = data.height; + if (typeof data1 !== "string") { + const err3 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/flowHeightV1/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + if (!(data1 === "compact" || data1 === "medium" || data1 === "large")) { + const err4 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/flowHeightV1/enum", keyword: "enum", params: { allowedValues: schema59.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + } + } else { + const err5 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + validate47.errors = vErrors; + return errors === 0; +} +validate47.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate46(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + ; + let vErrors = null; + let errors = 0; + const evaluated0 = validate46.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.type === void 0) { + const err0 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + if (data.version === void 0) { + const err1 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "version" }, message: "must have required property 'version'" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (data.preset === void 0) { + const err2 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "preset" }, message: "must have required property 'preset'" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + if (data.items === void 0) { + const err3 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "items" }, message: "must have required property 'items'" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + for (const key0 in data) { + if (!(key0 === "type" || key0 === "version" || key0 === "preset" || key0 === "items")) { + const err4 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + } + if (data.type !== void 0) { + let data0 = data.type; + if (typeof data0 !== "string") { + const err5 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + if ("flow" !== data0) { + const err6 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/const", keyword: "const", params: { allowedValue: "flow" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + } + if (data.version !== void 0) { + let data1 = data.version; + if (!(typeof data1 == "number" && (!(data1 % 1) && !isNaN(data1)) && isFinite(data1))) { + const err7 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + if (1 !== data1) { + const err8 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/const", keyword: "const", params: { allowedValue: 1 }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + } + if (data.preset !== void 0) { + let data2 = data.preset; + if (typeof data2 !== "string") { + const err9 = { instancePath: instancePath + "/preset", schemaPath: "#/$defs/flowPresetV1/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + if (!(data2 === "full-width" || data2 === "report" || data2 === "columns-2" || data2 === "columns-3")) { + const err10 = { instancePath: instancePath + "/preset", schemaPath: "#/$defs/flowPresetV1/enum", keyword: "enum", params: { allowedValues: schema57.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + } + if (data.items !== void 0) { + let data3 = data.items; + if (data3 && typeof data3 == "object" && !Array.isArray(data3)) { + if (Object.keys(data3).length > 100) { + const err11 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/maxProperties", keyword: "maxProperties", params: { limit: 100 }, message: "must NOT have more than 100 properties" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + for (const key1 in data3) { + const _errs11 = errors; + if (typeof key1 === "string") { + if (func1(key1) > 256) { + const err12 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters", propertyName: key1 }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + if (func1(key1) < 1) { + const err13 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters", propertyName: key1 }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + } + var valid2 = _errs11 === errors; + if (!valid2) { + const err14 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames", keyword: "propertyNames", params: { propertyName: key1 }, message: "property name must be valid" }; + if (vErrors === null) { + vErrors = [err14]; + } else { + vErrors.push(err14); + } + errors++; + } + } + for (const key2 in data3) { + if (!validate47(data3[key2], { instancePath: instancePath + "/items/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"), parentData: data3, parentDataProperty: key2, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate47.errors : vErrors.concat(validate47.errors); + errors = vErrors.length; + } + } + } else { + const err15 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + } + } else { + const err16 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } + validate46.errors = vErrors; + return errors === 0; +} +validate46.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validateDashboardV1 = validate49; +function validate52(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + let vErrors = null; + let errors = 0; + const evaluated0 = validate52.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + for (const key0 in data) { + if (!(key0 === "span" || key0 === "height")) { + const err0 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + } + if (data.span !== void 0) { + let data0 = data.span; + if (!(typeof data0 == "number" && (!(data0 % 1) && !isNaN(data0)) && isFinite(data0))) { + const err1 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (!(data0 === 1 || data0 === 2 || data0 === 3)) { + const err2 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/enum", keyword: "enum", params: { allowedValues: schema58.properties.span.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + } + if (data.height !== void 0) { + let data1 = data.height; + if (typeof data1 !== "string") { + const err3 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/flowHeightV1/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + if (!(data1 === "compact" || data1 === "medium" || data1 === "large")) { + const err4 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/flowHeightV1/enum", keyword: "enum", params: { allowedValues: schema59.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + } + } else { + const err5 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + validate52.errors = vErrors; + return errors === 0; +} +validate52.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate51(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + ; + let vErrors = null; + let errors = 0; + const evaluated0 = validate51.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.type === void 0) { + const err0 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + if (data.version === void 0) { + const err1 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "version" }, message: "must have required property 'version'" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (data.preset === void 0) { + const err2 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "preset" }, message: "must have required property 'preset'" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + if (data.items === void 0) { + const err3 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "items" }, message: "must have required property 'items'" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + for (const key0 in data) { + if (!(key0 === "type" || key0 === "version" || key0 === "preset" || key0 === "items")) { + const err4 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + } + if (data.type !== void 0) { + let data0 = data.type; + if (typeof data0 !== "string") { + const err5 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + if ("flow" !== data0) { + const err6 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/const", keyword: "const", params: { allowedValue: "flow" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + } + if (data.version !== void 0) { + let data1 = data.version; + if (!(typeof data1 == "number" && (!(data1 % 1) && !isNaN(data1)) && isFinite(data1))) { + const err7 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + if (1 !== data1) { + const err8 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/const", keyword: "const", params: { allowedValue: 1 }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + } + if (data.preset !== void 0) { + let data2 = data.preset; + if (typeof data2 !== "string") { + const err9 = { instancePath: instancePath + "/preset", schemaPath: "#/$defs/flowPresetV1/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + if (!(data2 === "full-width" || data2 === "report" || data2 === "columns-2" || data2 === "columns-3")) { + const err10 = { instancePath: instancePath + "/preset", schemaPath: "#/$defs/flowPresetV1/enum", keyword: "enum", params: { allowedValues: schema57.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + } + if (data.items !== void 0) { + let data3 = data.items; + if (data3 && typeof data3 == "object" && !Array.isArray(data3)) { + if (Object.keys(data3).length > 100) { + const err11 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/maxProperties", keyword: "maxProperties", params: { limit: 100 }, message: "must NOT have more than 100 properties" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + for (const key1 in data3) { + const _errs11 = errors; + if (typeof key1 === "string") { + if (func1(key1) > 256) { + const err12 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters", propertyName: key1 }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + if (func1(key1) < 1) { + const err13 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters", propertyName: key1 }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + } + var valid2 = _errs11 === errors; + if (!valid2) { + const err14 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames", keyword: "propertyNames", params: { propertyName: key1 }, message: "property name must be valid" }; + if (vErrors === null) { + vErrors = [err14]; + } else { + vErrors.push(err14); + } + errors++; + } + } + for (const key2 in data3) { + if (!validate52(data3[key2], { instancePath: instancePath + "/items/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"), parentData: data3, parentDataProperty: key2, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate52.errors : vErrors.concat(validate52.errors); + errors = vErrors.length; + } + } + } else { + const err15 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + } + } else { + const err16 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } + validate51.errors = vErrors; + return errors === 0; +} +validate51.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate50(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + let vErrors = null; + let errors = 0; + const evaluated0 = validate50.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.type === void 0) { + const err0 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + if (data.version === void 0) { + const err1 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "version" }, message: "must have required property 'version'" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + for (const key0 in data) { + if (!(key0 === "type" || key0 === "version" || key0 === "preset" || key0 === "config" || key0 === "items" || key0 === "fallback")) { + const err2 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + } + if (data.type !== void 0) { + let data0 = data.type; + if (typeof data0 === "string") { + if (func1(data0) > 256) { + const err3 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + if (func1(data0) < 1) { + const err4 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + } else { + const err5 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + } + if (data.version !== void 0) { + let data1 = data.version; + if (!(typeof data1 == "number" && (!(data1 % 1) && !isNaN(data1)) && isFinite(data1))) { + const err6 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + if (typeof data1 == "number" && isFinite(data1)) { + if (data1 < 1 || isNaN(data1)) { + const err7 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/minimum", keyword: "minimum", params: { comparison: ">=", limit: 1 }, message: "must be >= 1" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + } + } + if (data.preset !== void 0) { + let data2 = data.preset; + if (typeof data2 === "string") { + if (func1(data2) > 256) { + const err8 = { instancePath: instancePath + "/preset", schemaPath: "#/properties/preset/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + } else { + const err9 = { instancePath: instancePath + "/preset", schemaPath: "#/properties/preset/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + } + if (data.config !== void 0) { + let data3 = data.config; + if (!(data3 && typeof data3 == "object" && !Array.isArray(data3))) { + const err10 = { instancePath: instancePath + "/config", schemaPath: "#/properties/config/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + } + if (data.items !== void 0) { + let data4 = data.items; + if (data4 && typeof data4 == "object" && !Array.isArray(data4)) { + if (Object.keys(data4).length > 100) { + const err11 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/maxProperties", keyword: "maxProperties", params: { limit: 100 }, message: "must NOT have more than 100 properties" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + for (const key1 in data4) { + const _errs12 = errors; + if (typeof key1 === "string") { + if (func1(key1) > 256) { + const err12 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters", propertyName: key1 }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + if (func1(key1) < 1) { + const err13 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters", propertyName: key1 }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + } + var valid1 = _errs12 === errors; + if (!valid1) { + const err14 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames", keyword: "propertyNames", params: { propertyName: key1 }, message: "property name must be valid" }; + if (vErrors === null) { + vErrors = [err14]; + } else { + vErrors.push(err14); + } + errors++; + } + } + for (const key2 in data4) { + let data5 = data4[key2]; + if (!(data5 && typeof data5 == "object" && !Array.isArray(data5))) { + const err15 = { instancePath: instancePath + "/items/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"), schemaPath: "#/properties/items/additionalProperties/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + } + } else { + const err16 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } + } + if (data.fallback !== void 0) { + if (!validate51(data.fallback, { instancePath: instancePath + "/fallback", parentData: data, parentDataProperty: "fallback", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate51.errors : vErrors.concat(validate51.errors); + errors = vErrors.length; + } + } + } else { + const err17 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err17]; + } else { + vErrors.push(err17); + } + errors++; + } + validate50.errors = vErrors; + return errors === 0; +} +validate50.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate57(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + let vErrors = null; + let errors = 0; + const evaluated0 = validate57.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + for (const key0 in data) { + if (!(key0 === "variant" || key0 === "override")) { + const err0 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + } + if (data.variant !== void 0) { + let data0 = data.variant; + if (typeof data0 === "string") { + if (func1(data0) > 256) { + const err1 = { instancePath: instancePath + "/variant", schemaPath: "#/properties/variant/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (func1(data0) < 1) { + const err2 = { instancePath: instancePath + "/variant", schemaPath: "#/properties/variant/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + } else { + const err3 = { instancePath: instancePath + "/variant", schemaPath: "#/properties/variant/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + } + if (data.override !== void 0) { + let data1 = data.override; + if (data1 && typeof data1 == "object" && !Array.isArray(data1)) { + } else { + const err4 = { instancePath: instancePath + "/override", schemaPath: "query-spec-v1.schema.json#/$defs/queryPresentationPatchV1/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + } + } else { + const err5 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + validate57.errors = vErrors; + return errors === 0; +} +validate57.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate56(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + let vErrors = null; + let errors = 0; + const evaluated0 = validate56.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.id === void 0) { + const err0 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "id" }, message: "must have required property 'id'" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + if (data.queryId === void 0) { + const err1 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "queryId" }, message: "must have required property 'queryId'" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + for (const key0 in data) { + if (!(key0 === "id" || key0 === "queryId" || key0 === "title" || key0 === "description" || key0 === "presentation")) { + const err2 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + } + if (data.id !== void 0) { + let data0 = data.id; + if (typeof data0 === "string") { + if (func1(data0) > 256) { + const err3 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + if (func1(data0) < 1) { + const err4 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + if (!pattern4.test(data0)) { + const err5 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/pattern", keyword: "pattern", params: { pattern: "\\S" }, message: 'must match pattern "\\S"' }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + } else { + const err6 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + } + if (data.queryId !== void 0) { + let data1 = data.queryId; + if (typeof data1 === "string") { + if (func1(data1) > 256) { + const err7 = { instancePath: instancePath + "/queryId", schemaPath: "#/properties/queryId/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + if (func1(data1) < 1) { + const err8 = { instancePath: instancePath + "/queryId", schemaPath: "#/properties/queryId/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + if (!pattern4.test(data1)) { + const err9 = { instancePath: instancePath + "/queryId", schemaPath: "#/properties/queryId/pattern", keyword: "pattern", params: { pattern: "\\S" }, message: 'must match pattern "\\S"' }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + } else { + const err10 = { instancePath: instancePath + "/queryId", schemaPath: "#/properties/queryId/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + } + if (data.title !== void 0) { + let data2 = data.title; + if (typeof data2 === "string") { + if (func1(data2) > 512) { + const err11 = { instancePath: instancePath + "/title", schemaPath: "#/properties/title/maxLength", keyword: "maxLength", params: { limit: 512 }, message: "must NOT have more than 512 characters" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + } else { + const err12 = { instancePath: instancePath + "/title", schemaPath: "#/properties/title/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + } + if (data.description !== void 0) { + let data3 = data.description; + if (typeof data3 === "string") { + if (func1(data3) > 16384) { + const err13 = { instancePath: instancePath + "/description", schemaPath: "#/properties/description/maxLength", keyword: "maxLength", params: { limit: 16384 }, message: "must NOT have more than 16384 characters" }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + } else { + const err14 = { instancePath: instancePath + "/description", schemaPath: "#/properties/description/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err14]; + } else { + vErrors.push(err14); + } + errors++; + } + } + if (data.presentation !== void 0) { + if (!validate57(data.presentation, { instancePath: instancePath + "/presentation", parentData: data, parentDataProperty: "presentation", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate57.errors : vErrors.concat(validate57.errors); + errors = vErrors.length; + } + } + } else { + const err15 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + validate56.errors = vErrors; + return errors === 0; +} +validate56.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate49(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + ; + let vErrors = null; + let errors = 0; + const evaluated0 = validate49.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.documentVersion === void 0) { + const err0 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "documentVersion" }, message: "must have required property 'documentVersion'" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + if (data.id === void 0) { + const err1 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "id" }, message: "must have required property 'id'" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (data.title === void 0) { + const err2 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "title" }, message: "must have required property 'title'" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + if (data.revision === void 0) { + const err3 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "revision" }, message: "must have required property 'revision'" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + if (data.layout === void 0) { + const err4 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "layout" }, message: "must have required property 'layout'" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + if (data.filters === void 0) { + const err5 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "filters" }, message: "must have required property 'filters'" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + if (data.tiles === void 0) { + const err6 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "tiles" }, message: "must have required property 'tiles'" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + for (const key0 in data) { + if (!(key0 === "documentVersion" || key0 === "id" || key0 === "title" || key0 === "description" || key0 === "revision" || key0 === "layout" || key0 === "filters" || key0 === "tiles")) { + const err7 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + } + if (data.documentVersion !== void 0) { + let data0 = data.documentVersion; + if (!(typeof data0 == "number" && (!(data0 % 1) && !isNaN(data0)) && isFinite(data0))) { + const err8 = { instancePath: instancePath + "/documentVersion", schemaPath: "#/properties/documentVersion/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + if (1 !== data0) { + const err9 = { instancePath: instancePath + "/documentVersion", schemaPath: "#/properties/documentVersion/const", keyword: "const", params: { allowedValue: 1 }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + } + if (data.id !== void 0) { + let data1 = data.id; + if (typeof data1 === "string") { + if (func1(data1) > 256) { + const err10 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + if (func1(data1) < 1) { + const err11 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + if (!pattern4.test(data1)) { + const err12 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/pattern", keyword: "pattern", params: { pattern: "\\S" }, message: 'must match pattern "\\S"' }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + } else { + const err13 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + } + if (data.title !== void 0) { + let data2 = data.title; + if (typeof data2 === "string") { + if (func1(data2) > 512) { + const err14 = { instancePath: instancePath + "/title", schemaPath: "#/properties/title/maxLength", keyword: "maxLength", params: { limit: 512 }, message: "must NOT have more than 512 characters" }; + if (vErrors === null) { + vErrors = [err14]; + } else { + vErrors.push(err14); + } + errors++; + } + } else { + const err15 = { instancePath: instancePath + "/title", schemaPath: "#/properties/title/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + } + if (data.description !== void 0) { + let data3 = data.description; + if (typeof data3 === "string") { + if (func1(data3) > 16384) { + const err16 = { instancePath: instancePath + "/description", schemaPath: "#/properties/description/maxLength", keyword: "maxLength", params: { limit: 16384 }, message: "must NOT have more than 16384 characters" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } + } else { + const err17 = { instancePath: instancePath + "/description", schemaPath: "#/properties/description/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err17]; + } else { + vErrors.push(err17); + } + errors++; + } + } + if (data.revision !== void 0) { + let data4 = data.revision; + if (!(typeof data4 == "number" && (!(data4 % 1) && !isNaN(data4)) && isFinite(data4))) { + const err18 = { instancePath: instancePath + "/revision", schemaPath: "#/properties/revision/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err18]; + } else { + vErrors.push(err18); + } + errors++; + } + if (typeof data4 == "number" && isFinite(data4)) { + if (data4 < 1 || isNaN(data4)) { + const err19 = { instancePath: instancePath + "/revision", schemaPath: "#/properties/revision/minimum", keyword: "minimum", params: { comparison: ">=", limit: 1 }, message: "must be >= 1" }; + if (vErrors === null) { + vErrors = [err19]; + } else { + vErrors.push(err19); + } + errors++; + } + } + } + if (data.layout !== void 0) { + if (!validate50(data.layout, { instancePath: instancePath + "/layout", parentData: data, parentDataProperty: "layout", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate50.errors : vErrors.concat(validate50.errors); + errors = vErrors.length; + } + } + if (data.filters !== void 0) { + let data6 = data.filters; + if (Array.isArray(data6)) { + if (data6.length > 32) { + const err20 = { instancePath: instancePath + "/filters", schemaPath: "#/properties/filters/maxItems", keyword: "maxItems", params: { limit: 32 }, message: "must NOT have more than 32 items" }; + if (vErrors === null) { + vErrors = [err20]; + } else { + vErrors.push(err20); + } + errors++; + } + const len0 = data6.length; + for (let i0 = 0; i0 < len0; i0++) { + let data7 = data6[i0]; + if (data7 && typeof data7 == "object" && !Array.isArray(data7)) { + if (data7.id === void 0) { + const err21 = { instancePath: instancePath + "/filters/" + i0, schemaPath: "#/$defs/dashboardFilterDefinitionV1/required", keyword: "required", params: { missingProperty: "id" }, message: "must have required property 'id'" }; + if (vErrors === null) { + vErrors = [err21]; + } else { + vErrors.push(err21); + } + errors++; + } + if (data7.parameter === void 0) { + const err22 = { instancePath: instancePath + "/filters/" + i0, schemaPath: "#/$defs/dashboardFilterDefinitionV1/required", keyword: "required", params: { missingProperty: "parameter" }, message: "must have required property 'parameter'" }; + if (vErrors === null) { + vErrors = [err22]; + } else { + vErrors.push(err22); + } + errors++; + } + for (const key1 in data7) { + if (!(key1 === "id" || key1 === "parameter" || key1 === "label" || key1 === "sourceQueryId" || key1 === "targets" || key1 === "defaultValue" || key1 === "defaultActive")) { + const err23 = { instancePath: instancePath + "/filters/" + i0, schemaPath: "#/$defs/dashboardFilterDefinitionV1/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key1 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err23]; + } else { + vErrors.push(err23); + } + errors++; + } + } + if (data7.id !== void 0) { + let data8 = data7.id; + if (typeof data8 === "string") { + if (func1(data8) > 256) { + const err24 = { instancePath: instancePath + "/filters/" + i0 + "/id", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/id/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err24]; + } else { + vErrors.push(err24); + } + errors++; + } + if (func1(data8) < 1) { + const err25 = { instancePath: instancePath + "/filters/" + i0 + "/id", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/id/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err25]; + } else { + vErrors.push(err25); + } + errors++; + } + if (!pattern4.test(data8)) { + const err26 = { instancePath: instancePath + "/filters/" + i0 + "/id", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/id/pattern", keyword: "pattern", params: { pattern: "\\S" }, message: 'must match pattern "\\S"' }; + if (vErrors === null) { + vErrors = [err26]; + } else { + vErrors.push(err26); + } + errors++; + } + } else { + const err27 = { instancePath: instancePath + "/filters/" + i0 + "/id", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/id/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err27]; + } else { + vErrors.push(err27); + } + errors++; + } + } + if (data7.parameter !== void 0) { + let data9 = data7.parameter; + if (typeof data9 === "string") { + if (func1(data9) > 256) { + const err28 = { instancePath: instancePath + "/filters/" + i0 + "/parameter", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/parameter/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err28]; + } else { + vErrors.push(err28); + } + errors++; + } + if (func1(data9) < 1) { + const err29 = { instancePath: instancePath + "/filters/" + i0 + "/parameter", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/parameter/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err29]; + } else { + vErrors.push(err29); + } + errors++; + } + } else { + const err30 = { instancePath: instancePath + "/filters/" + i0 + "/parameter", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/parameter/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err30]; + } else { + vErrors.push(err30); + } + errors++; + } + } + if (data7.label !== void 0) { + let data10 = data7.label; + if (typeof data10 === "string") { + if (func1(data10) > 512) { + const err31 = { instancePath: instancePath + "/filters/" + i0 + "/label", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/label/maxLength", keyword: "maxLength", params: { limit: 512 }, message: "must NOT have more than 512 characters" }; + if (vErrors === null) { + vErrors = [err31]; + } else { + vErrors.push(err31); + } + errors++; + } + } else { + const err32 = { instancePath: instancePath + "/filters/" + i0 + "/label", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/label/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err32]; + } else { + vErrors.push(err32); + } + errors++; + } + } + if (data7.sourceQueryId !== void 0) { + let data11 = data7.sourceQueryId; + if (typeof data11 === "string") { + if (func1(data11) > 256) { + const err33 = { instancePath: instancePath + "/filters/" + i0 + "/sourceQueryId", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/sourceQueryId/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err33]; + } else { + vErrors.push(err33); + } + errors++; + } + if (func1(data11) < 1) { + const err34 = { instancePath: instancePath + "/filters/" + i0 + "/sourceQueryId", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/sourceQueryId/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err34]; + } else { + vErrors.push(err34); + } + errors++; + } + } else { + const err35 = { instancePath: instancePath + "/filters/" + i0 + "/sourceQueryId", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/sourceQueryId/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err35]; + } else { + vErrors.push(err35); + } + errors++; + } + } + if (data7.targets !== void 0) { + let data12 = data7.targets; + if (Array.isArray(data12)) { + if (data12.length > 100) { + const err36 = { instancePath: instancePath + "/filters/" + i0 + "/targets", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/targets/maxItems", keyword: "maxItems", params: { limit: 100 }, message: "must NOT have more than 100 items" }; + if (vErrors === null) { + vErrors = [err36]; + } else { + vErrors.push(err36); + } + errors++; + } + const len1 = data12.length; + for (let i1 = 0; i1 < len1; i1++) { + let data13 = data12[i1]; + if (typeof data13 === "string") { + if (func1(data13) > 256) { + const err37 = { instancePath: instancePath + "/filters/" + i0 + "/targets/" + i1, schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/targets/items/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err37]; + } else { + vErrors.push(err37); + } + errors++; + } + if (func1(data13) < 1) { + const err38 = { instancePath: instancePath + "/filters/" + i0 + "/targets/" + i1, schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/targets/items/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err38]; + } else { + vErrors.push(err38); + } + errors++; + } + } else { + const err39 = { instancePath: instancePath + "/filters/" + i0 + "/targets/" + i1, schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/targets/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err39]; + } else { + vErrors.push(err39); + } + errors++; + } + } + let i2 = data12.length; + let j0; + if (i2 > 1) { + const indices0 = {}; + for (; i2--; ) { + let item0 = data12[i2]; + if (typeof item0 !== "string") { + continue; + } + if (typeof indices0[item0] == "number") { + j0 = indices0[item0]; + const err40 = { instancePath: instancePath + "/filters/" + i0 + "/targets", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/targets/uniqueItems", keyword: "uniqueItems", params: { i: i2, j: j0 }, message: "must NOT have duplicate items (items ## " + j0 + " and " + i2 + " are identical)" }; + if (vErrors === null) { + vErrors = [err40]; + } else { + vErrors.push(err40); + } + errors++; + break; + } + indices0[item0] = i2; + } + } + } else { + const err41 = { instancePath: instancePath + "/filters/" + i0 + "/targets", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/targets/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err41]; + } else { + vErrors.push(err41); + } + errors++; + } + } + if (data7.defaultActive !== void 0) { + if (typeof data7.defaultActive !== "boolean") { + const err42 = { instancePath: instancePath + "/filters/" + i0 + "/defaultActive", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/defaultActive/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }; + if (vErrors === null) { + vErrors = [err42]; + } else { + vErrors.push(err42); + } + errors++; + } + } + } else { + const err43 = { instancePath: instancePath + "/filters/" + i0, schemaPath: "#/$defs/dashboardFilterDefinitionV1/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err43]; + } else { + vErrors.push(err43); + } + errors++; + } + } + } else { + const err44 = { instancePath: instancePath + "/filters", schemaPath: "#/properties/filters/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err44]; + } else { + vErrors.push(err44); + } + errors++; + } + } + if (data.tiles !== void 0) { + let data15 = data.tiles; + if (Array.isArray(data15)) { + if (data15.length > 100) { + const err45 = { instancePath: instancePath + "/tiles", schemaPath: "#/properties/tiles/maxItems", keyword: "maxItems", params: { limit: 100 }, message: "must NOT have more than 100 items" }; + if (vErrors === null) { + vErrors = [err45]; + } else { + vErrors.push(err45); + } + errors++; + } + const len2 = data15.length; + for (let i3 = 0; i3 < len2; i3++) { + if (!validate56(data15[i3], { instancePath: instancePath + "/tiles/" + i3, parentData: data15, parentDataProperty: i3, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate56.errors : vErrors.concat(validate56.errors); + errors = vErrors.length; + } + } + } else { + const err46 = { instancePath: instancePath + "/tiles", schemaPath: "#/properties/tiles/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err46]; + } else { + vErrors.push(err46); + } + errors++; + } + } + } else { + const err47 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err47]; + } else { + vErrors.push(err47); + } + errors++; + } + validate49.errors = vErrors; + return errors === 0; +} +validate49.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validateStoredWorkspaceV1 = validate60; +function validate60(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + ; + let vErrors = null; + let errors = 0; + const evaluated0 = validate60.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.storageVersion === void 0) { + const err0 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "storageVersion" }, message: "must have required property 'storageVersion'" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + if (data.id === void 0) { + const err1 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "id" }, message: "must have required property 'id'" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (data.name === void 0) { + const err2 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "name" }, message: "must have required property 'name'" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + if (data.queries === void 0) { + const err3 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "queries" }, message: "must have required property 'queries'" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + if (data.dashboard === void 0) { + const err4 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "dashboard" }, message: "must have required property 'dashboard'" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + for (const key0 in data) { + if (!(key0 === "storageVersion" || key0 === "id" || key0 === "name" || key0 === "queries" || key0 === "dashboard")) { + const err5 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + } + if (data.storageVersion !== void 0) { + let data0 = data.storageVersion; + if (!(typeof data0 == "number" && (!(data0 % 1) && !isNaN(data0)) && isFinite(data0))) { + const err6 = { instancePath: instancePath + "/storageVersion", schemaPath: "#/properties/storageVersion/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + if (1 !== data0) { + const err7 = { instancePath: instancePath + "/storageVersion", schemaPath: "#/properties/storageVersion/const", keyword: "const", params: { allowedValue: 1 }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + } + if (data.id !== void 0) { + let data1 = data.id; + if (typeof data1 === "string") { + if (func1(data1) > 256) { + const err8 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + if (func1(data1) < 1) { + const err9 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + if (!pattern4.test(data1)) { + const err10 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/pattern", keyword: "pattern", params: { pattern: "\\S" }, message: 'must match pattern "\\S"' }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + } else { + const err11 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + } + if (data.name !== void 0) { + let data2 = data.name; + if (typeof data2 === "string") { + if (func1(data2) > 512) { + const err12 = { instancePath: instancePath + "/name", schemaPath: "#/properties/name/maxLength", keyword: "maxLength", params: { limit: 512 }, message: "must NOT have more than 512 characters" }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + } else { + const err13 = { instancePath: instancePath + "/name", schemaPath: "#/properties/name/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + } + if (data.queries !== void 0) { + let data3 = data.queries; + if (Array.isArray(data3)) { + if (data3.length > 1e3) { + const err14 = { instancePath: instancePath + "/queries", schemaPath: "#/properties/queries/maxItems", keyword: "maxItems", params: { limit: 1e3 }, message: "must NOT have more than 1000 items" }; + if (vErrors === null) { + vErrors = [err14]; + } else { + vErrors.push(err14); + } + errors++; + } + const len0 = data3.length; + for (let i0 = 0; i0 < len0; i0++) { + if (!validate42(data3[i0], { instancePath: instancePath + "/queries/" + i0, parentData: data3, parentDataProperty: i0, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate42.errors : vErrors.concat(validate42.errors); + errors = vErrors.length; + } + } + } else { + const err15 = { instancePath: instancePath + "/queries", schemaPath: "#/properties/queries/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + } + if (data.dashboard !== void 0) { + let data5 = data.dashboard; + const _errs12 = errors; + let valid3 = false; + let passing0 = null; + const _errs13 = errors; + if (!validate49(data5, { instancePath: instancePath + "/dashboard", parentData: data, parentDataProperty: "dashboard", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate49.errors : vErrors.concat(validate49.errors); + errors = vErrors.length; + } + var _valid0 = _errs13 === errors; + if (_valid0) { + valid3 = true; + passing0 = 0; + } + const _errs14 = errors; + if (data5 !== null) { + const err16 = { instancePath: instancePath + "/dashboard", schemaPath: "#/properties/dashboard/oneOf/1/type", keyword: "type", params: { type: "null" }, message: "must be null" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } + var _valid0 = _errs14 === errors; + if (_valid0 && valid3) { + valid3 = false; + passing0 = [passing0, 1]; + } else { + if (_valid0) { + valid3 = true; + passing0 = 1; + } + } + if (!valid3) { + const err17 = { instancePath: instancePath + "/dashboard", schemaPath: "#/properties/dashboard/oneOf", keyword: "oneOf", params: { passingSchemas: passing0 }, message: "must match exactly one schema in oneOf" }; + if (vErrors === null) { + vErrors = [err17]; + } else { + vErrors.push(err17); + } + errors++; + } else { + errors = _errs12; + if (vErrors !== null) { + if (_errs12) { + vErrors.length = _errs12; + } else { + vErrors = null; + } + } + } + } + } else { + const err18 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err18]; + } else { + vErrors.push(err18); + } + errors++; + } + validate60.errors = vErrors; + return errors === 0; +} +validate60.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validatePortableBundleV1 = validate63; +function validate63(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + ; + let vErrors = null; + let errors = 0; + const evaluated0 = validate63.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.format === void 0) { + const err0 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "format" }, message: "must have required property 'format'" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + if (data.version === void 0) { + const err1 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "version" }, message: "must have required property 'version'" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (data.exportedAt === void 0) { + const err2 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "exportedAt" }, message: "must have required property 'exportedAt'" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + if (data.queries === void 0) { + const err3 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "queries" }, message: "must have required property 'queries'" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + if (data.dashboards === void 0) { + const err4 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "dashboards" }, message: "must have required property 'dashboards'" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + for (const key0 in data) { + if (!(key0 === "$schema" || key0 === "format" || key0 === "version" || key0 === "exportedAt" || key0 === "metadata" || key0 === "queries" || key0 === "dashboards")) { + const err5 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + } + if (data.$schema !== void 0) { + if ("https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json" !== data.$schema) { + const err6 = { instancePath: instancePath + "/$schema", schemaPath: "#/properties/%24schema/const", keyword: "const", params: { allowedValue: "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + } + if (data.format !== void 0) { + if ("altinity-sql-browser/portable-bundle" !== data.format) { + const err7 = { instancePath: instancePath + "/format", schemaPath: "#/properties/format/const", keyword: "const", params: { allowedValue: "altinity-sql-browser/portable-bundle" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + } + if (data.version !== void 0) { + let data2 = data.version; + if (!(typeof data2 == "number" && (!(data2 % 1) && !isNaN(data2)) && isFinite(data2))) { + const err8 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + if (1 !== data2) { + const err9 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/const", keyword: "const", params: { allowedValue: 1 }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + } + if (data.exportedAt !== void 0) { + let data3 = data.exportedAt; + if (typeof data3 === "string") { + if (!formats0.validate(data3)) { + const err10 = { instancePath: instancePath + "/exportedAt", schemaPath: "#/properties/exportedAt/format", keyword: "format", params: { format: "date-time" }, message: 'must match format "date-time"' }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + } else { + const err11 = { instancePath: instancePath + "/exportedAt", schemaPath: "#/properties/exportedAt/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + } + if (data.metadata !== void 0) { + let data4 = data.metadata; + if (data4 && typeof data4 == "object" && !Array.isArray(data4)) { + for (const key1 in data4) { + if (!(key1 === "name" || key1 === "description")) { + const err12 = { instancePath: instancePath + "/metadata", schemaPath: "#/properties/metadata/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key1 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + } + if (data4.name !== void 0) { + let data5 = data4.name; + if (typeof data5 === "string") { + if (func1(data5) > 512) { + const err13 = { instancePath: instancePath + "/metadata/name", schemaPath: "#/properties/metadata/properties/name/maxLength", keyword: "maxLength", params: { limit: 512 }, message: "must NOT have more than 512 characters" }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + } else { + const err14 = { instancePath: instancePath + "/metadata/name", schemaPath: "#/properties/metadata/properties/name/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err14]; + } else { + vErrors.push(err14); + } + errors++; + } + } + if (data4.description !== void 0) { + let data6 = data4.description; + if (typeof data6 === "string") { + if (func1(data6) > 16384) { + const err15 = { instancePath: instancePath + "/metadata/description", schemaPath: "#/properties/metadata/properties/description/maxLength", keyword: "maxLength", params: { limit: 16384 }, message: "must NOT have more than 16384 characters" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + } else { + const err16 = { instancePath: instancePath + "/metadata/description", schemaPath: "#/properties/metadata/properties/description/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } + } + } else { + const err17 = { instancePath: instancePath + "/metadata", schemaPath: "#/properties/metadata/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err17]; + } else { + vErrors.push(err17); + } + errors++; + } + } + if (data.queries !== void 0) { + let data7 = data.queries; + if (Array.isArray(data7)) { + if (data7.length > 1e3) { + const err18 = { instancePath: instancePath + "/queries", schemaPath: "#/properties/queries/maxItems", keyword: "maxItems", params: { limit: 1e3 }, message: "must NOT have more than 1000 items" }; + if (vErrors === null) { + vErrors = [err18]; + } else { + vErrors.push(err18); + } + errors++; + } + const len0 = data7.length; + for (let i0 = 0; i0 < len0; i0++) { + if (!validate42(data7[i0], { instancePath: instancePath + "/queries/" + i0, parentData: data7, parentDataProperty: i0, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate42.errors : vErrors.concat(validate42.errors); + errors = vErrors.length; + } + } + } else { + const err19 = { instancePath: instancePath + "/queries", schemaPath: "#/properties/queries/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err19]; + } else { + vErrors.push(err19); + } + errors++; + } + } + if (data.dashboards !== void 0) { + let data9 = data.dashboards; + if (Array.isArray(data9)) { + if (data9.length > 32) { + const err20 = { instancePath: instancePath + "/dashboards", schemaPath: "#/properties/dashboards/maxItems", keyword: "maxItems", params: { limit: 32 }, message: "must NOT have more than 32 items" }; + if (vErrors === null) { + vErrors = [err20]; + } else { + vErrors.push(err20); + } + errors++; + } + const len1 = data9.length; + for (let i1 = 0; i1 < len1; i1++) { + if (!validate49(data9[i1], { instancePath: instancePath + "/dashboards/" + i1, parentData: data9, parentDataProperty: i1, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate49.errors : vErrors.concat(validate49.errors); + errors = vErrors.length; + } + } + } else { + const err21 = { instancePath: instancePath + "/dashboards", schemaPath: "#/properties/dashboards/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err21]; + } else { + vErrors.push(err21); + } + errors++; + } + } + } else { + const err22 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err22]; + } else { + vErrors.push(err22); + } + errors++; + } + validate63.errors = vErrors; + return errors === 0; +} +validate63.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; export { + validateDashboardV1, + validateFlowLayoutV1, validateLibraryV2, + validatePortableBundleV1, validateQuerySpecV1, - validateSavedQueryV2 + validateSavedQueryV2, + validateStoredWorkspaceV1 }; export const validatorsById = { "https://altinity.com/schemas/altinity-sql-browser/query-spec-v1.schema.json": validateQuerySpecV1, "https://altinity.com/schemas/altinity-sql-browser/saved-query-v2.schema.json": validateSavedQueryV2, "https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json": validateLibraryV2, + "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json": validateFlowLayoutV1, + "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json": validateDashboardV1, + "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json": validateStoredWorkspaceV1, + "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json": validatePortableBundleV1, }; diff --git a/src/generated/json-schema.types.ts b/src/generated/json-schema.types.ts index ce5c0900..742b920a 100644 --- a/src/generated/json-schema.types.ts +++ b/src/generated/json-schema.types.ts @@ -122,18 +122,65 @@ export interface FieldConfig { [k: string]: unknown; } +/** + * Presentation patch + * + * RFC 7396 JSON Merge Patch applied over the saved query's base panel object. Object members merge recursively, arrays replace the previous array completely, and a null member deletes an optional property. A patch may not change panel.cfg.type, and the final resolved panel must validate like a normal saved-query panel. + */ +export type QueryPresentationPatchV1 = Record; + +/** + * Size hints + * + * Layout-neutral tile size hints. Authoring may derive an initial placement from them; they never own actual Dashboard placement. + */ +export interface DashboardSizeHints { + /** + * Preferred size + * + * Preferred layout-neutral tile size. + */ + preferred?: "compact" | "medium" | "wide"; + /** + * Minimum size + * + * Smallest layout-neutral tile size that still renders usefully. + */ + minimum?: "compact" | "medium" | "wide"; + /** + * Aspect ratio + * + * Preferred width/height ratio hint. + */ + aspectRatio?: number; + [k: string]: unknown; +} + /** * Dashboard configuration * - * Dashboard participation metadata. Feature-specific extensions remain forward compatible. + * Reusable Dashboard presentation for this query: role, named presentation variants, and layout-neutral size hints. Actual Dashboard sequence, placement, and tile-local overrides live in the Dashboard document. Feature-specific extensions remain forward compatible. */ -export interface Dashboard { +export interface QueryDashboardPresentationV1 { /** * Dashboard role (Panel, Filter, or Setup) * * How the saved query participates in a dashboard. panel (the default) creates a visualization tile. filter returns exactly one row whose supported top-level Array, named Tuple Array, or Map columns provide option lists for parameters with the same exact names; it creates no tile and its SQL cannot declare parameters. setup is reserved for serialized Dashboard setup execution. */ role?: "panel" | "filter" | "setup"; + /** + * Default presentation variant + * + * Variant applied when a tile does not select one. Must name an existing entry in variants. + */ + defaultVariant?: string; + /** + * Presentation variants + * + * Named reusable presentation patches, each an RFC 7396 JSON Merge Patch over the base panel. + */ + variants?: Record; + sizeHints?: DashboardSizeHints; [k: string]: unknown; } @@ -510,7 +557,7 @@ export interface QuerySpecV1 { */ view?: "table" | "json" | "panel"; panel?: Panel; - dashboard?: Dashboard; + dashboard?: QueryDashboardPresentationV1; [k: string]: unknown; } @@ -575,3 +622,346 @@ export interface LibraryV2 { /** Saved queries */ queries: SavedQueryV2[]; } + +// dashboard-layout-flow v1 — https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json + +/** + * Flow preset + * + * Desktop column arrangement: full-width and report render one column (report centers a constrained-width column), columns-2 and columns-3 render equal columns. + */ +export type FlowPresetV1 = "full-width" | "report" | "columns-2" | "columns-3"; + +/** + * Tile height + * + * Normative height ordering is compact < medium < large; exact pixels are renderer-defined. + */ +export type FlowHeightV1 = "compact" | "medium" | "large"; + +/** + * Tile placement + * + * Closed placement contract: unknown fields fail validation. Future extension requires flow@2 or an explicit extension namespace. + */ +export interface FlowTilePlacementV1 { + /** + * Column span + * + * Columns the tile occupies; the effective span is clamped to the active column count. + */ + span?: 1 | 2 | 3; + height?: FlowHeightV1; +} + +/** + * Altinity SQL Browser Dashboard flow@1 layout + * + * The normative flow@1 Dashboard layout: deterministic row-major packing driven by dashboard.tiles order, one preset, and per-tile span/height placements keyed by tile ID. This document is also the only valid DashboardLayoutFallbackV1 shape. + */ +export interface FlowLayoutV1 { + /** + * Layout engine + * + * Layout engine identifier; always flow for this contract. + */ + type: "flow"; + /** + * Layout engine version + * + * flow contract version; always 1 for this contract. + */ + version: 1; + preset: FlowPresetV1; + /** + * Tile placements + * + * Per-tile placement keyed by tile ID. A missing placement uses span 1 and medium height. + */ + items: Record; +} + +// dashboard v1 — https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json + +/** + * Tile presentation selection + * + * Selected reusable variant and optional small tile-local override patch, both resolved over the saved query's base panel. + */ +export interface DashboardTilePresentationV1 { + /** + * Selected variant + * + * Name of a variant declared on the tile's saved query. A persisted name that no longer exists fails validation; there is no silent fallback. + */ + variant?: string; + override?: QueryPresentationPatchV1; +} + +/** + * Dashboard tile + * + * One query instance placed on this Dashboard: stable instance identity, query reference, selected presentation, and optional local title/description. Actual placement and size live in the layout document. + */ +export interface DashboardTileV1 { + /** + * Tile identifier + * + * Stable tile instance identity within this Dashboard. + */ + id: string; + /** + * Saved-query reference + * + * ID of the saved query this tile renders. The query must exist and have Dashboard role panel. + */ + queryId: string; + /** + * Tile title override + * + * Optional Dashboard-local title override. + */ + title?: string; + /** + * Tile description override + * + * Optional Dashboard-local description override. + */ + description?: string; + presentation?: DashboardTilePresentationV1; +} + +/** + * Dashboard filter definition + * + * One Dashboard filter: the targeted parameter name, an optional filter-role source query providing options, and optional explicit target tiles. Runtime filter values are never persisted here. + */ +export interface DashboardFilterDefinitionV1 { + /** + * Filter identifier + * + * Stable filter identity within this Dashboard. + */ + id: string; + /** + * Parameter name + * + * ClickHouse query parameter name this filter supplies. Target queries must declare the parameter with compatible types. + */ + parameter: string; + /** + * Filter label + * + * Optional user-visible filter label. + */ + label?: string; + /** + * Option source query + * + * ID of a filter-role saved query whose result provides the option list. The source query never creates a tile. + */ + sourceQueryId?: string; + /** + * Target tiles + * + * Tile IDs this filter applies to. Absent means every compatible panel tile. + */ + targets?: string[]; + /** + * Default value + * + * Optional default parameter value; any JSON value. + */ + defaultValue?: unknown; + /** + * Active by default + * + * Whether the filter starts active. + */ + defaultActive?: boolean; +} + +/** + * Layout fallback + * + * A complete valid flow@1 layout rendered when the primary layout engine cannot load. Fallback placement keys resolve against the same dashboard.tiles. + */ +export type DashboardLayoutFallbackV1 = FlowLayoutV1; + +/** + * Dashboard layout document + * + * Versioned, engine-specific placement document. An unsupported type/version must carry a valid flow@1 fallback or the Dashboard fails before execution. + */ +export interface DashboardLayoutDocumentV1 { + /** + * Layout engine + * + * Layout engine identifier, e.g. flow. + */ + type: string; + /** + * Layout engine version + * + * Layout engine contract version. + */ + version: number; + /** + * Layout preset + * + * Engine-specific preset identifier. + */ + preset?: string; + /** + * Engine configuration + * + * Open engine-specific configuration object; recursively key-sorted by the canonical encoder. + */ + config?: Record; + /** + * Placement entries + * + * Engine-specific placement objects keyed by tile ID. Every key must reference an existing tile and the entry count must not exceed the tile count. + */ + items?: Record>; + fallback?: DashboardLayoutFallbackV1; +} + +/** + * Altinity SQL Browser Dashboard document v1 + * + * One explicit Dashboard aggregate: tile instances, semantic tile order, layout, filter definitions, and persistence revision. Runtime values and result caches are never persisted here. Unknown future documentVersion values fail closed. + */ +export interface DashboardDocumentV1 { + /** + * Dashboard document version + * + * Dashboard document contract version; always 1 for this contract. + */ + documentVersion: 1; + /** + * Dashboard identifier + * + * Stable application-managed Dashboard identity. + */ + id: string; + /** + * Dashboard title + * + * User-visible Dashboard title. + */ + title: string; + /** + * Dashboard description + * + * Optional authoring note shown with the Dashboard. + */ + description?: string; + /** + * Persistence revision + * + * Incremented once for each successfully committed Dashboard document mutation. Validation, preview, and export never increment it. Starts at 1. + */ + revision: number; + layout: DashboardLayoutDocumentV1; + /** + * Filter definitions + * + * Dashboard filter definitions in filter order. Required even when empty. + */ + filters: DashboardFilterDefinitionV1[]; + /** + * Tiles + * + * Tile instances in canonical semantic order: execution planning, DOM and keyboard traversal, fallback rendering, print/export, and serialization all follow this order. Required even when empty. + */ + tiles: DashboardTileV1[]; +} + +// stored-workspace v1 — https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json + +/** + * Altinity SQL Browser stored workspace v1 + * + * The atomic browser-persistence aggregate: one workspace with an ordered saved-query collection and zero or one Dashboard. Internal persistence contract; portable interchange uses portable-bundle-v1 instead. + */ +export interface StoredWorkspaceV1 { + /** + * Storage version + * + * Stored-workspace contract version; always 1 for this contract. Unknown future versions fail closed. + */ + storageVersion: 1; + /** + * Workspace identifier + * + * Stable generated workspace identity. Two files with the same name still produce distinct workspace IDs. + */ + id: string; + /** + * Workspace name + * + * User-visible workspace name. Renaming the workspace does not rename its Dashboard. + */ + name: string; + /** + * Saved queries + * + * The ordered saved-query collection in catalog/authoring order, independent of Dashboard tile order. Required even when empty. + */ + queries: SavedQueryV2[]; + /** + * Dashboard + * + * The zero-or-one editable Dashboard of this workspace; null when none exists. + */ + dashboard: DashboardDocumentV1 | null; +} + +// portable-bundle v1 — https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json + +/** + * Altinity SQL Browser portable bundle v1 + * + * The one canonical portable interchange format: saved queries plus zero or more Dashboard documents. All newly written importable/exportable JSON uses this format; legacy Library v1/v2 files remain readable through compatibility decoders. + */ +export interface PortableBundleV1 { + /** + * Schema identifier + * + * Optional schema hint for editors, agents, and third-party tools. + */ + $schema?: "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json"; + /** Format identifier */ + format: "altinity-sql-browser/portable-bundle"; + /** + * Bundle format version + * + * Portable bundle contract version; always 1 for this contract. Unknown future versions fail closed. + */ + version: 1; + /** + * Export timestamp + * + * RFC 3339 timestamp indicating when this portable bundle was created. + */ + exportedAt: string; + /** + * Bundle metadata + * + * Optional human-readable bundle name and description. + */ + metadata?: { name?: string; description?: string; }; + /** + * Saved queries + * + * Every query referenced by the bundled dashboards plus any standalone queries; each query appears exactly once. Required even when empty. + */ + queries: SavedQueryV2[]; + /** + * Dashboard documents + * + * Bundled Dashboard documents. The v1 Workbench manages at most one Dashboard; multi-dashboard bundles are import-resolution input for tooling and forward compatibility. Required even when empty. + */ + dashboards: DashboardDocumentV1[]; +} diff --git a/src/generated/json-schemas.js b/src/generated/json-schemas.js index 6d8cd804..a6409390 100644 --- a/src/generated/json-schemas.js +++ b/src/generated/json-schemas.js @@ -44,7 +44,7 @@ export const querySpecV1Schema = { "$ref": "#/$defs/panel" }, "dashboard": { - "$ref": "#/$defs/dashboard" + "$ref": "#/$defs/queryDashboardPresentationV1" } }, "additionalProperties": true, @@ -215,9 +215,54 @@ export const querySpecV1Schema = { "columns" ] }, - "dashboard": { + "queryPresentationPatchV1": { + "title": "Presentation patch", + "description": "RFC 7396 JSON Merge Patch applied over the saved query's base panel object. Object members merge recursively, arrays replace the previous array completely, and a null member deletes an optional property. A patch may not change panel.cfg.type, and the final resolved panel must validate like a normal saved-query panel.", + "type": "object", + "additionalProperties": true + }, + "dashboardSizeHints": { + "title": "Size hints", + "description": "Layout-neutral tile size hints. Authoring may derive an initial placement from them; they never own actual Dashboard placement.", + "type": "object", + "properties": { + "preferred": { + "title": "Preferred size", + "description": "Preferred layout-neutral tile size.", + "type": "string", + "enum": [ + "compact", + "medium", + "wide" + ] + }, + "minimum": { + "title": "Minimum size", + "description": "Smallest layout-neutral tile size that still renders usefully.", + "type": "string", + "enum": [ + "compact", + "medium", + "wide" + ] + }, + "aspectRatio": { + "title": "Aspect ratio", + "description": "Preferred width/height ratio hint.", + "type": "number", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": true, + "x-altinity-order": [ + "preferred", + "minimum", + "aspectRatio" + ] + }, + "queryDashboardPresentationV1": { "title": "Dashboard configuration", - "description": "Dashboard participation metadata. Feature-specific extensions remain forward compatible.", + "description": "Reusable Dashboard presentation for this query: role, named presentation variants, and layout-neutral size hints. Actual Dashboard sequence, placement, and tile-local overrides live in the Dashboard document. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { @@ -233,11 +278,37 @@ export const querySpecV1Schema = { "examples": [ "filter" ] + }, + "defaultVariant": { + "title": "Default presentation variant", + "description": "Variant applied when a tile does not select one. Must name an existing entry in variants.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "variants": { + "title": "Presentation variants", + "description": "Named reusable presentation patches, each an RFC 7396 JSON Merge Patch over the base panel.", + "type": "object", + "maxProperties": 32, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "$ref": "#/$defs/queryPresentationPatchV1" + } + }, + "sizeHints": { + "$ref": "#/$defs/dashboardSizeHints" } }, "additionalProperties": true, "x-altinity-order": [ - "role" + "role", + "defaultVariant", + "variants", + "sizeHints" ] }, "panel": { @@ -1126,8 +1197,565 @@ export const libraryV2Schema = { "additionalProperties": false }; +export const flowLayoutV1Schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json", + "title": "Altinity SQL Browser Dashboard flow@1 layout", + "description": "The normative flow@1 Dashboard layout: deterministic row-major packing driven by dashboard.tiles order, one preset, and per-tile span/height placements keyed by tile ID. This document is also the only valid DashboardLayoutFallbackV1 shape.", + "x-altinity-kind": "dashboard-layout-flow", + "x-altinity-version": 1, + "type": "object", + "required": [ + "type", + "version", + "preset", + "items" + ], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier; always flow for this contract.", + "type": "string", + "const": "flow" + }, + "version": { + "title": "Layout engine version", + "description": "flow contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "preset": { + "$ref": "#/$defs/flowPresetV1" + }, + "items": { + "title": "Tile placements", + "description": "Per-tile placement keyed by tile ID. A missing placement uses span 1 and medium height.", + "type": "object", + "maxProperties": 100, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "$ref": "#/$defs/flowTilePlacementV1" + } + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "type", + "version", + "preset", + "items" + ], + "$defs": { + "flowPresetV1": { + "title": "Flow preset", + "description": "Desktop column arrangement: full-width and report render one column (report centers a constrained-width column), columns-2 and columns-3 render equal columns.", + "type": "string", + "enum": [ + "full-width", + "report", + "columns-2", + "columns-3" + ] + }, + "flowHeightV1": { + "title": "Tile height", + "description": "Normative height ordering is compact < medium < large; exact pixels are renderer-defined.", + "type": "string", + "enum": [ + "compact", + "medium", + "large" + ] + }, + "flowTilePlacementV1": { + "title": "Tile placement", + "description": "Closed placement contract: unknown fields fail validation. Future extension requires flow@2 or an explicit extension namespace.", + "type": "object", + "properties": { + "span": { + "title": "Column span", + "description": "Columns the tile occupies; the effective span is clamped to the active column count.", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "height": { + "$ref": "#/$defs/flowHeightV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "span", + "height" + ] + } + } +}; + +export const dashboardV1Schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json", + "title": "Altinity SQL Browser Dashboard document v1", + "description": "One explicit Dashboard aggregate: tile instances, semantic tile order, layout, filter definitions, and persistence revision. Runtime values and result caches are never persisted here. Unknown future documentVersion values fail closed.", + "x-altinity-kind": "dashboard", + "x-altinity-version": 1, + "type": "object", + "required": [ + "documentVersion", + "id", + "title", + "revision", + "layout", + "filters", + "tiles" + ], + "properties": { + "documentVersion": { + "title": "Dashboard document version", + "description": "Dashboard document contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "id": { + "title": "Dashboard identifier", + "description": "Stable application-managed Dashboard identity.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "title": { + "title": "Dashboard title", + "description": "User-visible Dashboard title.", + "type": "string", + "maxLength": 512 + }, + "description": { + "title": "Dashboard description", + "description": "Optional authoring note shown with the Dashboard.", + "type": "string", + "maxLength": 16384 + }, + "revision": { + "title": "Persistence revision", + "description": "Incremented once for each successfully committed Dashboard document mutation. Validation, preview, and export never increment it. Starts at 1.", + "type": "integer", + "minimum": 1 + }, + "layout": { + "$ref": "#/$defs/dashboardLayoutDocumentV1" + }, + "filters": { + "title": "Filter definitions", + "description": "Dashboard filter definitions in filter order. Required even when empty.", + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/$defs/dashboardFilterDefinitionV1" + } + }, + "tiles": { + "title": "Tiles", + "description": "Tile instances in canonical semantic order: execution planning, DOM and keyboard traversal, fallback rendering, print/export, and serialization all follow this order. Required even when empty.", + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/$defs/dashboardTileV1" + } + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "documentVersion", + "id", + "title", + "description", + "revision", + "layout", + "filters", + "tiles" + ], + "$defs": { + "dashboardTilePresentationV1": { + "title": "Tile presentation selection", + "description": "Selected reusable variant and optional small tile-local override patch, both resolved over the saved query's base panel.", + "type": "object", + "properties": { + "variant": { + "title": "Selected variant", + "description": "Name of a variant declared on the tile's saved query. A persisted name that no longer exists fails validation; there is no silent fallback.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "override": { + "$ref": "query-spec-v1.schema.json#/$defs/queryPresentationPatchV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "variant", + "override" + ] + }, + "dashboardTileV1": { + "title": "Dashboard tile", + "description": "One query instance placed on this Dashboard: stable instance identity, query reference, selected presentation, and optional local title/description. Actual placement and size live in the layout document.", + "type": "object", + "required": [ + "id", + "queryId" + ], + "properties": { + "id": { + "title": "Tile identifier", + "description": "Stable tile instance identity within this Dashboard.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "queryId": { + "title": "Saved-query reference", + "description": "ID of the saved query this tile renders. The query must exist and have Dashboard role panel.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "title": { + "title": "Tile title override", + "description": "Optional Dashboard-local title override.", + "type": "string", + "maxLength": 512 + }, + "description": { + "title": "Tile description override", + "description": "Optional Dashboard-local description override.", + "type": "string", + "maxLength": 16384 + }, + "presentation": { + "$ref": "#/$defs/dashboardTilePresentationV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "id", + "queryId", + "title", + "description", + "presentation" + ] + }, + "dashboardFilterDefinitionV1": { + "title": "Dashboard filter definition", + "description": "One Dashboard filter: the targeted parameter name, an optional filter-role source query providing options, and optional explicit target tiles. Runtime filter values are never persisted here.", + "type": "object", + "required": [ + "id", + "parameter" + ], + "properties": { + "id": { + "title": "Filter identifier", + "description": "Stable filter identity within this Dashboard.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "parameter": { + "title": "Parameter name", + "description": "ClickHouse query parameter name this filter supplies. Target queries must declare the parameter with compatible types.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "label": { + "title": "Filter label", + "description": "Optional user-visible filter label.", + "type": "string", + "maxLength": 512 + }, + "sourceQueryId": { + "title": "Option source query", + "description": "ID of a filter-role saved query whose result provides the option list. The source query never creates a tile.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "targets": { + "title": "Target tiles", + "description": "Tile IDs this filter applies to. Absent means every compatible panel tile.", + "type": "array", + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "defaultValue": { + "title": "Default value", + "description": "Optional default parameter value; any JSON value." + }, + "defaultActive": { + "title": "Active by default", + "description": "Whether the filter starts active.", + "type": "boolean" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "id", + "parameter", + "label", + "sourceQueryId", + "targets", + "defaultValue", + "defaultActive" + ] + }, + "dashboardLayoutFallbackV1": { + "title": "Layout fallback", + "description": "A complete valid flow@1 layout rendered when the primary layout engine cannot load. Fallback placement keys resolve against the same dashboard.tiles.", + "$ref": "dashboard-layout-flow-v1.schema.json" + }, + "dashboardLayoutDocumentV1": { + "title": "Dashboard layout document", + "description": "Versioned, engine-specific placement document. An unsupported type/version must carry a valid flow@1 fallback or the Dashboard fails before execution.", + "type": "object", + "required": [ + "type", + "version" + ], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier, e.g. flow.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "version": { + "title": "Layout engine version", + "description": "Layout engine contract version.", + "type": "integer", + "minimum": 1 + }, + "preset": { + "title": "Layout preset", + "description": "Engine-specific preset identifier.", + "type": "string", + "maxLength": 256 + }, + "config": { + "title": "Engine configuration", + "description": "Open engine-specific configuration object; recursively key-sorted by the canonical encoder.", + "type": "object" + }, + "items": { + "title": "Placement entries", + "description": "Engine-specific placement objects keyed by tile ID. Every key must reference an existing tile and the entry count must not exceed the tile count.", + "type": "object", + "maxProperties": 100, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "type": "object" + } + }, + "fallback": { + "$ref": "#/$defs/dashboardLayoutFallbackV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "type", + "version", + "preset", + "config", + "items", + "fallback" + ] + } + } +}; + +export const storedWorkspaceV1Schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json", + "title": "Altinity SQL Browser stored workspace v1", + "description": "The atomic browser-persistence aggregate: one workspace with an ordered saved-query collection and zero or one Dashboard. Internal persistence contract; portable interchange uses portable-bundle-v1 instead.", + "x-altinity-kind": "stored-workspace", + "x-altinity-version": 1, + "type": "object", + "required": [ + "storageVersion", + "id", + "name", + "queries", + "dashboard" + ], + "properties": { + "storageVersion": { + "title": "Storage version", + "description": "Stored-workspace contract version; always 1 for this contract. Unknown future versions fail closed.", + "type": "integer", + "const": 1 + }, + "id": { + "title": "Workspace identifier", + "description": "Stable generated workspace identity. Two files with the same name still produce distinct workspace IDs.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "name": { + "title": "Workspace name", + "description": "User-visible workspace name. Renaming the workspace does not rename its Dashboard.", + "type": "string", + "maxLength": 512 + }, + "queries": { + "title": "Saved queries", + "description": "The ordered saved-query collection in catalog/authoring order, independent of Dashboard tile order. Required even when empty.", + "type": "array", + "maxItems": 1000, + "items": { + "$ref": "saved-query-v2.schema.json" + } + }, + "dashboard": { + "title": "Dashboard", + "description": "The zero-or-one editable Dashboard of this workspace; null when none exists.", + "oneOf": [ + { + "$ref": "dashboard-v1.schema.json" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "storageVersion", + "id", + "name", + "queries", + "dashboard" + ] +}; + +export const portableBundleV1Schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "title": "Altinity SQL Browser portable bundle v1", + "description": "The one canonical portable interchange format: saved queries plus zero or more Dashboard documents. All newly written importable/exportable JSON uses this format; legacy Library v1/v2 files remain readable through compatibility decoders.", + "x-altinity-kind": "portable-bundle", + "x-altinity-version": 1, + "type": "object", + "required": [ + "format", + "version", + "exportedAt", + "queries", + "dashboards" + ], + "properties": { + "$schema": { + "title": "Schema identifier", + "description": "Optional schema hint for editors, agents, and third-party tools.", + "const": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json" + }, + "format": { + "title": "Format identifier", + "const": "altinity-sql-browser/portable-bundle" + }, + "version": { + "title": "Bundle format version", + "description": "Portable bundle contract version; always 1 for this contract. Unknown future versions fail closed.", + "type": "integer", + "const": 1 + }, + "exportedAt": { + "title": "Export timestamp", + "description": "RFC 3339 timestamp indicating when this portable bundle was created.", + "type": "string", + "format": "date-time" + }, + "metadata": { + "title": "Bundle metadata", + "description": "Optional human-readable bundle name and description.", + "type": "object", + "properties": { + "name": { + "title": "Bundle name", + "type": "string", + "maxLength": 512 + }, + "description": { + "title": "Bundle description", + "type": "string", + "maxLength": 16384 + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "name", + "description" + ] + }, + "queries": { + "title": "Saved queries", + "description": "Every query referenced by the bundled dashboards plus any standalone queries; each query appears exactly once. Required even when empty.", + "type": "array", + "maxItems": 1000, + "items": { + "$ref": "saved-query-v2.schema.json" + } + }, + "dashboards": { + "title": "Dashboard documents", + "description": "Bundled Dashboard documents. The v1 Workbench manages at most one Dashboard; multi-dashboard bundles are import-resolution input for tooling and forward compatibility. Required even when empty.", + "type": "array", + "maxItems": 32, + "items": { + "$ref": "dashboard-v1.schema.json" + } + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "$schema", + "format", + "version", + "exportedAt", + "metadata", + "queries", + "dashboards" + ] +}; + export const schemasById = { "https://altinity.com/schemas/altinity-sql-browser/query-spec-v1.schema.json": querySpecV1Schema, "https://altinity.com/schemas/altinity-sql-browser/saved-query-v2.schema.json": savedQueryV2Schema, "https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json": libraryV2Schema, + "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json": flowLayoutV1Schema, + "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json": dashboardV1Schema, + "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json": storedWorkspaceV1Schema, + "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json": portableBundleV1Schema, }; diff --git a/src/styles.css b/src/styles.css index 6cefd52d..16fe1f2c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2183,20 +2183,21 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } } .dash-btn:hover { background: var(--bg-hover); } .dash-btn:disabled { opacity: .55; cursor: default; } -/* Arrange grid: the persisted column count (2 or 3, via `--dash-cols`, default - 3) on wide screens, degrading to 2 then 1 as width shrinks. The four-way - layout switcher lives in the toolbar (#149 D2, #184). */ +/* Arrange grid (#286 flow@1): the container is a vertical STACK of `.dash-row` + wrappers — each row is itself the grid that owns its column count (the active + preset's columns, or 1 on mobile), set inline by the flow renderer. The + container must NOT impose its own `grid-template-columns`, or it would lay the + rows out side by side (regression: full-width showed 3 rows in a line, 3-col + showed 9). The four-way layout switcher lives in the toolbar (#149 D2, #184). */ .dash-grid { - display: grid; gap: 14px; padding: 18px 20px 40px; - grid-template-columns: repeat(var(--dash-cols, 3), minmax(0, 1fr)); + display: flex; flex-direction: column; gap: 14px; padding: 18px 20px 40px; max-width: 1560px; margin: 0 auto; } -@media (max-width: 1100px) { .dash-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } /* Report mode (#149 D2): one centered column (1100px) with taller tiles. The .is-report/.is-wide selectors outweigh the responsive `.dash-grid` rules above (class+class > single class), so both stay one column at every width — consistent with the all-modes-to-one-column narrow fallback. */ -.dash-grid.is-report { grid-template-columns: 1fr; max-width: 1100px; } +.dash-grid.is-report { max-width: 1100px; } .dash-grid.is-report .dash-tile { min-height: 440px; } .dash-grid.is-report .dash-tile.is-kpi, .dash-grid.is-wide .dash-tile.is-kpi { min-height: 0; } @@ -2204,7 +2205,7 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } dashboard content width (inside the grid's existing 20px page gutters), for horizontally expansive Grafana-style panels. Unlike Report it keeps the normal Arrange tile height — it tests width, not document-like height. */ -.dash-grid.is-wide { grid-template-columns: 1fr; max-width: none; width: 100%; margin: 0; } +.dash-grid.is-wide { max-width: none; width: 100%; margin: 0; } /* D1 shows charts read-only: renderChart is called with controls:false so the Type/X/Y config bar isn't built at all (a settings-popover arrives in D6). */ .dash-empty { padding: 60px 20px; text-align: center; color: var(--fg-mute); font-size: 13px; } diff --git a/src/ui/app.ts b/src/ui/app.ts index 3b9091a5..e5041ebf 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -72,6 +72,9 @@ import { createExportService } from '../application/export-service.js'; import type { ExportSink, FileHandleLike, DirectoryHandleLike } from '../application/export-service.js'; import { createSchemaGraphSession, SchemaGraphAuthRequiredError } from '../application/schema-graph-session.js'; import { createAppPreferences } from '../application/app-preferences.js'; +import { createWorkspaceRepository } from '../workspace/workspace-repository.js'; +import { createIndexedDbWorkspaceStore } from '../workspace/indexeddb-workspace-store.js'; +import { migrateLegacyWorkspaceIfNeeded } from '../workspace/legacy-migration.js'; import { createWorkbenchSession } from './workbench/workbench-session.js'; import { createQueryDocumentSession } from '../application/query-document-session.js'; import { createSavedQueryService } from '../application/saved-query-service.js'; @@ -190,6 +193,15 @@ export function createApp(env: CreateAppEnv = {}): App { app.prefs = prefs; app.saveJSON = saveJSON; app.saveStr = saveStr; + // Atomic StoredWorkspaceV1 persistence (#280 Phase 2 / #284): the injected + // IndexedDB factory seam (mirrors crypto/sessionStorage) backs a single-record + // WorkspaceStore, behind which the pure WorkspaceRepository does validate-then- + // atomically-replace commits. Constructed lazily — no database is opened until + // a workspace operation runs — so this never touches IndexedDB during + // bootstrap. The favorites-driven Dashboard render still reads legacy keys in + // this phase; wiring reads onto the aggregate is Phases 3-6 of #280. + const workspaceStore = createIndexedDbWorkspaceStore(env.indexedDB || win.indexedDB); + app.workspace = createWorkspaceRepository({ store: workspaceStore }); // The `{name:Type}` var-value/filter-active/recent-value persistence // wrappers (saveVarValues/saveFilterActive/saveVarRecent/ // saveVarRecentDisabled) + the recent-value policy that sits on top of them @@ -1346,6 +1358,27 @@ export function createApp(env: CreateAppEnv = {}): App { // was retired so cap/settings fixes can't apply to only one path. app.renderDashboard = () => renderDashboard(app); + // #286 Phase 4: resolve the current StoredWorkspaceV1 the Dashboard viewer + // reads from. The one-shot legacy migration (favorites → tiles, + // dashLayout/dashCols → flow@1) runs only when no aggregate exists yet, from + // the flat `asb:*` keys already loaded onto `app.state`; then the aggregate + // is the source of truth. Composition-root wiring — the store lives here, so + // the migration deps are assembled here rather than in the render module. + app.loadDashboardWorkspace = async () => { + await migrateLegacyWorkspaceIfNeeded({ + store: workspaceStore, + repository: app.workspace, + legacy: { + name: app.state.libraryName.value, + queries: app.state.savedQueries, + dashLayout: app.state.dashLayout, + dashCols: app.state.dashCols, + }, + genId: () => uid('ws-'), + }); + return app.workspace.loadCurrent(); + }; + // Open the dashboard in a new tab and stand ready to hand it our credentials // — the cross-tab auth-handoff GRANT side itself is the session's job now // (`conn.grantHandoffTo`, #276 Phase 2); this stays app-side only because diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 44a24611..7c6243fd 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -18,6 +18,8 @@ import type { ConnectionSession, SessionChCtx } from '../application/connection- import type { SchemaCatalogService } from '../application/schema-catalog-service.js'; import type { SchemaGraphSession } from '../application/schema-graph-session.js'; import type { AppPreferences } from '../application/app-preferences.js'; +import type { WorkspaceRepository } from '../workspace/workspace-repository.js'; +import type { StoredWorkspaceV1 } from '../generated/json-schema.types.js'; import type { SavedQueryV2 } from '../generated/json-schema.types.js'; import type { DynamicSources } from '../core/spec-completion.js'; import type { WorkbenchSession } from './workbench/workbench-session.js'; @@ -238,6 +240,12 @@ export interface App { * `App.savePref` delegate); `toggleTheme`'s preference-write half also * delegates here, the DOM half stays in app.ts. */ prefs: AppPreferences; + /** Atomic StoredWorkspaceV1 aggregate persistence (#280 Phase 2 / #284), + * behind the injected IndexedDB seam (`env.indexedDB`). Pure/testable — no + * App/AppState/DOM dependency. In this phase it is constructed but the + * favorites-driven Dashboard render still reads legacy keys; Phases 3-6 of + * #280 route reads/commits through it and retire the legacy keys. */ + workspace: WorkspaceRepository; saveJSON(key: string, value: unknown): void; saveStr(key: string, value: string): void; /** The one deliberate delegate survivor of #276 Phase 5's params-group @@ -353,6 +361,11 @@ export interface App { renderApp(): void; renderDashboard(): void; openDashboard(): void; + /** #286 Phase 4: resolve the current StoredWorkspaceV1 for the Dashboard + * viewer, running the one-shot legacy migration first when no aggregate + * exists. Returns null when neither an aggregate nor a migratable legacy + * workspace is available. */ + loadDashboardWorkspace(): Promise; actions: ActionsRegistry; } diff --git a/src/ui/dashboard-kpi-band.ts b/src/ui/dashboard-kpi-band.ts deleted file mode 100644 index 3ff57334..00000000 --- a/src/ui/dashboard-kpi-band.ts +++ /dev/null @@ -1,234 +0,0 @@ -// The Dashboard KPI band (#240): consecutive explicit `panel.cfg.type==='kpi'` -// favorites render as one full-width, flat card stream instead of each being -// its own gray tile with a nested KPI grid. Isolated from `src/ui/dashboard.js` -// so its own branches (state cards, warning aggregation) get independent test -// coverage rather than inflating that file's already-100%-covered functions. -// -// A band owns `{ el, stream, warningHost, sources }`: `stream` is the flex-wrap -// `.dash-kpi-stream` card row (spans every Dashboard grid column via CSS, -// independent of the selected tile layout); `warningHost` is the band's shared -// `.dash-kpi-warnings` area below it. Each member favorite gets one stable -// `.dash-kpi-source` slot (`display:contents` — its children participate -// directly in the stream's flex-wrap without adding a visible box) appended to -// `stream` in favorite order at band-build time and never reordered; a source's -// own request lifecycle only ever replaces ITS host's children in place -// (loading → success cards | state card), mirroring the ordinary tile slot's -// "never remove/reappend" discipline (dashboard.js's buildTileSlot) so the -// #193 stable-slot-identity/generation/abort guarantees extend unchanged to -// KPI sources. - -import { h } from './dom.js'; -import { Icon } from './icons.js'; -import { resolvePanel } from '../core/panel-cfg.js'; -import type { KpiResult, ResultLike } from '../core/panel-cfg.js'; -import { renderKpiCards as _renderKpiCards, KPI_STREAM_ARIA as _KPI_STREAM_ARIA } from './kpi-panel.js'; -import type { Panel } from '../generated/json-schema.types.js'; -import type { App } from './app.types.js'; - -// This file only ever calls dom.ts's `h()` with a string tag, a plain attrs -// object (class/role/aria-*/style, or `null`), and string/element children — -// the local `HAttrs` alias below just names that shape for its own call sites. -type HAttrs = Record> | null; - -// icons.js's `Icon.spinner()` is unconverted JS built via the same untyped -// `s()` SVG hyperscript — this file only ever appends its returned node as an -// `h()` child, so the wrapper pins the one shape that matters here. -const spinnerIcon = Icon.spinner as () => SVGElement; - -// kpi-panel.js is unconverted JS. `KPI_STREAM_ARIA` is a plain object literal -// (no `any` risk, so a direct annotation suffices); `renderKpiCards` builds -// its `cards` via the same dom.ts `h()` this file imports above, and reads/ -// returns diagnostics shaped exactly like kpi.js's `diagnostic()` -// (severity/code/message/columnName?) — the shape this file's own warning -// aggregation and state-card rendering rely on. -const KPI_STREAM_ARIA: { role: string; 'aria-label': string } = _KPI_STREAM_ARIA; - -/** One diagnostic as kpi.js's `readKpiFields`/kpi-panel.js's `renderKpiCards` - * produce it (severity/code/message, with an optional offending column). */ -export interface KpiDiagnostic { - severity: 'info' | 'warning' | 'error'; - code: string; - message: string; - columnName?: string; -} -const renderKpiCards = _renderKpiCards as (normalized: KpiResult | null | undefined) => { - cards: HTMLElement[]; - warnings: KpiDiagnostic[]; - errors: KpiDiagnostic[]; -}; - -/** One band warning: a source's `KpiDiagnostic` tagged with the source name - * that produced it (`refreshBandWarnings`'s shared rendering needs both). */ -export interface KpiSourceWarning extends KpiDiagnostic { - sourceName: string; -} - -/** One KPI band's DOM handles + member sources, built by `buildKpiBand`. */ -export interface KpiBand { - el: HTMLElement; - stream: HTMLElement; - warningHost: HTMLElement; - sources: KpiSourceSlot[]; -} - -/** One favorite's stable KPI source slot (`buildKpiSourceSlot`) — mirrors the - * ordinary tile slot's stable-identity/generation/abort fields exactly (see - * dashboard.js's `buildTileSlot`) so `runFavoriteSource`'s dispatch works - * unchanged over a KPI source. */ -export interface KpiSourceSlot { - kind: 'kpi-source'; - host: HTMLElement; - band: KpiBand; - name: string; - explicit: Panel; - warnings: KpiSourceWarning[]; - gen: number; - status: 'loading' | 'unfilled' | 'error' | 'panel' | null; - abortController: AbortController | null; - loadLabel: HTMLElement | null; -} - -/** A KPI source's completed-or-errored result, as this band applies it - * (`applyKpiSourceResult`) — the same `{error}` | `{columns, rows}` shape - * every dashboard source's request lifecycle settles into (dashboard.js). */ -interface KpiSourceResult extends Pick { - /** `null` and absent both mean "no error" — the shared fetched-result shape - * carries `error: null` on success (the `!= null` guard below handles both). */ - error?: string | null; -} - -/** One compact white state card (loading/unfilled/error) — the query name - * plus a message, replacing the KPI success cards in a source's stable host - * while it has none to show. `role` drives assistive-tech behavior: `status` - * (+ `aria-live=polite` for loading) or `alert` for errors. */ -function kpiStateCard( - name: string, role: 'status' | 'alert', live: boolean, ...messageChildren: (string | HTMLElement)[] -): HTMLElement { - // Same resulting attrs either way as the original imperative - // `if (live) attrs['aria-live'] = 'polite'` this replaces — spread-in-place - // instead of mutate-after so the literal's type stays exact (no - // after-construction assignment to an optional key). No behavior change. - const attrs: HAttrs = { - class: 'dash-kpi-state-card', role, 'aria-label': name, - ...(live ? { 'aria-live': 'polite' } : {}), - }; - return h('div', attrs, - h('div', { class: 'dash-kpi-state-label' }, name), - h('div', { class: 'dash-kpi-state-message' }, ...messageChildren)); -} - -/** Build one KPI band container: a full-width `.dash-kpi-band` holding the - * card `stream` and a `warningHost` for its shared warning area (rendered - * only while non-empty). `sources` accumulates this band's member slots in - * favorite order, read back by `refreshBandWarnings`. */ -export function buildKpiBand(): KpiBand { - const stream = h('div', { class: 'dash-kpi-stream', ...KPI_STREAM_ARIA }); - const warningHost = h('div', { class: 'dash-kpi-warnings', style: { display: 'none' } }); - const el = h('div', { class: 'dash-kpi-band' }, stream, warningHost); - return { el, stream, warningHost, sources: [] }; -} - -/** Build one favorite's stable KPI source slot, append its host into the - * band's stream (favorite order), and register it on the band for warning - * aggregation. `explicit` (the favorite's saved `cfg.type==='kpi'` panel) is - * cached on the slot once, here, at the same structural build time - * `partitionKpiBands` already establishes eligibility — so a later wave's - * dispatch (dashboard.js's runPlan) reads `slot.explicit` instead of - * re-deriving it every Refresh/filter-affected run. `abortController` mirrors - * buildTileSlot's field exactly, so runFavoriteSource's existing generation- - * guard/abort dispatch works unchanged over a KPI source. */ -export function buildKpiSourceSlot(band: KpiBand, explicit: Panel, name: string): KpiSourceSlot { - const host = h('div', { class: 'dash-kpi-source' }); - const slot: KpiSourceSlot = { - kind: 'kpi-source', host, band, name, explicit, warnings: [], - gen: 0, status: null, abortController: null, loadLabel: null, - }; - band.sources.push(slot); - band.stream.appendChild(host); - return slot; -} - -/** Rebuild a band's shared warning area from every member source's current - * `warnings`, in source order (favorite order, set at band-build time) then - * diagnostic order — a stale rerun replaces it wholesale, never appends. - * Every entry is always `severity:'warning'` (renderKpiCards's `warnings` - * output is pre-filtered to that severity) so the role/class are fixed, - * not diagnostic-driven — a blocking (`error`) diagnostic is a state card, - * never a band warning. */ -export function refreshBandWarnings(band: KpiBand): void { - const all = band.sources.flatMap((slot) => slot.warnings); - band.warningHost.style.display = all.length ? '' : 'none'; - band.warningHost.replaceChildren(...all.map((w) => h('div', { - class: 'dash-kpi-warning', role: 'status', - }, `${w.sourceName}: ${w.message}`))); -} - -/** One compact loading state card, in the source's stable position. Returns - * the live message text node so streamed row progress (onChunk, #193) can - * update just its text, exactly like the ordinary tile's loading label. - * Does NOT itself call `refreshBandWarnings` — a Refresh wave marks every - * affected source loading in one synchronous pass (dashboard.js's runPlan), - * and rebuilding the shared band DOM once per source in that pass would be - * N redundant O(N) rebuilds before the first ever paints; the caller - * refreshes each distinct touched band exactly once after the pass instead. */ -export function setKpiSourceLoading(slot: KpiSourceSlot): HTMLElement { - slot.status = 'loading'; - slot.warnings = []; - const label = h('span', null, 'Loading…'); - slot.loadLabel = label; - const row = h('div', { class: 'dash-kpi-state-loading' }, spinnerIcon(), label); - slot.host.replaceChildren(kpiStateCard(slot.name, 'status', true, row)); - return label; -} - -/** A KPI source blocked on an empty/invalid `{name:Type}` value (#170) — one - * filter value away from rendering, so it stays in its stable position with - * a neutral prompt rather than an error. */ -export function setKpiSourceUnfilled(slot: KpiSourceSlot, names: string[]): void { - slot.status = 'unfilled'; - slot.warnings = []; - slot.host.replaceChildren(kpiStateCard(slot.name, 'status', false, 'Enter a value for: ' + names.join(', '))); - refreshBandWarnings(slot.band); -} - -/** Apply a completed (or errored) result to one KPI source: a transport/SQL - * error or a blocking KPI diagnostic (zero rows, wrong row count, no - * eligible fields) renders as one state card; otherwise the normalized KPI - * cards replace the source's host contents and its warnings feed the band's - * shared area. `explicit` is always a `cfg.type==='kpi'` panel here — band - * membership is gated on exactly that at partition time (core/dashboard.js's - * `partitionKpiBands`), so `resolvePanel`'s kpi branch is unconditional and - * its non-kpi fallback path can't be reached from a KPI source. Streamed row - * progress during the fetch updates `label.textContent` directly (see - * dashboard.js), never re-entering this function mid-stream. */ -export function applyKpiSourceResult(app: App, explicit: Panel, slot: KpiSourceSlot, r: KpiSourceResult): void { - const name = slot.name; - if (r.error != null) { - slot.status = 'error'; - slot.warnings = []; - slot.host.replaceChildren(kpiStateCard(name, 'alert', false, r.error)); - refreshBandWarnings(slot.band); - return; - } - const resolved = resolvePanel(explicit, { - columns: r.columns, rows: r.rows, fieldConfig: explicit.fieldConfig, serverVersion: app.state.serverVersion ?? undefined, - }); - const { cards, warnings, errors } = renderKpiCards(resolved.kpi); - if (errors.length) { - slot.status = 'error'; - slot.warnings = []; - // Every blocking diagnostic stacks as its own line in the ONE state card - // (never dropped) — the workbench's renderKpiPanel renders the same - // `errors` list in full (kpi-panel.js), so the two surfaces show - // identical diagnostic detail for identical data. - const role = errors.some((d) => d.severity === 'error') ? 'alert' : 'status'; - const lines = errors.map((d) => h('div', null, d.message)); - slot.host.replaceChildren(kpiStateCard(name, role, false, ...lines)); - refreshBandWarnings(slot.band); - return; - } - slot.status = 'panel'; - slot.warnings = warnings.map((w) => ({ ...w, sourceName: name })); - slot.host.replaceChildren(...cards); - refreshBandWarnings(slot.band); -} diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 89655310..23775f0a 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -1,79 +1,69 @@ -// The standalone read-only Dashboard page (#149 D1–D3, #166). Render module -// over the `app` controller: it builds a header + a grid of tiles, one per -// favorited Library query (a snapshot taken when the tab opens — Refresh -// re-runs the data, it does not re-scan the Library). Favorites are -// PARTITIONED BEFORE EXECUTION (#166): a text panel renders immediately with -// zero queries; everything else streams its SQL read-only through the shared -// `app.exec.executeRead` seam (#193/#276 — full streaming transport, server-side row cap, -// bounded client memory, and real per-tile AbortController cancellation, the -// same path the workbench run() and the detached Data view use) and renders -// through the shared panel registry (panels.js) — an explicit saved -// `panel` wins (and never vanishes: zero-row explicit panels show an honest -// "0 rows" state), an unconfigured result goes through the autoPanel -// heuristic; eligible one-row results become KPI tiles and only unconfigured -// empty results are skipped and counted in a header note. A global filter bar drives -// 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). +// The read-only Dashboard page (#149 / #240 / #280 / #286). Phase 4 of #280 +// FLIPS Dashboard membership reads off `spec.favorite` and onto +// `dashboard.tiles[]`: this module resolves the current `StoredWorkspaceV1` +// (via `app.loadDashboardWorkspace()` — the phase-2 repository, migrating the +// legacy favorites/layout keys once when no aggregate exists), constructs a +// standalone `DashboardViewerSession` over that document + the workspace +// queries, and renders the DOM from the session's `state` signal. The heavy +// runtime — presentation resolution, the filter/tile execution waves (with +// #235 parallelism), bounded concurrency, per-tile cancellation, and the +// normative `flow@1` layout math — all live in the session and its pure +// dependencies; this module is the render/interaction shell over them. // -// #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/**`). +// The filter bar is the SHARED `buildFilterBar` (the same rich field family the +// Workbench var-strip and detached view use — relative-time presets, recents, +// enum + curated comboboxes), driven over the viewer's filter model: a draft +// value/active bag the bar mutates, `session.getFilterField` for live #170 +// validation, and `session.applyFilter` on commit (which owns activation). +// Recents come from the real app (a cross-surface concern) through the shim — +// the viewer never touches global AppState (check-boundaries keeps it that way). +// +// Tile reordering is pointer DRAG ONLY (owner override, #286 final scope — the +// per-tile keyboard Move controls and the in-tile span/height buttons were +// removed; span/height are tuned in the Spec editor). A drag persists the new +// `dashboard.tiles[]` order through the `move-tile` authoring command. The +// layout preset switcher drives `change-layout`. The `spec.favorite` dual-WRITE +// stays until GA (the Workbench star); only the READ is flipped here. +// +// check-boundaries.mjs keeps this file off `src/ui/app.ts`; everything it needs +// is injected on the `app` controller. +import { effect } from '@preact/signals-core'; import { h } from './dom.js'; import { Icon as IconUntyped } from './icons.js'; import { renderResolvedPanel } from './panels.js'; -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_TABLE_DISPLAY_CAP, - activeDashboardView, dashboardViewSelection, partitionKpiBands, -} from '../core/dashboard.js'; +import { DASH_TILE_ROW_CAP, DASH_TABLE_DISPLAY_CAP } from '../core/dashboard.js'; import { formatBytes as formatBytesUntyped, formatRows as formatRowsUntyped, } from '../core/format.js'; -import { queryDescription, queryFavorite, queryName, queryPanel } from '../core/saved-query.js'; -import { explicitPanel, isKpiPanel } from '../core/panel-execution.js'; -import { effectiveDashboardRole } from '../core/result-choice.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 { createDashboardSession } from './dashboard/dashboard-session.js'; +import { analyzeParameterizedSources, fieldControls } from '../core/param-pipeline.js'; +import type { ValidationMode } from '../core/param-pipeline.js'; +import { queryDashboardRole } from '../dashboard/model/workspace-semantics.js'; +import { renderKpiCards, KPI_STREAM_ARIA } from './kpi-panel.js'; +import { buildFilterBar, filterClearAllButton, filterActiveCount } from './filter-bar.js'; +import type { FilterBarApp } from './filter-bar.js'; +import { createDashboardViewerSession } from '../dashboard/application/dashboard-viewer-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'; + DashboardViewerSession, DashboardViewState, ViewerTileState, ViewerFilterState, +} from '../dashboard/application/dashboard-viewer-session.js'; +import { defaultLayoutRegistry } from '../dashboard/layouts/layout-registry.js'; +import { flowLayoutPlugin } from '../dashboard/layouts/flow-layout.js'; +import { applyCommand } from '../dashboard/application/dashboard-commands.js'; +import { createQueryResolver } from '../dashboard/application/dashboard-query-resolver.js'; +import type { + DashboardDocumentV1, DashboardFilterDefinitionV1, FlowPresetV1, SavedQueryV2, StoredWorkspaceV1, +} from '../generated/json-schema.types.js'; import type { App, AppDom } from './app.types.js'; import type { AppState } from '../state.js'; import type { ConnectionSession } from '../application/connection-session.js'; -import type { AppPreferences } from '../application/app-preferences.js'; import type { QueryExecutionService } from '../application/query-execution-service.js'; import type { WorkbenchParameterSession } from '../application/workbench-parameter-session.js'; +import type { WorkspaceRepository } from '../workspace/workspace-repository.js'; -// ── Typed wrappers over still-untyped .js dependencies ────────────────────── -// Each const/overload pins exactly the signature this module relies on, -// verified against the wrapped function body; the runtime module stays `.js` -// until its own leaf-up conversion (ADR-0002) — same convention as panels.ts / -// state.ts / core/panel-execution.ts. (dom.ts and diagnostics.ts are already -// typed — see their own `h` overload -// and `diagnostic()` factory, imported directly above.) - -// icons.js is unconverted JS built on the untyped `s()` SVG hyperscript — -// this module only ever appends the returned nodes as h() children or via -// appendChild, so the six icons it uses are pinned to that one honest shape. +// icons.js is unconverted — the six icons this module appends, pinned to the +// one honest shape (same wrapper the pre-#286 module used). const Icon: { star(filled?: boolean): SVGElement; spinner(): SVGElement; @@ -83,52 +73,40 @@ const Icon: { arrow(): SVGElement; } = IconUntyped; -// format.js is unconverted — formatRows/formatBytes render '—' for -// null/NaN and compact human-readable text otherwise. 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 }, -) => { el: HTMLElement; dispose(): void } = buildFilterBarUntyped; +/** The narrow `app` surface this render module reads (not the full App — + * matches the convention results.ts/filter-bar.ts established). */ +export interface DashboardApp { + document: Document; + state: AppState; + dom: AppDom; + root: Element | null; + toggleTheme(): void; + conn: Pick; + exec: Pick; + now(): number; + wallNow(): number; + params: Pick; + workspace: Pick; + loadDashboardWorkspace(): Promise; +} + +const valueString = (value: unknown): string => + (typeof value === 'string' ? value : value == null ? '' : String(value)); /** - * Build a segmented control (the four-way `Full width | Report | 2 columns | - * 3 columns` layout switcher, #184): a row of buttons of which exactly one - * reads active. `options` are `[value, label, title?]` triples (the optional - * `title` becomes the button's hover tooltip); `ariaLabel` names the group for - * assistive tech. `getActive` returns the currently-selected value; `onPick( - * value)` fires on a click. Returns `{ el, sync }` — `sync()` repaints the - * active button (and its `aria-pressed`) from `getActive()`, so a pick and the - * shared `apply()` stay in agreement. + * Build a segmented control (the flow preset switcher). `options` are + * `[value,label,title?]`; exactly one reads active from `getActive()`; + * `onPick(value)` fires on a click; `sync()` repaints the active button. */ type SegOption = [value: string, label: string, title?: string]; function buildSeg( - cls: string, - options: SegOption[], - getActive: () => string, - onPick: (value: string) => void, - ariaLabel: string, + cls: string, options: SegOption[], getActive: () => string, onPick: (value: string) => void, ariaLabel: string, ): { el: HTMLElement; sync: () => void } { - // `h` skips nullish attribute values, so an option's absent `title` (or a - // missing `ariaLabel`) simply isn't set — no explicit guard needed here. - const btns = options.map(([, label, title]) => - h('button', { class: 'dash-seg-btn', type: 'button', title }, label)); - const sync = () => btns.forEach((b, i) => { + const btns = options.map(([, label, title]) => h('button', { class: 'dash-seg-btn', type: 'button', title }, label)); + const sync = (): void => btns.forEach((b, i) => { const on = options[i][0] === getActive(); b.classList.toggle('is-active', on); b.setAttribute('aria-pressed', String(on)); @@ -139,15 +117,8 @@ function buildSeg( return { el, sync }; } -/** - * Build a tile's footer meta row (rows · ms · bytes). On the streaming seam - * (#193) `ms` is wall-clock (like run()'s finally) and `bytes` is the progress - * byte count — both always present — so the row is unconditional. A - * fetch-truncated result (#149 D9: the client trimmed it to DASH_TILE_ROW_CAP) - * gets an honest note — client-side sort and chart aggregation only cover that - * fetched prefix, not the full underlying result. - */ -function tileFooter(meta: TileResultMeta): HTMLElement[] { +/** A tile footer meta row (rows · ms · bytes), with a truncation note. */ +function tileFooter(meta: NonNullable): HTMLElement[] { const parts = [ h('span', null, formatRows(meta.rows) + ' rows'), h('span', null, meta.ms + ' ms'), @@ -160,244 +131,85 @@ function tileFooter(meta: TileResultMeta): HTMLElement[] { return parts; } -// 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 -// both reorder the grid and orphan the "same" tile's identity. Every later -// state transition (`setSlotLoading`/`setSlotUnfilled`/`applyTileResult`) -// updates this same slot's contents/visibility in place instead. `gen` is a -// per-tile monotonically increasing generation counter guarding against -// out-of-order responses (edit A, then B, before A's request returns — B's -// response must win — and a queued Refresh worker that a newer wave has already -// superseded); `abortController` cancels this slot's in-flight streamed request -// when a newer wave supersedes it (#193); `destroy` tears down the slot's live -// panel instance (a chart's Chart.js object, via the registry's renderPanel -// contract) before it's replaced; `panelState` is the slot-persistent table-tile -// state (#166 — sort + column widths, keyed by result schema); `loadLabel` is -// the loading placeholder's live row-count text node (streamed progress, #193). -// An EXPLICIT KPI favorite never reaches this builder — it's routed to a KPI -// band slot instead (#240, see partitionKpiBands) — so `is-kpi` here only ever -// toggles on later for an AUTO-DETECTED one-row result (applyTileResult). -function buildTileSlot(q: SavedQueryV2): TileSlot { - const body = h('div', { class: 'dash-tile-body' }); - const foot = h('div', { class: 'dash-tile-foot' }); - const name = queryName(q); - const description = queryDescription(q); - // Header: the favorite's name, plus its saved description as a subtitle when it - // has one (single line, ellipsized) — mirrors the design mockup's tile header. - const head = h('div', { class: 'dash-tile-head' }, - h('span', { class: 'dash-tile-name', title: name }, name)); - if (description) head.appendChild(h('div', { class: 'dash-tile-desc', title: description }, description)); - const card = h('div', { class: 'dash-tile' }, head, body, foot); - return { - kind: 'tile', card, body, foot, gen: 0, status: null, destroy: null, panelState: null, - abortController: null, loadLabel: null, - }; -} - -function destroySlotChart(slot: TileSlot): void { - if (slot.destroy) { slot.destroy(); slot.destroy = null; } -} - -// 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 { - destroySlotChart(slot); - slot.status = 'panel'; - slot.card.style.display = ''; - const { node } = renderResolvedPanel(app, resolvePanel(queryPanel(q), []), null, - { surface: 'dashboard', state: {}, rerender: () => {}, readonly: true }); - slot.body.replaceChildren(node); - slot.foot.replaceChildren(); -} - -function setSlotLoading(slot: TileSlot): HTMLElement { - destroySlotChart(slot); - slot.card.style.display = ''; - // Return the label node so streamed progress (onChunk, #193) can update just - // its text — "Loading… N rows" — without rebuilding the tile or classifying - // yet. Panel classification + rendering happen ONCE, after completion (never - // per chunk, which would thrash Chart.js and flash partial data). - const label = h('span', null, 'Loading…'); - slot.loadLabel = label; - slot.body.replaceChildren(h('div', { class: 'dash-tile-load' }, Icon.spinner(), label)); - slot.foot.replaceChildren(); - return label; -} - -// A tile whose SQL still has an empty/absent, or invalid (#170), {name:Type} -// value never issues a request — it shows this placeholder instead (reusing -// the card's header/footer chrome so it doesn't look broken), and stays -// visible: unlike a classifyTile `skip`, one filter value away it becomes -// chartable, so it is NOT counted in the header's "N not shown" note. -function setSlotUnfilled(slot: TileSlot, names: string[]): void { - destroySlotChart(slot); - slot.status = 'unfilled'; - slot.card.style.display = ''; - slot.body.replaceChildren(h('div', { class: 'dash-tile-unfilled' }, 'Enter a value for: ' + names.join(', '))); - slot.foot.replaceChildren(); +/** The stable per-tile DOM the reconciler reuses across state publishes (so a + * chart is painted once, not thrashed on every loading/progress tick). */ +interface TileEl { + card: HTMLElement; + body: HTMLElement; + foot: HTMLElement; + panelState: { key: string;[k: string]: unknown } | null; + destroy: (() => void) | null; + paintedRows: unknown[][] | null; } -function applyTileResult(app: App, q: SavedQueryV2, slot: TileSlot, r: FavoriteSourceResult): void { - destroySlotChart(slot); - if (r.error != null) { - slot.status = 'error'; - slot.card.style.display = ''; - slot.body.replaceChildren(h('div', { class: 'dash-tile-error' }, r.error)); - slot.foot.replaceChildren(); - return; +/** Synthesize a filter definition per distinct `{name:Type}` panel-tile param + * that no explicit filter already targets — so a migrated Dashboard (whose + * persisted `filters` is empty) still surfaces its implicit param filters. + * Runtime-only; never persisted. */ +function synthesizeImplicitFilters( + doc: DashboardDocumentV1, queryById: Map, +): DashboardFilterDefinitionV1[] { + const declared = new Set((doc.filters || []).map((f) => f.parameter)); + const panelSources = (doc.tiles || []) + .map((tile) => queryById.get(tile.queryId)) + .filter((query): query is SavedQueryV2 => !!query && queryDashboardRole(query) === 'panel') + .map((query, index) => ({ id: 't' + index, kind: 'tile', sql: query.sql, bindPolicy: 'row-returning' })); + const analysis = analyzeParameterizedSources(panelSources); + const out: DashboardFilterDefinitionV1[] = []; + for (const control of fieldControls(analysis)) { + if (!declared.has(control.name)) out.push({ id: control.name, parameter: control.name }); } - const savedPanel = queryPanel(q); - const explicit = explicitPanel(q); - // `!` (on rows/meta below): a non-error outcome is always dashboardTileResult's - // full fetched shape — runFavoriteSource's only other applyResult payloads - // are error-only, and those returned above. - // Unconfigured empty results remain skipped. An EXPLICIT panel never vanishes — - // a zero-row one renders an honest "0 rows" state instead (visible, and - // excluded from the header's skip tally). - if (!explicit && r.rows!.length === 0) { - slot.status = 'skip'; - slot.card.style.display = 'none'; - // Clear the previous panel's DOM (its live instance is already torn down - // by destroySlotChart above) so a tile that flips panel → skip on a later - // refresh/filter change doesn't leave a dead canvas hidden in the DOM. - slot.body.replaceChildren(); - slot.foot.replaceChildren(); - return; - } - slot.status = 'panel'; - slot.card.style.display = ''; - // `explicit` here is never an explicit KPI panel — those are routed to a KPI - // band slot (#240) and never reach applyTileResult — so an explicit zero-row - // result is always a non-KPI panel's honest "0 rows" state. - if (explicit && r.rows!.length === 0) { - slot.body.replaceChildren(h('div', { class: 'dash-tile-empty' }, '0 rows')); - slot.foot.replaceChildren(...tileFooter(r.meta!)); - return; - } - // The one shared resolution (#166/#254): queryPanel retains fieldConfig even - // when cfg is absent, so auto-derived Dashboard panels receive the same - // presentation metadata as the workbench. `explicit` remains separate above - // because only cfg-bearing panels own zero-row and transport semantics. - const resolved = resolvePanel(savedPanel, { - columns: r.columns, - rows: r.rows, - fieldConfig: savedPanel?.fieldConfig, - serverVersion: app.state.serverVersion, - }); - slot.card.classList.toggle('is-kpi', resolved.cfg.type === 'kpi'); - // Grid state persists across refreshes/filter edits on the stable slot, - // keyed by result schema — a schema change resets it, a re-run keeps it. - const key = schemaKey(r.columns); - if (!slot.panelState || slot.panelState.key !== key) slot.panelState = { key }; - // `as`: panels.ts's (unexported) PanelResult also declares `error`/`rawText`, - // which no dashboard-dispatched arm reads (table/logs/chart consume - // columns/rows only; kpi/text ignore the result) — the dashboard has always - // passed exactly this two-field shape. Reported as a panels.ts contract gap. - const res = { columns: r.columns, rows: r.rows } as Parameters[2]; - const paint = () => { - destroySlotChart(slot); - const out = renderResolvedPanel(app, resolved, res, { - surface: 'dashboard', - // `!`: assigned right above (and only ever replaced, never nulled back). - state: slot.panelState!, - rerender: paint, // header-click sorts re-paint locally — NO re-query - readonly: true, - cap: DASH_TABLE_DISPLAY_CAP, - onCell: () => {}, - }); - slot.destroy = out.destroy || null; - slot.body.replaceChildren(out.node); - }; - paint(); - slot.foot.replaceChildren(...tileFooter(r.meta!)); -} - -// 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; -} - -// ── The narrow `app` surface this module reads directly ──────────────────── -// Not the full `App` contract (app.types.ts) — matches the convention -// results.ts/filter-bar.ts/explain-graph.ts already established for their own -// narrow app surfaces (#276 Phase 5). `renderTextSlot`/`applyTileResult` -// (above) and the `buildFilterBar`/`applyKpiSourceResult` calls below still -// want the literal `App` (panels.ts's shared registry — `renderResolvedPanel` -// — takes it historically, and narrowing that registry's own signature isn't -// mechanical: it reaches many more `App` fields across the chart/panel-editor -// arms than this module ever touches); those four call sites cast -// (`app as App`) at that one seam, same as results.ts's own documented cast. -export interface DashboardApp { - document: Document; - state: AppState; - dom: AppDom; - root: Element | null; - /** Kept as the flat delegate (not re-pointed to the extracted - * `toggleThemeDom` helper directly) — see the theme-toggle button's own - * wiring comment below for why. */ - toggleTheme(): void; - conn: Pick; - prefs: Pick; - /** The shared request/stream/normalize service (#276 Phase 1) — this module - * only ever needs `executeRead` (every favorite/filter tile streams its SQL - * read-only through it). */ - exec: Pick; - now(): number; - wallNow(): number; - /** #276 Phase 5: no flat `App` delegates for the params-group members this - * module needs — `app.params.*` directly. */ - params: Pick; - saveJSON(key: string, value: unknown): void; + return out; } /** Render the dashboard into `app.root`. */ -export function renderDashboard(app: DashboardApp): Promise { +export async function renderDashboard(app: DashboardApp): Promise { const { document: doc, state } = app; doc.documentElement.setAttribute('data-theme', state.theme); doc.documentElement.setAttribute('data-density', state.density); app.dom = {}; - const favorites = state.savedQueries.filter(queryFavorite); - const panelFavorites: SavedQueryV2[] = []; - const filterFavorites: SavedQueryV2[] = []; - const roleDiagnostics: { severity: 'warning' | 'error'; message: string }[] = []; - for (const query of favorites) { - const role = effectiveDashboardRole(query.spec); - if (role === 'panel') panelFavorites.push(query); - else if (role === 'filter') filterFavorites.push(query); - else if (role === 'setup') roleDiagnostics.push({ severity: 'warning', message: `${queryName(query)} uses Setup, which is not implemented yet.` }); - else roleDiagnostics.push({ severity: 'error', message: `${queryName(query)} has unknown Dashboard role "${role}".` }); - } + const workspace = await app.loadDashboardWorkspace(); + const queries: SavedQueryV2[] = workspace ? workspace.queries : state.savedQueries; + const queryById = new Map(); + for (const query of queries) if (!queryById.has(query.id)) queryById.set(query.id, query); + + // The live document — layout/order edits replace it; membership is read from + // `dashboard.tiles[]` (NOT `savedQueries.filter(queryFavorite)`). + let currentDoc: DashboardDocumentV1 = workspace && workspace.dashboard + ? workspace.dashboard + : { + documentVersion: 1, id: 'empty', title: state.libraryName.value, revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [], + }; + let committedRevision = currentDoc.revision; + + // Merge explicit + synthesized implicit filters for the viewer. + const viewerDoc: DashboardDocumentV1 = { + ...currentDoc, filters: [...(currentDoc.filters || []), ...synthesizeImplicitFilters(currentDoc, queryById)], + }; - // KPI bands are built structurally, from the saved config alone, before any - // query executes (#240): an EXPLICIT `panel.cfg.type==='kpi'` favorite joins - // a band; an auto-detected one-row KPI result (no saved panel) never does — - // that distinction lives entirely in `explicitPanel`/`isKpiPanel`, never in a - // fetched result, so it can't drift with what a query happens to return. - const layoutItems = partitionKpiBands(panelFavorites.map((q) => isKpiPanel(explicitPanel(q)))); - - const favChip = h('span', { class: 'dash-chip dash-fav' }, - Icon.star(true), - h('span', null, favorites.length + (favorites.length === 1 ? ' favorite' : ' favorites'))); - const skipNote = h('span', { class: 'dash-skip', style: { display: 'none' } }); + const session: DashboardViewerSession = createDashboardViewerSession({ + document: viewerDoc, + queries, + exec: app.exec, + connection: { ensureFreshToken: () => app.conn.ensureFreshToken() }, + registry: defaultLayoutRegistry, + now: () => app.now(), + wallNow: () => app.wallNow(), + isMobile: () => state.isMobile.value, + onAuthFailed: () => app.conn.chCtx.onSignedOut(), + recordBoundParams: (bp) => app.params.recordBoundParams(bp), + }); + + // ── Header chrome ─────────────────────────────────────────────────────── + const tileCountLabel = h('span'); + const tileCount = h('span', { class: 'dash-chip dash-fav' }, Icon.star(true), tileCountLabel); const updated = h('span', { class: 'dash-updated' }); const refreshBtn = h('button', { class: 'dash-btn dash-refresh', title: 'Re-run all tiles', 'aria-label': 'Refresh dashboard', }, Icon.refresh(), h('span', { class: 'dash-refresh-label' }, 'Refresh')); - // Theme toggle, mirroring the workbench header: reuse app.toggleTheme - // (persists the pref + flips data-theme via the shared `toggleThemeDom` - // composition, #276 Phase 5 — ui/theme-toggle.ts), and register the button - // as app.dom.themeBtn so that helper repaints its icon on toggle. Kept on - // the flat `app.toggleTheme` delegate rather than calling `toggleThemeDom` - // directly (unlike the workbench header, which is wired the same way for - // the same reason): this is the exact substitution seam - // dashboard.test.ts's own "has a theme toggle wired to app.toggleTheme" - // case exercises, and explain-graph.ts's detached overlay depends on the - // delegate surviving too (see theme-toggle.ts's header comment) — nothing - // left to gain by re-pointing this one call site. + refreshBtn.onclick = () => session.refresh(); const themeBtn = h('button', { class: 'dash-icobtn', title: 'Toggle theme', 'aria-label': 'Toggle theme', onclick: () => app.toggleTheme(), }); @@ -406,219 +218,270 @@ export function renderDashboard(app: DashboardApp): Promise { const header = h('div', { class: 'dash-header' }, h('a', { - class: 'dash-back', href: app.conn.basePath || '/sql', title: 'Back to SQL Browser', - 'aria-label': 'Back to SQL Browser', + class: 'dash-back', href: app.conn.basePath || '/sql', title: 'Back to SQL Browser', 'aria-label': 'Back to SQL Browser', }, Icon.arrow(), h('span', { class: 'dash-back-label' }, 'SQL Browser')), - h('div', { class: 'dash-title' }, state.libraryName.value), - favChip, - skipNote, + h('div', { class: 'dash-title' }, currentDoc.title || state.libraryName.value), + tileCount, h('div', { class: 'dash-spacer', style: { flex: '1' } }), - h('span', { class: 'dash-chip dash-src', title: app.conn.host() }, - h('span', { class: 'dash-dot' }), app.conn.host()), - updated, - themeBtn, - refreshBtn); + h('span', { class: 'dash-chip dash-src', title: app.conn.host() }, h('span', { class: 'dash-dot' }), app.conn.host()), + updated, themeBtn, refreshBtn); - const grid = h('div', { class: 'dash-grid' }); - const empty = h('div', { class: 'dash-empty', style: { display: favorites.length ? 'none' : '' } }, - 'No favorites yet — star a query in the Library to add it to the dashboard.'); - - // Layout toolbar (#149 D2, #184) + global filter bar (#149 D3). One four-way - // segmented control — Full width | Report | 2 columns | 3 columns — replaces - // the old Arrange|Report + separate Columns pair (#184): every effective view - // is one click away and the two persisted keys (dashLayout/dashCols) are - // driven together through activeDashboardView / dashboardViewSelection. It is - // presentation-only: `apply()` toggles the grid's mutually-exclusive shape - // classes and the tiles' Chart.js instances resize themselves via their - // ResizeObserver — no tile re-query. Only the keys that actually change are - // persisted (asb:dashLayout/dashCols) so the choice survives reloads and - // Refresh. The filter bar sits immediately after the switcher; it is entirely - // absent (no row, no spacing) when no favorite references a `{name:Type}`. - const apply = () => { - grid.classList.toggle('is-wide', state.dashLayout === 'wide'); - grid.classList.toggle('is-report', state.dashLayout === 'report'); - grid.style.setProperty('--dash-cols', String(state.dashCols)); - layoutSeg.sync(); - }; - const layoutSeg = buildSeg('dash-seg-layout', [ - ['wide', 'Full width', 'One tile per row using all available width'], + // ── Preset switcher (change-layout command) ─────────────────────────────── + const presetSeg = buildSeg('dash-seg-layout', [ + ['full-width', 'Full width', 'One tile per row using all available width'], ['report', 'Report', 'One centered, taller tile per row'], ['columns-2', '2 columns', 'Arrange tiles in two columns'], ['columns-3', '3 columns', 'Arrange tiles in three columns'], - ], () => activeDashboardView(state), (view) => { - if (view === activeDashboardView(state)) return; - const sel = dashboardViewSelection(view); - if (sel.dashLayout !== state.dashLayout) { - // `!`: dashboardViewSelection always returns a concrete dashLayout — the - // optionality belongs to its persisted-state-shaped return interface, - // not this value (a core/dashboard.ts return-type gap, reported). - state.dashLayout = sel.dashLayout!; - app.prefs.save('dashLayout', sel.dashLayout); - } - if (sel.dashCols != null && sel.dashCols !== state.dashCols) { - state.dashCols = sel.dashCols; - app.prefs.save('dashCols', sel.dashCols); - } - apply(); - }, 'Dashboard layout'); - const layoutWrap = h('div', { class: 'dash-layout-wrap' }, - h('span', { class: 'dash-seg-label' }, 'Layout'), layoutSeg.el); + ], () => (typeof currentDoc.layout.preset === 'string' ? currentDoc.layout.preset : 'full-width'), + (preset) => { runCommand({ type: 'change-layout', layout: { ...currentDoc.layout, preset: preset as FlowPresetV1 } }); }, + 'Dashboard layout'); + const layoutWrap = h('div', { class: 'dash-layout-wrap' }, h('span', { class: 'dash-seg-label' }, 'Layout'), presetSeg.el); + // ── Filter bar (shared buildFilterBar, viewer-backed) ───────────────────── const filterHost = h('div', { class: 'dash-filter-host' }); - const filterDiagnosticsHost = h('div', { class: 'dash-filter-diagnostics' }); - - // 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. + const filterCountNode = h('span', { class: 'dash-filter-count-host' }); + const clearAllNode = h('span', { class: 'dash-filter-clear-all-host' }); + // The draft value/active bag the shared filter bar reads + mutates; re-seeded + // from committed filter state on each (re)build. Recents come from the real + // app — the viewer never touches AppState. + const draftValues: Record = {}; + const draftActive: Record = {}; + const filterBarApp: FilterBarApp = { + document: doc, + state: { varValues: draftValues, filterActive: draftActive, varRecent: state.varRecent }, + params: { + saveVarValues: () => {}, + saveFilterActive: () => {}, + clearVarRecent: (name: string) => app.params.clearVarRecent(name), + }, + wallNow: () => app.wallNow(), + }; let filterBarDispose: (() => void) | null = null; - const disposeCurrentFilterBar = (): void => { filterBarDispose?.(); }; - const renderFilterBar = (curatedFields: Record): void => { - disposeCurrentFilterBar(); - const bar = buildFilterBar(app as App, session.controls, (name) => session.runAffected(name), session.getFilterField, { curatedFields }); - filterHost.replaceChildren(bar.el); + + function rebuildFilterBar(sview: DashboardViewState): void { + filterBarDispose?.(); + const idByParam = new Map(); + const curatedFields: Record = {}; + for (const f of sview.filters) { + draftValues[f.parameter] = valueString(f.value); + draftActive[f.parameter] = f.active; + idByParam.set(f.parameter, f.id); + if (f.options && f.options.length) curatedFields[f.parameter] = { options: f.options }; + } + const onCommit = (name: string): void => { + const id = idByParam.get(name); + if (id) session.applyFilter(id, draftValues[name] ?? '', !!draftActive[name]); + }; + const getField = (name: string, mode: ValidationMode) => session.getFilterField(name, mode, draftValues, draftActive); + const bar = buildFilterBar(filterBarApp, session.controls, onCommit, getField, { curatedFields, document: doc }); + filterHost.replaceChildren(bar.el, clearAllNode, filterCountNode); filterBarDispose = bar.dispose; - }; + } - 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 the session's runFilterSource with `sourceId: query.id` (a - // string). - const retry = item.code === 'filter-query-failed' && item.sourceId - ? 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 filterDiagnosticsHost = h('div', { class: 'dash-filter-diagnostics' }); + const grid = h('div', { class: 'dash-grid' }); + const empty = h('div', { class: 'dash-empty', style: { display: currentDoc.tiles.length ? 'none' : '' } }, + 'No tiles yet — star a query in the Library to add it to the dashboard.'); + + // ── Structural commands (reorder via drag, preset) ──────────────────────── + // move-tile / update-placement / change-layout are the phase-3 authoring + // commands; the dashboard UI drives only move-tile (drag) and change-layout + // (preset) — span/height (update-placement) is tuned in the Spec editor. + function runCommand(command: Parameters[1]): void { + const applied = applyCommand(currentDoc, command, { + resolver: createQueryResolver(queries), genTileId: () => 'tile', plugin: flowLayoutPlugin, + }); + // A UI-driven command (drag move-tile, preset change-layout) is always + // valid; a rejected candidate is simply ignored (no draft change). + if (applied.ok) { + const normalized = flowLayoutPlugin.normalize(applied.dashboard); + currentDoc = normalized; + presetSeg.sync(); + session.syncDocument({ + ...normalized, filters: [...(normalized.filters || []), ...synthesizeImplicitFilters(normalized, queryById)], + }); + // Best-effort persistence (revision increments once per successful commit). + if (workspace) { + const candidate: StoredWorkspaceV1 = { + storageVersion: 1, id: workspace.id, name: workspace.name, queries: workspace.queries, + dashboard: { ...normalized, revision: committedRevision + 1 }, + }; + app.workspace.commit(candidate).then((result) => { if (result.ok) committedRevision += 1; }); + } + } + } - // 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); + // ── Tile DOM ────────────────────────────────────────────────────────────── + const tileEls = new Map(); + let dragTileId: string | null = null; + + function ensureTileEl(ts: ViewerTileState): TileEl { + const existing = tileEls.get(ts.tileId); + if (existing) return existing; + const head = h('div', { class: 'dash-tile-head' }, h('span', { class: 'dash-tile-name', title: ts.title }, ts.title)); + const body = h('div', { class: 'dash-tile-body' }); + const foot = h('div', { class: 'dash-tile-foot' }); + const card = h('div', { class: 'dash-tile', draggable: 'true' }, head, body, foot); + // Pointer drag is the sole reorder mechanism (#286 owner override): a drop + // persists the new dashboard.tiles[] order through the move-tile command. + card.addEventListener('dragstart', () => { dragTileId = ts.tileId; }); + card.addEventListener('dragover', (event) => event.preventDefault()); + card.addEventListener('drop', (event) => { + event.preventDefault(); + if (dragTileId && dragTileId !== ts.tileId) { + runCommand({ type: 'move-tile', tileId: dragTileId, toIndex: currentDoc.tiles.map((t) => t.id).indexOf(ts.tileId) }); } + dragTileId = null; + }); + const tileEl: TileEl = { card, body, foot, panelState: null, destroy: null, paintedRows: null }; + tileEls.set(ts.tileId, tileEl); + return tileEl; + } + + function destroyChart(tileEl: TileEl): void { if (tileEl.destroy) { tileEl.destroy(); tileEl.destroy = null; } } + + // Paint an ordinary (non-KPI) tile's result once per new result. Only ever + // called for a 'ready' tile, so columns/rows/meta/panel are all present. + function paintPanel(ts: ViewerTileState, tileEl: TileEl): void { + if (ts.rows === tileEl.paintedRows) return; + destroyChart(tileEl); + const panel = (ts.panel || {}) as Record; + const columns = ts.columns as Column[]; + const rows = ts.rows as unknown[][]; + const resolved = resolvePanel(panel as Parameters[0], { + columns, rows, fieldConfig: panel.fieldConfig as never, serverVersion: state.serverVersion, + }); + tileEl.card.classList.toggle('is-kpi', resolved.cfg.type === 'kpi'); + const key = JSON.stringify(columns.map((c) => c.name + ':' + c.type)); + if (!tileEl.panelState || tileEl.panelState.key !== key) tileEl.panelState = { key }; + const result = { columns, rows } as Parameters[2]; + const out = renderResolvedPanel(app as unknown as App, resolved, result, { + surface: 'dashboard', state: tileEl.panelState, rerender: () => paintForce(ts, tileEl), + readonly: true, cap: DASH_TABLE_DISPLAY_CAP, onCell: () => {}, + }); + tileEl.destroy = out.destroy || null; + tileEl.body.replaceChildren(out.node); + tileEl.foot.replaceChildren(...tileFooter(ts.meta as NonNullable)); + tileEl.paintedRows = ts.rows; + } + + // A local re-paint (header-click sort) — force even when the rows ref is + // unchanged (the sort mutated the panel state, not the data). + function paintForce(ts: ViewerTileState, tileEl: TileEl): void { tileEl.paintedRows = null; paintPanel(ts, tileEl); } + + function reconcileTile(ts: ViewerTileState): void { + const tileEl = ensureTileEl(ts); + if (ts.isKpi) return; // KPI tiles are rendered inside their band, not as a card + if (ts.status === 'ready') { paintPanel(ts, tileEl); return; } + destroyChart(tileEl); + tileEl.paintedRows = null; + tileEl.foot.replaceChildren(); + if (ts.status === 'error') { + tileEl.body.replaceChildren(h('div', { class: 'dash-tile-error' }, ts.error || 'Error')); + } else if (ts.status === 'unfilled') { + tileEl.body.replaceChildren(h('div', { class: 'dash-tile-unfilled' }, 'Enter a value for: ' + ts.unfilled.join(', '))); + } else { + const label = h('span', null, ts.progressRows ? 'Loading… ' + formatRows(ts.progressRows) + ' rows' : 'Loading…'); + tileEl.body.replaceChildren(h('div', { class: 'dash-tile-load' }, Icon.spinner(), label)); } - return built; - }; + } - const tileHooks: TileDomHooks = { - setUnfilled: setSlotUnfilled, - setLoading: setSlotLoading, - onProgress: setSlotProgress, - applyResult: (q, slot, r) => applyTileResult(app as App, q, slot, r), - renderText: (q, slot) => renderTextSlot(app as App, q, slot), - }; - const kpiHooks: KpiSourceDomHooks = { - setUnfilled: setKpiSourceUnfilled, - setLoading: setKpiSourceLoading, - onProgress: setSlotProgress, - applyResult: (explicit, slot, r) => applyKpiSourceResult(app as App, explicit, slot, r), - refreshBandWarnings, - }; + // Render one KPI tile's cards (or its non-ready state) into `host`. On 'ready' + // the viewer guarantees columns/rows (no defensive fallback). + function renderKpiInto(host: HTMLElement, ts: ViewerTileState): void { + if (ts.status !== 'ready') { + host.replaceChildren(h('div', { class: 'dash-kpi-state-card' }, + ts.status === 'error' ? (ts.error || 'Error') + : ts.status === 'unfilled' ? 'Enter a value for: ' + ts.unfilled.join(', ') : 'Loading…')); + return; + } + const panel = (ts.panel || {}) as Record; + const resolved = resolvePanel(panel as Parameters[0], { + columns: ts.columns as Column[], rows: ts.rows as unknown[][], + fieldConfig: panel.fieldConfig as never, + serverVersion: state.serverVersion, + }); + const { cards, errors } = renderKpiCards(resolved.kpi); + host.replaceChildren(...(errors.length ? errors.map((e) => h('div', { class: 'dash-kpi-state-card' }, e.message)) : cards)); + } - 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'; - } - }, - disposeFilterBar: disposeCurrentFilterBar, - onAuthFailed: () => { app.conn.chCtx.onSignedOut(); }, - onRunAllStart: () => { refreshBtn.disabled = true; }, - onRunAllSettled: () => { + // ── Grid reconciliation from the flow model ─────────────────────────────── + let lastLayoutSig = ''; + function reconcileGrid(sview: DashboardViewState): void { + const byId = new Map(sview.tiles.map((t) => [t.tileId, t])); + for (const ts of sview.tiles) reconcileTile(ts); + const sig = JSON.stringify({ + m: sview.layout.mobile, c: sview.layout.columns, p: sview.layout.preset, + rows: sview.layout.rows.map((r) => ({ k: r.kind, t: r.tiles.map((t) => [t.tileId, t.span]) })), + }); + // Rebuild the row STRUCTURE only when the flow model changes (a reorder, + // preset, or mobile flip) — moving stable tile cards, so charts are never + // thrashed. + if (sig !== lastLayoutSig) { + lastLayoutSig = sig; + grid.classList.toggle('is-report', sview.layout.preset === 'report'); + grid.replaceChildren(...sview.layout.rows.map((row) => { + if (row.kind === 'kpi-band') { + const stream = h('div', { class: 'dash-kpi-stream', ...KPI_STREAM_ARIA }); + for (const member of row.tiles) stream.appendChild(h('div', { class: 'dash-kpi-member', 'data-tile': member.tileId })); + return h('div', { class: 'dash-row dash-kpi-band' }, stream); + } + const rowEl = h('div', { class: 'dash-row', style: { display: 'grid', gridTemplateColumns: `repeat(${row.columns}, minmax(0, 1fr))`, gap: '12px' } }); + for (const t of row.tiles) { + const tileEl = tileEls.get(t.tileId); + if (tileEl) { tileEl.card.style.gridColumn = `span ${t.span}`; rowEl.appendChild(tileEl.card); } + } + return rowEl; + })); + } + // A KPI band member's CONTENT (cards / state) is refreshed on every publish + // — cheap, KPI cards carry no charts — so a member reaching ready repaints + // without a structural rebuild. + for (const host of grid.querySelectorAll('.dash-kpi-member')) { + const ts = byId.get((host as HTMLElement).dataset.tile || ''); + if (ts) renderKpiInto(host as HTMLElement, ts); + } + } + + // ── Effect: reconcile on every publish (and on the mobile-breakpoint flip) ─ + let lastMobile = state.isMobile.value; + let barSig = ''; + effect(() => { + const sview = session.state.value; + const mobileNow = state.isMobile.value; // tracked so a breakpoint flip re-runs the effect + // A breakpoint flip after the last publish needs a fresh flow model — + // republish through the viewer (recomputes it with the new mobile flag). + if (mobileNow !== lastMobile && mobileNow !== sview.layout.mobile) { lastMobile = mobileNow; session.syncDocument(currentDoc); return; } + lastMobile = mobileNow; + // Rebuild the shared filter bar only when its field structure changes + // (activation, committed value, or curated options arriving) — not on tile + // progress ticks — so in-progress typing is not disturbed mid-wave. + const sig = JSON.stringify(sview.filters.map((f) => [f.id, f.active, valueString(f.value), !!(f.options && f.options.length)])); + if (sig !== barSig) { barSig = sig; rebuildFilterBar(sview); } + clearAllNode.replaceChildren(filterClearAllButton({ active: sview.activeFilterCount > 0, onClearAll: () => session.clearAllFilters() })); + filterCountNode.replaceChildren(filterActiveCount(sview.activeFilterCount)); + tileCountLabel.textContent = sview.tiles.length + (sview.tiles.length === 1 ? ' tile' : ' tiles'); + empty.style.display = sview.tiles.length ? 'none' : ''; + // Genuine dashboard-config diagnostics only (a tile whose presentation + // could not resolve, etc.). Per-filter "required/invalid" badges were + // dropped as noise (owner decision) — an unfilled required filter simply + // leaves its target tiles in their normal unfilled state. + filterDiagnosticsHost.replaceChildren( + ...sview.diagnostics.map((d) => h('div', { class: 'dash-config-diagnostic is-error' }, d.message)), + ); + reconcileGrid(sview); + refreshBtn.disabled = sview.running; + if (!sview.running && sview.updatedAt != null) { 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.conn.ensureFreshToken(), - now: () => app.now(), - wallNow: () => app.wallNow(), - recordBoundParams: (bp) => app.params.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.params.saveFilterActive(); - }, - hooks, - }; - 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. + const toolbar = h('div', { class: 'dash-toolbar' + (session.state.value.filters.length ? ' has-filters' : '') }, layoutWrap, filterHost); + + // `!`: the dashboard renders only into a mounted page. 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(); + await session.start(); } diff --git a/src/ui/dashboard/dashboard-session.ts b/src/ui/dashboard/dashboard-session.ts deleted file mode 100644 index dc406f27..00000000 --- a/src/ui/dashboard/dashboard-session.ts +++ /dev/null @@ -1,676 +0,0 @@ -// #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 b2296733..4b0d3ea0 100644 --- a/src/ui/filter-bar.ts +++ b/src/ui/filter-bar.ts @@ -86,6 +86,38 @@ const buildRecentField = _buildRecentField as (opts: { onValueInput: () => void; onCommit: () => void; }) => FilterBarComboField; +// ── #188 filter-bar usability affordances (#286) ───────────────────────────── +// Small, reusable DOM builders the Dashboard viewer drives from its filter +// state (clearFilter / clearAllFilters / activeFilterCount / blocking). Kept +// here, on the shared filter-bar module, so any filter surface can compose the +// same affordances; they are pure element factories (no app/state coupling). + +/** The toolbar clear-all affordance (#188): resets every filter in one wave. + * Hidden (not just disabled) when nothing is active, so it never draws focus + * to a no-op. */ +export function filterClearAllButton(opts: { active: boolean; onClearAll: () => void }): HTMLButtonElement { + const btn = h('button', { + type: 'button', class: 'dash-filter-clear-all', + title: 'Clear all filters', 'aria-label': 'Clear all filters', + onclick: () => opts.onClearAll(), + }, 'Clear all'); + if (!opts.active) btn.style.display = 'none'; + return btn; +} + +/** The "N active" indicator (#188): counts ACTIVE filter definitions, readable + * by assistive tech without a live-region announcement (a plain labeled + * status node, not aria-live). Absent (empty) when nothing is active. */ +export function filterActiveCount(count: number): HTMLElement { + const node = h('span', { + class: 'dash-filter-count', role: 'status', + 'aria-label': `${count} active filter${count === 1 ? '' : 's'}`, + }); + if (count > 0) node.textContent = `${count} active`; + else node.style.display = 'none'; + return node; +} + // Idle time after the last keystroke in a filter field before it triggers a // re-run (#149 D3) — longer than the FROM-scope column-load debounce // (codemirror-adapter.js) since this fires a real query, not a metadata fetch. diff --git a/src/workspace/indexeddb-workspace-store.ts b/src/workspace/indexeddb-workspace-store.ts new file mode 100644 index 00000000..26ba466b --- /dev/null +++ b/src/workspace/indexeddb-workspace-store.ts @@ -0,0 +1,110 @@ +// Concrete IndexedDB adapter behind the injected `WorkspaceStore` seam (#280 +// Phase 2 / issue #284). This is the one place a real IndexedDB is touched; +// the repository/migration logic never sees it. The pinned Phase-2 decision: +// persist the StoredWorkspaceV1 aggregate as a SINGLE record and replace it +// atomically via ONE readwrite transaction (IndexedDB was chosen over a single +// localStorage key because a 10 MiB `maxDecodedJsonBytes` aggregate exceeds the +// ~5 MB localStorage origin quota, and because Phase 6's cross-tab token +// handoff needs IndexedDB anyway). +// +// The `IDBFactory` is injected (createApp resolves `env.indexedDB` / +// `win.indexedDB`), so tests drive this with a minimal in-memory fake factory +// instead of a real browser database — the same seam pattern as fetch/crypto. + +import type { WorkspaceStore } from './workspace-store.types.js'; + +export interface IndexedDbWorkspaceStoreOptions { + /** IndexedDB database name. */ + dbName?: string; + /** Object-store name inside the database. */ + storeName?: string; + /** Fixed key of the single aggregate record. */ + recordKey?: string; +} + +const DEFAULTS = { + dbName: 'asb-workspace', + storeName: 'workspace', + recordKey: 'current', +} as const; + +// Promisify one IDBRequest. +function requestResult(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); +} + +// Resolve when a readwrite transaction durably completes (the atomicity point). +function transactionDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed')); + tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted')); + }); +} + +/** Build a `WorkspaceStore` backed by IndexedDB. The database is opened lazily + * on first use (and the open promise cached), so constructing the store with a + * not-yet-available factory never throws — the failure surfaces only when an + * operation actually runs, where the repository catches it. */ +export function createIndexedDbWorkspaceStore( + factory: IDBFactory | undefined, options: IndexedDbWorkspaceStoreOptions = {}, +): WorkspaceStore { + const dbName = options.dbName ?? DEFAULTS.dbName; + const storeName = options.storeName ?? DEFAULTS.storeName; + const recordKey = options.recordKey ?? DEFAULTS.recordKey; + let dbPromise: Promise | null = null; + + function openDb(): Promise { + if (dbPromise) return dbPromise; + const pending = new Promise((resolve, reject) => { + if (!factory) { + reject(new Error('IndexedDB is unavailable')); + return; + } + const request = factory.open(dbName, 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(storeName)) db.createObjectStore(storeName); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed')); + // Dormant at version 1 (no upgrade can block a fresh open), but an + // unhandled `blocked` event would hang the open forever — reject so the + // no-cache-on-failure path below reopens on the next call. + request.onblocked = () => reject(new Error('IndexedDB open blocked')); + }); + // Cache only a SUCCESSFUL open. A rejected open must not poison the store + // for the page's lifetime (one createApp() builds one long-lived store): + // drop the cached promise on failure so a same-session retry can reopen, + // matching the repository's "failed write leaves a draft to retry" contract. + dbPromise = pending; + pending.catch(() => { if (dbPromise === pending) dbPromise = null; }); + return pending; + } + + async function read(): Promise { + const db = await openDb(); + const tx = db.transaction([storeName], 'readonly'); + const value = await requestResult(tx.objectStore(storeName).get(recordKey)); + return typeof value === 'string' ? value : null; + } + + async function write(text: string): Promise { + const db = await openDb(); + const tx = db.transaction([storeName], 'readwrite'); + tx.objectStore(storeName).put(text, recordKey); + await transactionDone(tx); + } + + async function clear(): Promise { + const db = await openDb(); + const tx = db.transaction([storeName], 'readwrite'); + tx.objectStore(storeName).delete(recordKey); + await transactionDone(tx); + } + + return { read, write, clear }; +} diff --git a/src/workspace/legacy-migration.ts b/src/workspace/legacy-migration.ts new file mode 100644 index 00000000..7e0f2d31 --- /dev/null +++ b/src/workspace/legacy-migration.ts @@ -0,0 +1,131 @@ +// Legacy migration marker (#280 "Legacy migration marker", Phase 2 of #280 / +// issue #284). One-shot conversion of the pre-aggregate flat localStorage +// state into one atomic StoredWorkspaceV1: +// +// 1. read legacy values (the caller supplies them — this module is pure); +// 2. build one candidate StoredWorkspaceV1; +// 3. create the initial Dashboard from legacy favorites (`spec.favorite`); +// 4. convert the legacy layout preferences (asb:dashLayout/asb:dashCols) to a +// valid flow@1 layout; +// 5. validate the WHOLE candidate (via the repository's commit); +// 6. persist it atomically; +// 7. treat a successful aggregate write as migration completion. +// +// The marker is aggregate RECORD EXISTENCE — migration runs only when the +// store holds no record (checked via `store.read()`), never keyed on +// loadCurrent validity, so a present-but-corrupt aggregate is never clobbered +// by a re-run. Legacy keys are NEVER deleted or modified here: Phase 2 still +// serves the favorites-driven UI off them, and #280 forbids touching them +// before the aggregate write succeeds. Removing the legacy reads (and the +// `spec.favorite` dual-write) is the documented Phase 3-5 removal path, not +// this phase. +// +// Pure over injected seams: the query-collection is decoded by the caller, the +// ID generator is injected, and persistence goes through the injected +// WorkspaceStore/WorkspaceRepository — no DOM, no storage, no crypto import. + +import { queryFavorite } from '../core/saved-query.js'; +import { activeDashboardView } from '../core/dashboard.js'; +import { queryDashboardRole } from '../dashboard/model/workspace-semantics.js'; +import { CURRENT_STORAGE_VERSION, DEFAULT_WORKSPACE_NAME } from './workspace-operations.js'; +import type { WorkspaceIdGen } from './workspace-operations.js'; +import type { WorkspaceStore } from './workspace-store.types.js'; +import type { WorkspaceRepository, WorkspaceCommitResult } from './workspace-repository.js'; +import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnostics.js'; +import type { + FlowPresetV1, SavedQueryV2, StoredWorkspaceV1, +} from '../generated/json-schema.types.js'; + +/** The flat legacy persistence the caller reads out of localStorage before + * migrating: the decoded saved-query collection (asb:saved), the Library name + * (asb:libraryName), and the two Dashboard layout preferences + * (asb:dashLayout/asb:dashCols). */ +export interface LegacyWorkspaceInput { + name: string; + queries: readonly SavedQueryV2[]; + dashLayout: string; + dashCols: number; +} + +/** Map the legacy Dashboard layout preferences to a normative flow@1 preset. + * Reuses the existing `activeDashboardView` derivation (core/dashboard.ts) and + * remaps its `wide` value to the flow preset name `full-width`; `report`, + * `columns-2`, and `columns-3` already match the flow preset names. */ +export function legacyLayoutToFlowPreset(dashLayout: string, dashCols: number): FlowPresetV1 { + const view = activeDashboardView({ dashLayout, dashCols }); + return view === 'wide' ? 'full-width' : view; +} + +/** Build the one candidate StoredWorkspaceV1 from the legacy state (steps 2-4). + * The initial Dashboard's tiles are the PANEL-role favorites in catalog order: + * a favorited filter/setup-role query cannot be a tile by the #280 contract, + * so it is not turned into one (it stays in the query collection). The + * Dashboard is always created so the legacy layout preference is preserved + * even for a user with no favorites yet; it starts at revision 1. `genId` is + * called once for the workspace ID, once for the Dashboard ID, and once per + * tile. The candidate is NOT validated here — the caller validates the whole + * thing through `repository.commit`. */ +export function buildLegacyMigrationCandidate( + legacy: LegacyWorkspaceInput, genId: WorkspaceIdGen, +): StoredWorkspaceV1 { + const name = legacy.name.trim() ? legacy.name : DEFAULT_WORKSPACE_NAME; + const queries = [...legacy.queries]; + const workspaceId = genId(); + const dashboardId = genId(); + const tiles = queries + .filter((query) => queryFavorite(query) && queryDashboardRole(query) === 'panel') + .map((query) => ({ id: genId(), queryId: query.id })); + return { + storageVersion: CURRENT_STORAGE_VERSION, + id: workspaceId, + name, + queries, + dashboard: { + documentVersion: 1, + id: dashboardId, + title: name, + revision: 1, + layout: { + type: 'flow', + version: 1, + preset: legacyLayoutToFlowPreset(legacy.dashLayout, legacy.dashCols), + items: {}, + }, + filters: [], + tiles, + }, + }; +} + +/** The outcome of `migrateLegacyWorkspaceIfNeeded`. `migrated: false` with + * `reason: 'aggregate-exists'` means the marker found a record and skipped; + * `reason: 'commit-failed'` carries the whole-candidate validation or + * persistence diagnostics (legacy keys were left intact). */ +export type MigrationResult = + | { migrated: true; result: Extract } + | { migrated: false; reason: 'aggregate-exists' } + | { migrated: false; reason: 'commit-failed'; diagnostics: WorkspaceDiagnostic[] }; + +export interface MigrationDeps { + /** Checked for record existence — the migration marker. */ + store: WorkspaceStore; + /** The repository whose atomic `commit` validates + persists the candidate. */ + repository: WorkspaceRepository; + legacy: LegacyWorkspaceInput; + genId: WorkspaceIdGen; +} + +/** Run the one-shot migration when — and only when — no aggregate record + * exists yet. Idempotent: once the aggregate persists, a later call finds the + * record and returns `aggregate-exists` without rebuilding or rewriting. A + * failed commit leaves the store (and every legacy key) untouched, so a retry + * on the next load is safe. */ +export async function migrateLegacyWorkspaceIfNeeded(deps: MigrationDeps): Promise { + const { store, repository, legacy, genId } = deps; + const existing = await store.read(); + if (existing !== null) return { migrated: false, reason: 'aggregate-exists' }; + const candidate = buildLegacyMigrationCandidate(legacy, genId); + const result = await repository.commit(candidate); + if (!result.ok) return { migrated: false, reason: 'commit-failed', diagnostics: result.diagnostics }; + return { migrated: true, result }; +} diff --git a/src/workspace/stored-workspace.ts b/src/workspace/stored-workspace.ts new file mode 100644 index 00000000..12ff67eb --- /dev/null +++ b/src/workspace/stored-workspace.ts @@ -0,0 +1,119 @@ +// StoredWorkspaceV1 contract codec and whole-workspace validation (#280 +// "Internal persistence: StoredWorkspaceV1", phase 1 of #283). The atomic +// WorkspaceRepository itself is Phase 2; this module owns the persistence +// aggregate's validation pipeline (codec guards → storageVersion +// identification, fail closed → structural schema validation → whole- +// workspace cross-resource semantics → sorted diagnostics) and the canonical +// encoding used for persistence snapshots, hashing, and equality. Pure. + +import { PORTABLE_LIMITS } from '../dashboard/model/portable-limits.js'; +import { parseJsonWithLimits, utf8ByteLength } from '../dashboard/model/json-limits.js'; +import type { JsonLimitOptions } from '../dashboard/model/json-limits.js'; +import { canonicalJson, STORED_WORKSPACE_SHAPE } from '../dashboard/model/canonical-json.js'; +import { diagnostic, sortDiagnostics } from '../dashboard/model/workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnostics.js'; +import { + unsupportedDashboardVersionDiagnostics, + unsupportedSpecVersionDiagnostics, + validateDashboardSemantics, + validateQueryCollectionSemantics, +} from '../dashboard/model/workspace-semantics.js'; +import { jsonSchemaValidationService } from '../core/library-codec.js'; +import type { JsonSchemaValidationService } from '../core/json-schema-validation.js'; +import type { StoredWorkspaceV1 } from '../generated/json-schema.types.js'; + +export const CURRENT_STORED_WORKSPACE_VERSION = 1; +export const STORED_WORKSPACE_V1_SCHEMA_ID = + 'https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json'; + +export type WorkspaceFailResult = { ok: false; diagnostics: WorkspaceDiagnostic[] }; + +export interface WorkspaceCodecOptions { + validationService?: JsonSchemaValidationService; +} + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +function identifyStoredWorkspace(document: unknown): WorkspaceDiagnostic[] { + if (!isObject(document)) return [diagnostic([], 'workspace-invalid-root', 'Stored workspace must be an object')]; + if (!Object.hasOwn(document, 'storageVersion')) { + return [diagnostic(['storageVersion'], 'workspace-version-missing', 'Missing stored-workspace version')]; + } + if (!Number.isInteger(document.storageVersion)) { + return [diagnostic(['storageVersion'], 'workspace-version-invalid', 'Invalid stored-workspace version')]; + } + if (document.storageVersion !== CURRENT_STORED_WORKSPACE_VERSION) { + return [diagnostic(['storageVersion'], 'workspace-version-unsupported', + `Unsupported stored-workspace version ${document.storageVersion}`)]; + } + return []; +} + +/** Complete deterministic validation of one stored-workspace aggregate — + * the same pipeline Phase 2's `WorkspaceRepository.commit` must run before + * any write. */ +export function validateStoredWorkspaceDocument( + document: unknown, { validationService = jsonSchemaValidationService }: WorkspaceCodecOptions = {}, +): WorkspaceDiagnostic[] { + const identity = identifyStoredWorkspace(document); + if (identity.length) return sortDiagnostics(identity); + const doc = document as Record; + const queries = Array.isArray(doc.queries) ? doc.queries : []; + const dashboard = doc.dashboard ?? null; + const versionDiagnostics = [ + ...unsupportedSpecVersionDiagnostics(queries, ['queries']), + ...unsupportedDashboardVersionDiagnostics(dashboard === null ? [] : [dashboard], []) + .map((item) => ({ ...item, path: ['dashboard', ...item.path.slice(1)] })), + ]; + const skipQueryIndexes = new Set(versionDiagnostics + .filter((item) => item.path[0] === 'queries').map((item) => item.path[1])); + const skipDashboard = versionDiagnostics.some((item) => item.path[0] === 'dashboard'); + const structural = validationService.validate(STORED_WORKSPACE_V1_SCHEMA_ID, document) + .filter((item) => !(item.path[0] === 'queries' && skipQueryIndexes.has(item.path[1])) + && !(skipDashboard && item.path[0] === 'dashboard')); + if (versionDiagnostics.length || structural.length) { + return sortDiagnostics([...versionDiagnostics, ...structural]); + } + return sortDiagnostics([ + ...validateQueryCollectionSemantics(queries), + ...(dashboard === null ? [] : validateDashboardSemantics(dashboard, { + queries, path: ['dashboard'], validationService, + })), + ]); +} + +export type DecodeStoredWorkspaceResult = { ok: true; value: StoredWorkspaceV1 } | WorkspaceFailResult; + +/** Parse and fully validate stored-workspace JSON text. */ +export function decodeStoredWorkspaceJson( + text: unknown, options: WorkspaceCodecOptions & JsonLimitOptions = {}, +): DecodeStoredWorkspaceResult { + const parsed = parseJsonWithLimits(text, options); + if (!parsed.ok) return parsed; + const diagnostics = validateStoredWorkspaceDocument(parsed.value, options); + if (diagnostics.length) return { ok: false, diagnostics }; + return { ok: true, value: parsed.value as StoredWorkspaceV1 }; +} + +export type EncodeStoredWorkspaceResult = { ok: true; value: string } | WorkspaceFailResult; + +/** Validate and canonically encode one stored-workspace aggregate — the one + * encoder output persistence snapshots, hashing, equality checks, and + * snapshot tests all share. */ +export function encodeStoredWorkspaceJson( + workspace: unknown, options: WorkspaceCodecOptions = {}, +): EncodeStoredWorkspaceResult { + const diagnostics = validateStoredWorkspaceDocument(workspace, options); + if (diagnostics.length) return { ok: false, diagnostics }; + const encoded = canonicalJson(workspace, STORED_WORKSPACE_SHAPE); + const bytes = utf8ByteLength(encoded); + if (bytes > PORTABLE_LIMITS.maxDecodedJsonBytes) { + return { + ok: false, + diagnostics: [diagnostic([], 'limit-json-bytes', + `Encoded document is ${bytes} UTF-8 bytes; the maximum is ${PORTABLE_LIMITS.maxDecodedJsonBytes}`)], + }; + } + return { ok: true, value: encoded }; +} diff --git a/src/workspace/workspace-operations.ts b/src/workspace/workspace-operations.ts new file mode 100644 index 00000000..6c3553f4 --- /dev/null +++ b/src/workspace/workspace-operations.ts @@ -0,0 +1,81 @@ +// Pure workspace-operation semantics (#280 "Workspace operation semantics", +// Phase 2 of #280 / issue #284). These build the NEXT candidate +// StoredWorkspaceV1; the repository (workspace-repository.ts) is what commits +// one atomically. The file-menu UI and the transactional bundle import planner +// are Phase 5 (#287) — this module is only the repository-level candidate +// construction + workspace ID generation those UIs will drive. +// +// Pure: no DOM, no storage, no crypto import — the ID generator is injected so +// the same call is deterministic in tests and unguessable in production. + +import type { SavedQueryV2, StoredWorkspaceV1 } from '../generated/json-schema.types.js'; + +export const CURRENT_STORAGE_VERSION = 1 as const; + +/** Fallback workspace name when none is supplied. Mirrors state.ts's + * `DEFAULT_LIBRARY_NAME` by value (state.ts is a forbidden import for the + * workspace layer, so the constant is duplicated deliberately, not shared). */ +export const DEFAULT_WORKSPACE_NAME = 'SQL Library'; + +/** A workspace-ID minter — injected so production wires it to + * `crypto.randomUUID()` (unguessable) and tests pass a counter (deterministic). + * Called once per operation that needs a fresh identity. */ +export type WorkspaceIdGen = () => string; + +const normalizeName = (name: unknown): string => + (typeof name === 'string' && name.trim() ? name : DEFAULT_WORKSPACE_NAME); + +/** Generate a fresh workspace ID. Two imported files with the same name still + * produce distinct IDs because identity always comes from a fresh `genId()` + * call, never from the file's name (#280). */ +export const generateWorkspaceId = (genId: WorkspaceIdGen): string => genId(); + +/** Rename the workspace: changes `name` only. It never renames the Dashboard — + * the Dashboard keeps its own `title` (#280 "Rename workspace changes + * workspace.name; it does not automatically rename an existing Dashboard"). */ +export function renameWorkspace(workspace: StoredWorkspaceV1, name: unknown): StoredWorkspaceV1 { + return { ...workspace, name: normalizeName(name) }; +} + +/** Create a brand-new empty workspace: a fresh generated ID, the given name, + * no queries, and no Dashboard. The caller confirms any data-loss replacement + * of the current workspace before committing this (#280). */ +export function createNewWorkspace(genId: WorkspaceIdGen, name?: unknown): StoredWorkspaceV1 { + return { + storageVersion: CURRENT_STORAGE_VERSION, + id: genId(), + name: normalizeName(name), + queries: [], + dashboard: null, + }; +} + +/** Import a query collection into the workspace: modifies `queries` ONLY. The + * Dashboard is left byte-for-byte unchanged — imported `spec.favorite` flags + * never add Dashboard tiles (#280 "Import queries modifies the query + * collection only; imported favorite flags do not add Dashboard tiles"). The + * incoming collection replaces the workspace's queries; conflict resolution + * (use-existing/copy/replace/skip) and ID remapping are the Phase-5 import + * planner's job, applied before a candidate reaches here. */ +export function importQueries( + workspace: StoredWorkspaceV1, queries: readonly SavedQueryV2[], +): StoredWorkspaceV1 { + return { ...workspace, queries: [...queries], dashboard: workspace.dashboard }; +} + +/** Replace the workspace's queries AND Dashboard atomically while preserving + * its identity (`id`/`name`) — the repository-level primitive behind "Replace + * from bundle" and "Replace workspace" (#280). The parsing/validation of an + * external bundle and the dependency-closure/ID-remapping that select the + * `queries`/`dashboard` passed here are the Phase-5 import planner's job; this + * only assembles the candidate the repository then commits in one transaction. */ +export function replaceWorkspaceContents( + workspace: StoredWorkspaceV1, + contents: { queries: readonly SavedQueryV2[]; dashboard: StoredWorkspaceV1['dashboard'] }, +): StoredWorkspaceV1 { + return { + ...workspace, + queries: [...contents.queries], + dashboard: contents.dashboard, + }; +} diff --git a/src/workspace/workspace-repository.ts b/src/workspace/workspace-repository.ts new file mode 100644 index 00000000..8d8e7888 --- /dev/null +++ b/src/workspace/workspace-repository.ts @@ -0,0 +1,108 @@ +// WorkspaceRepository — atomic StoredWorkspaceV1 aggregate persistence (#280 +// "WorkspaceRepository", Phase 2 of #280 / issue #284). Sits behind the +// injected `WorkspaceStore` seam (workspace-store.types.ts) so it is pure over +// an in-memory fake in tests and never imports a concrete IndexedDB. +// +// Atomicity + last-commit-wins (#280 "Multi-tab authoring policy"): `commit` +// validates the COMPLETE candidate through the Phase-1 whole-workspace +// pipeline (stored-workspace.ts) BEFORE any write, then does ONE atomic +// `store.write` of the canonical encoding. It is atomic replacement, NOT +// compare-and-swap — the last successful commit wins, and because one write +// replaces the entire record there is no interleaving that could produce a +// partially-mixed workspace. Committed application state is published (the +// returned `workspace`) only after persistence succeeds; a failed write leaves +// the previously stored workspace intact, does not touch the caller's draft, +// and never increments any revision (the repository never mutates revision — +// the authoring session that built the candidate owns that, Phase 3). + +import { + decodeStoredWorkspaceJson, encodeStoredWorkspaceJson, +} from './stored-workspace.js'; +import type { WorkspaceStore } from './workspace-store.types.js'; +import { diagnostic } from '../dashboard/model/workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnostics.js'; +import type { JsonSchemaValidationService } from '../core/json-schema-validation.js'; +import type { StoredWorkspaceV1 } from '../generated/json-schema.types.js'; + +/** The #280 commit-result union: the published committed workspace and its + * Dashboard revision on success, or the sorted validation/persistence + * diagnostics on failure. */ +export type WorkspaceCommitResult = + | { ok: true; workspace: StoredWorkspaceV1; dashboardRevision: number | null } + | { ok: false; diagnostics: WorkspaceDiagnostic[] }; + +/** The #280 repository contract. Every method is async because the backing + * store (IndexedDB) is async. */ +export interface WorkspaceRepository { + /** Load the current aggregate, or `null` when none is persisted. A stored + * record that no longer validates also reads as `null` (a corrupt aggregate + * is not returned as if valid); the migration marker keys on raw record + * existence via the store, not on this method, so it never re-runs over a + * corrupt-but-present record. */ + loadCurrent(): Promise; + /** Validate the complete candidate, then atomically replace the persisted + * aggregate. Publishes committed state only after the write succeeds. */ + commit(candidate: StoredWorkspaceV1): Promise; + /** Delete the persisted aggregate. */ + clearCurrent(): Promise; +} + +export interface WorkspaceRepositoryDeps { + /** The injected persistence seam (concrete IndexedDB adapter in production, + * an in-memory fake in tests). */ + store: WorkspaceStore; + /** Optional override of the compiled schema-validation service the Phase-1 + * codec uses (tests inject a stub); production uses the generated default. */ + validationService?: JsonSchemaValidationService; +} + +const message = (error: unknown): string => + (error instanceof Error ? error.message : String(error)); + +/** Build a `WorkspaceRepository` bound to `deps`. Trivial constructor — no I/O + * happens until a method is called, so constructing one with a not-yet-usable + * store (e.g. a browser without IndexedDB) never throws. */ +export function createWorkspaceRepository(deps: WorkspaceRepositoryDeps): WorkspaceRepository { + const { store, validationService } = deps; + const codecOptions = validationService ? { validationService } : {}; + + async function loadCurrent(): Promise { + const text = await store.read(); + if (text === null) return null; + const decoded = decodeStoredWorkspaceJson(text, codecOptions); + return decoded.ok ? decoded.value : null; + } + + async function commit(candidate: StoredWorkspaceV1): Promise { + // Validate + canonically encode the WHOLE candidate before touching the + // store; invalid candidates never reach persistence. + const encoded = encodeStoredWorkspaceJson(candidate, codecOptions); + if (!encoded.ok) return { ok: false, diagnostics: encoded.diagnostics }; + try { + await store.write(encoded.value); + } catch (error) { + // Failed persistence: the previously stored workspace is untouched, and + // no revision is incremented. The caller keeps its dirty draft to retry. + return { + ok: false, + diagnostics: [diagnostic([], 'workspace-persist-failed', + `Persisting the workspace failed: ${message(error)}`)], + }; + } + // Publish only after the write succeeds. The canonical text we just wrote + // is guaranteed valid JSON, so parse it back as the normalized published + // snapshot rather than re-running validation. + const workspace = JSON.parse(encoded.value) as StoredWorkspaceV1; + return { + ok: true, + workspace, + dashboardRevision: workspace.dashboard === null ? null : workspace.dashboard.revision, + }; + } + + function clearCurrent(): Promise { + return store.clear(); + } + + return { loadCurrent, commit, clearCurrent }; +} diff --git a/src/workspace/workspace-store.types.ts b/src/workspace/workspace-store.types.ts new file mode 100644 index 00000000..bee4a48a --- /dev/null +++ b/src/workspace/workspace-store.types.ts @@ -0,0 +1,28 @@ +// The injected persistence seam the WorkspaceRepository (Phase 2 of #280, +// issue #284) sits behind. Exactly like the fetch/crypto/storage seams: the +// repository logic depends only on this narrow async interface, never on a +// concrete IndexedDB, so it is unit-testable with a plain in-memory fake. +// +// The aggregate is ONE record — the whole StoredWorkspaceV1 serialized as its +// canonical JSON text. `write` is an atomic full-record replacement (one +// IndexedDB readwrite transaction in the real adapter), which is what gives +// the repository genuine last-commit-wins semantics: a commit can never leave +// a half-mixed aggregate, because there is no read-modify-write across two +// transactions — one `write` replaces the entire record or nothing at all. +// +// Type-only (ADR-0002 seam contract) — no executable statements, excluded from +// the coverage gate like every other `*.types.ts`. +export interface WorkspaceStore { + /** Read the single persisted aggregate record's canonical JSON text, or + * `null` when no record exists. A rejected promise means the read failed + * (storage unavailable); it is distinct from a resolved `null` (no record), + * and the migration marker keys on record existence via this method. */ + read(): Promise; + /** Atomically replace the single aggregate record with `text` in one + * transaction. Rejects when persistence fails; on rejection the previously + * stored record is left intact. */ + write(text: string): Promise; + /** Delete the aggregate record (idempotent — clearing an absent record + * resolves). */ + clear(): Promise; +} diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index bc261351..fd206813 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -40,6 +40,7 @@ import type { WorkbenchSession } from '../../src/ui/workbench/workbench-session. import type { WorkbenchParameterSession } from '../../src/application/workbench-parameter-session.js'; import type { ExportService } from '../../src/application/export-service.js'; import type { QueryDocumentSession } from '../../src/application/query-document-session.js'; +import type { WorkspaceRepository } from '../../src/workspace/workspace-repository.js'; import type { SavedQueryService, CreateSavedResult, CommitLinkedResult, ShareResult, } from '../../src/application/saved-query-service.js'; @@ -242,6 +243,12 @@ const appDefaults: App = { params: paramsDefaults, graph: graphDefaults, prefs: prefsDefaults, + workspace: { + loadCurrent: async () => null, + commit: async () => ({ ok: true, workspace: {} as never, dashboardRevision: null }), + clearCurrent: async () => {}, + }, + loadDashboardWorkspace: async () => null, sqlEditor: {} as App['sqlEditor'], specEditor: {} as App['specEditor'], CodeViewer: () => ({ setText: () => {}, setLanguage: () => {}, setWrap: () => {}, focus: () => {}, destroy: () => {} }), @@ -316,7 +323,11 @@ const appDefaults: App = { * convenience key (#276 Phase 5 deleted `App.chCtx` — `app.conn.chCtx` is * the only live alias now), kept so existing `makeApp({ chCtx: {...} })` * call sites don't all need to become `conn: { chCtx: {...} }`. */ -type AppOverrides = Partial> & { +type AppOverrides = Partial> & { + /** Partial like the rest (#286 Phase 4) — the Dashboard viewer reads a + * StoredWorkspaceV1 through `loadDashboardWorkspace`/`workspace.loadCurrent`; + * a dashboard test overrides just the method(s) it drives. */ + workspace?: Partial; dom?: Partial; chCtx?: Partial; actions?: Partial; @@ -513,6 +524,7 @@ export function makeApp>(override exports: { ...exportsDefaults, ...(overrides.exports ?? {}) }, graph: { ...graphDefaults, ...(overrides.graph ?? {}) }, prefs: { ...prefsDefaults, ...base.prefs, ...(overrides.prefs ?? {}) }, + workspace: { ...appDefaults.workspace, ...(overrides.workspace ?? {}) }, }; // Assignability check only (a variable reference, not a fresh literal, so // this never trips an excess-property error) — `merged`'s own inferred type diff --git a/tests/helpers/saved-query.ts b/tests/helpers/saved-query.ts index 9a61cdd9..ca1ad445 100644 --- a/tests/helpers/saved-query.ts +++ b/tests/helpers/saved-query.ts @@ -8,7 +8,7 @@ // `specVersion: 1` inside that catch-all, which bypasses the shorthand // entirely and hands back a fully custom `spec` untouched (`structuredClone`d) // for tests exercising a pre-built Spec document directly. -import type { Dashboard, Panel, QuerySpecV1, SavedQueryV2 } from '../../src/generated/json-schema.types.js'; +import type { Panel, QueryDashboardPresentationV1, QuerySpecV1, SavedQueryV2 } from '../../src/generated/json-schema.types.js'; export interface SavedQueryFixture { id?: string; @@ -18,7 +18,7 @@ export interface SavedQueryFixture { description?: string; view?: QuerySpecV1['view']; panel?: Panel; - dashboard?: Dashboard; + dashboard?: QueryDashboardPresentationV1; spec?: Record; // Arbitrary extension fields (e.g. `extension`, or `specVersion: 1` — see // above) — QuerySpecV1's own index signature accepts the same. diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index d22f3c5f..199b28f0 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -355,6 +355,20 @@ describe('createApp basics', () => { const app = createApp(env({ specValidators: service })); expect(app.specValidators).toBe(service); // identity — the seam's own service passes straight through }); + it('constructs the atomic StoredWorkspaceV1 repository (#284) behind the injected IndexedDB seam', () => { + // Default env: no env.indexedDB → falls back to win.indexedDB (absent under + // happy-dom). The repository still constructs (lazy — no DB opened yet) and + // exposes the full #280 contract. + const app = createApp(env()); + expect(typeof app.workspace.loadCurrent).toBe('function'); + expect(typeof app.workspace.commit).toBe('function'); + expect(typeof app.workspace.clearCurrent).toBe('function'); + // Injecting a factory exercises the left side of `env.indexedDB || + // win.indexedDB`; the repository still constructs lazily (no DB is opened + // until an operation runs — the concrete adapter is covered on its own). + const app2 = createApp(env({ indexedDB: {} as IDBFactory })); + expect(typeof app2.workspace.commit).toBe('function'); + }); it('reads the stored token and derives identity', () => { const app = createApp(env()); expect(app.conn.token()).toBe(validToken); diff --git a/tests/unit/bundle-order.test.ts b/tests/unit/bundle-order.test.ts new file mode 100644 index 00000000..a72f17c9 --- /dev/null +++ b/tests/unit/bundle-order.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + arrangeBundleResources, dashboardDependencyQueryIds, + orderBundleQueries, sortDashboardsCanonically, +} from '../../src/dashboard/model/bundle-order.js'; + +const dashboard = (id: string, tiles: string[], filterSources: string[] = []) => ({ + id, + tiles: tiles.map((queryId, index) => ({ id: `${id}-t${index}`, queryId })), + filters: filterSources.map((sourceQueryId, index) => ({ id: `${id}-f${index}`, parameter: 'p', sourceQueryId })), +}); + +describe('dashboardDependencyQueryIds', () => { + it('emits tile queries first in semantic order, then filter sources, once each', () => { + const d = dashboard('d1', ['q3', 'q1', 'q3'], ['q1', 'q9']); + expect(dashboardDependencyQueryIds(d)).toEqual(['q3', 'q1', 'q9']); + }); + + it('tolerates missing/invalid structures', () => { + expect(dashboardDependencyQueryIds(null)).toEqual([]); + expect(dashboardDependencyQueryIds({})).toEqual([]); + expect(dashboardDependencyQueryIds({ tiles: [null, { queryId: 5 }, { queryId: 'q1' }], filters: [null, { sourceQueryId: 7 }] })) + .toEqual(['q1']); + }); +}); + +describe('sortDashboardsCanonically', () => { + it('sorts by normalized id lexicographically, keeping id-less dashboards last stably', () => { + const a = dashboard('bravo', []); + const b = dashboard('alpha', []); + const c = { tiles: [] }; // no id + const d = { id: 5 }; // non-string id → treated as id-less + expect(sortDashboardsCanonically([a, b, c, d]).map((x) => (x as { id?: unknown }).id)) + .toEqual(['alpha', 'bravo', undefined, 5]); + }); + + it('normalizes ids (NFC) before comparing and does not mutate the input', () => { + const composed = { id: 'é' }; // é precomposed + const decomposed = { id: 'é' }; // é decomposed → NFC equal + const input = [composed, decomposed]; + const sorted = sortDashboardsCanonically(input); + expect(sorted).toHaveLength(2); + expect(input[0]).toBe(composed); // untouched + }); +}); + +describe('orderBundleQueries', () => { + const q = (id: string) => ({ id }); + it('orders by first reference across dashboards, then unreferenced in catalog order', () => { + const queries = [q('q1'), q('q2'), q('q3'), q('q4')]; + const dashboards = [dashboard('d1', ['q3'], ['q2']), dashboard('d2', ['q3', 'q1'])]; + // First reference: q3 (d1 tile), q2 (d1 filter), q1 (d2 tile); q4 unreferenced last. + expect(orderBundleQueries(queries, dashboards).map((x) => x.id)).toEqual(['q3', 'q2', 'q1', 'q4']); + }); + + it('emits each query once and ignores references to unknown queries', () => { + const queries = [q('q1'), q('q1'), q('q2')]; // duplicate catalog entry + const dashboards = [dashboard('d1', ['q2', 'missing'])]; + expect(orderBundleQueries(queries, dashboards).map((x) => x.id)).toEqual(['q2', 'q1']); + }); +}); + +describe('arrangeBundleResources', () => { + it('applies canonical dashboard sort then first-reference query order', () => { + const q = (id: string) => ({ id }); + const queries = [q('qa'), q('qb'), q('qc')]; + const dashboards = [dashboard('zeta', ['qc']), dashboard('alpha', ['qb'])]; + const arranged = arrangeBundleResources({ queries, dashboards }); + expect(arranged.dashboards.map((d) => d.id)).toEqual(['alpha', 'zeta']); + // alpha (qb) referenced first, then zeta (qc), then unreferenced qa. + expect(arranged.queries.map((x) => x.id)).toEqual(['qb', 'qc', 'qa']); + }); +}); diff --git a/tests/unit/canonical-json.test.ts b/tests/unit/canonical-json.test.ts new file mode 100644 index 00000000..9e20fc4a --- /dev/null +++ b/tests/unit/canonical-json.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { + canonicalEqual, canonicalJson, + DASHBOARD_DOCUMENT_SHAPE, PORTABLE_BUNDLE_SHAPE, QUERY_SPEC_SHAPE, + SAVED_QUERY_SHAPE, STORED_WORKSPACE_SHAPE, FLOW_LAYOUT_SHAPE, +} from '../../src/dashboard/model/canonical-json.js'; +import type { CanonicalShape } from '../../src/dashboard/model/canonical-json.js'; + +describe('canonicalJson primitives and formatting', () => { + it('encodes primitives and matches JSON.stringify 2-space formatting for open objects', () => { + expect(canonicalJson(null)).toBe('null'); + expect(canonicalJson(42)).toBe('42'); + expect(canonicalJson('hi')).toBe('"hi"'); + expect(canonicalJson(true)).toBe('true'); + expect(canonicalJson([])).toBe('[]'); + expect(canonicalJson({})).toBe('{}'); + const value = { b: 1, a: [2, 3], c: { z: 1, y: 2 } }; + // Open object → keys sorted lexicographically, recursively. + expect(canonicalJson(value)).toBe('{\n "a": [\n 2,\n 3\n ],\n "b": 1,\n "c": {\n "y": 2,\n "z": 1\n }\n}'); + }); + + it('skips non-JSON members like JSON.stringify and throws on a non-JSON root', () => { + expect(canonicalJson({ a: undefined, b: 1, c: () => 0 })).toBe('{\n "b": 1\n}'); + // Non-JSON array member becomes null (JSON.stringify parity). + expect(canonicalJson([undefined])).toBe('[\n null\n]'); + expect(() => canonicalJson(undefined)).toThrow('non-JSON root'); + expect(() => canonicalJson(() => 0)).toThrow('non-JSON root'); + }); +}); + +describe('canonical field ordering', () => { + it('orders schema-defined fields canonically and key-sorts unknown extras after them', () => { + const shape: CanonicalShape = { order: ['first', 'second'] }; + expect(canonicalJson({ zeta: 1, second: 2, alpha: 3, first: 4 }, shape)) + .toBe('{\n "first": 4,\n "second": 2,\n "alpha": 3,\n "zeta": 1\n}'); + // Absent declared fields are simply skipped. + expect(canonicalJson({ second: 2 }, shape)).toBe('{\n "second": 2\n}'); + }); + + it('sorts map-like keys lexicographically, including integer-like keys as strings', () => { + const shape: CanonicalShape = { map: { order: ['span', 'height'] } }; + // Integer-like map keys must sort as strings ("10" < "2"), never numerically. + const encoded = canonicalJson({ '2': { height: 'medium', span: 1 }, '10': { span: 2 } }, shape); + expect(encoded).toBe('{\n "10": {\n "span": 2\n },\n "2": {\n "span": 1,\n "height": "medium"\n }\n}'); + }); + + it('produces identical output for differently-ordered equivalent objects', () => { + const a = { b: { d: 4, c: 3 }, a: [1, { y: 2, x: 1 }] }; + const b = { a: [1, { x: 1, y: 2 }], b: { c: 3, d: 4 } }; + expect(canonicalJson(a)).toBe(canonicalJson(b)); + expect(canonicalEqual(a, b)).toBe(true); + expect(canonicalEqual({ a: 1 }, { a: 2 })).toBe(false); + }); +}); + +describe('documented shapes', () => { + it('orders a query Spec panel/dashboard, keeping style + variant patches key-sorted', () => { + const spec = { + dashboard: { + sizeHints: { aspectRatio: 2, preferred: 'wide' }, + variants: { zoom: { cfg: { zBefore: 1, aAfter: 2 } }, alpha: { title: 't' } }, + role: 'panel', + }, + panel: { fieldConfig: { columns: { z: { unit: '%' }, a: { hidden: true } } }, cfg: { y: [1], type: 'bar', x: 0 } }, + name: 'Q', + }; + const encoded = canonicalJson(spec, QUERY_SPEC_SHAPE); + // name before panel before dashboard; cfg type/x/y order; variants key-sorted; + // sizeHints preferred before aspectRatio; open variant patch key-sorted. + expect(encoded.indexOf('"name"')).toBeLessThan(encoded.indexOf('"panel"')); + expect(encoded.indexOf('"panel"')).toBeLessThan(encoded.indexOf('"dashboard"')); + expect(encoded.indexOf('"type": "bar"')).toBeLessThan(encoded.indexOf('"x": 0')); + expect(encoded.indexOf('"alpha"')).toBeLessThan(encoded.indexOf('"zoom"')); + expect(encoded.indexOf('"aAfter"')).toBeLessThan(encoded.indexOf('"zBefore"')); + expect(encoded.indexOf('"preferred"')).toBeLessThan(encoded.indexOf('"aspectRatio"')); + // field config column map keys sorted lexicographically. + expect(encoded.indexOf('"a": {')).toBeLessThan(encoded.indexOf('"z": {')); + }); + + it('orders a full stored workspace and bundle deterministically regardless of input order', () => { + const dashboard = { + tiles: [{ queryId: 'q1', id: 't1' }], + filters: [], + layout: { version: 1, type: 'flow', preset: 'full-width', items: { t1: { height: 'medium', span: 1 } } }, + revision: 1, title: 'D', id: 'd1', documentVersion: 1, + }; + const query = { spec: { name: 'Q' }, specVersion: 1, sql: 'SELECT 1', id: 'q1' }; + const workspaceA = { dashboard, queries: [query], name: 'W', id: 'w1', storageVersion: 1 }; + const workspaceB = { storageVersion: 1, id: 'w1', name: 'W', queries: [query], dashboard }; + expect(canonicalJson(workspaceA, STORED_WORKSPACE_SHAPE)) + .toBe(canonicalJson(workspaceB, STORED_WORKSPACE_SHAPE)); + const ws = canonicalJson(workspaceA, STORED_WORKSPACE_SHAPE); + expect(ws.indexOf('"storageVersion"')).toBeLessThan(ws.indexOf('"queries"')); + expect(ws.indexOf('"documentVersion"')).toBeLessThan(ws.indexOf('"revision"')); + + const bundle = { + dashboards: [dashboard], queries: [query], exportedAt: 'x', + version: 1, format: 'altinity-sql-browser/portable-bundle', + $schema: 's', metadata: { description: 'd', name: 'n' }, + }; + const b = canonicalJson(bundle, PORTABLE_BUNDLE_SHAPE); + expect(b.indexOf('"$schema"')).toBeLessThan(b.indexOf('"format"')); + expect(b.indexOf('"queries"')).toBeLessThan(b.indexOf('"dashboards"')); + expect(b.indexOf('"name"')).toBeLessThan(b.indexOf('"description"')); + }); + + it('orders flow layout, saved query, and dashboard document field-canonically', () => { + const flow = canonicalJson({ items: { t1: { height: 'large', span: 3 } }, preset: 'report', version: 1, type: 'flow' }, FLOW_LAYOUT_SHAPE); + expect(flow.indexOf('"type"')).toBeLessThan(flow.indexOf('"version"')); + expect(flow.indexOf('"span"')).toBeLessThan(flow.indexOf('"height"')); + const sq = canonicalJson({ spec: {}, specVersion: 1, sql: 's', id: 'q' }, SAVED_QUERY_SHAPE); + expect(sq.indexOf('"id"')).toBeLessThan(sq.indexOf('"sql"')); + const doc = canonicalJson({ + tiles: [{ presentation: { override: { b: 1 }, variant: 'v' }, queryId: 'q', id: 't' }], + filters: [{ defaultActive: true, id: 'f', parameter: 'p' }], + layout: { type: 'flow', version: 1, config: { z: 1, a: 2 }, fallback: { type: 'flow', version: 1, preset: 'full-width', items: {} } }, + revision: 2, title: 'T', id: 'd', documentVersion: 1, + }, DASHBOARD_DOCUMENT_SHAPE); + expect(doc.indexOf('"variant"')).toBeLessThan(doc.indexOf('"override"')); + expect(doc.indexOf('"id": "f"')).toBeLessThan(doc.indexOf('"parameter"')); + // open layout config key-sorted. + expect(doc.indexOf('"a": 2')).toBeLessThan(doc.indexOf('"z": 1')); + }); +}); diff --git a/tests/unit/dashboard-authoring-session.test.ts b/tests/unit/dashboard-authoring-session.test.ts new file mode 100644 index 00000000..9371a0f4 --- /dev/null +++ b/tests/unit/dashboard-authoring-session.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest'; +import { + createDashboardAuthoringSession, +} from '../../src/dashboard/application/dashboard-authoring-session.js'; +import { createWorkspaceRepository } from '../../src/workspace/workspace-repository.js'; +import { jsonSchemaValidationService } from '../../src/core/library-codec.js'; +import { querySpecSchemaService } from '../../src/core/spec-schema.js'; +import { PORTABLE_LIMITS } from '../../src/dashboard/model/portable-limits.js'; +import type { StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; + +const panelQuery = (id: string) => ({ + id, sql: 'SELECT a,b', specVersion: 1 as const, + spec: { name: id, panel: { cfg: { type: 'bar', x: 0, y: [1] } } }, +}); +const filterQuery = (id: string) => ({ + id, sql: "SELECT ['a','b'] AS c", specVersion: 1 as const, spec: { name: id, dashboard: { role: 'filter' } }, +}); + +const emptyDash = () => ({ + documentVersion: 1 as const, id: 'dash', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [], +}); + +const workspaceFixture = (over: Partial = {}): StoredWorkspaceV1 => ({ + storageVersion: 1, id: 'ws', name: 'WS', + queries: [panelQuery('q1'), panelQuery('q2')], dashboard: emptyDash(), ...over, +} as StoredWorkspaceV1); + +const makeStore = () => { + let text: string | null = null; + let failWrite = false; + return { + store: { + read: async () => text, + write: async (t: string) => { if (failWrite) throw new Error('disk full'); text = t; }, + clear: async () => { text = null; }, + }, + setFailWrite: (v: boolean) => { failWrite = v; }, + }; +}; + +interface SessionOpts { + workspace?: StoredWorkspaceV1; + nowISO?: () => string; + withServices?: boolean; +} +const makeSession = (opts: SessionOpts = {}) => { + const backing = makeStore(); + const repository = createWorkspaceRepository({ store: backing.store }); + let n = 0; + const session = createDashboardAuthoringSession({ + workspace: opts.workspace ?? workspaceFixture(), + repository, genId: () => `g${++n}`, nowISO: opts.nowISO, + ...(opts.withServices ? { validationService: jsonSchemaValidationService, schemaService: querySpecSchemaService } : {}), + }); + return { session, ...backing }; +}; + +describe('DashboardAuthoringSession — atomic commands', () => { + it('replaces the draft and increments draftVersion once on success', async () => { + const { session } = makeSession(); + const result = await session.execute({ type: 'add-query', queryId: 'q1' }); + expect(result.ok).toBe(true); + if (result.ok) expect((result.value as { tileId: string }).tileId).toBe('g1'); + expect(result.draftVersion).toBe(1); + expect(session.state.value.draftVersion).toBe(1); + expect(session.state.value.dirty).toBe(true); + expect(session.state.value.document.tiles).toHaveLength(1); + expect(session.state.value.diagnostics).toEqual([]); + }); + + it('leaves the draft byte-for-byte unchanged on a failed command', async () => { + const { session } = makeSession(); + const before = JSON.stringify(session.state.value.document); + const result = await session.execute({ type: 'add-query', queryId: 'gone' }); + expect(result.ok).toBe(false); + expect(JSON.stringify(session.state.value.document)).toBe(before); + expect(session.state.value.draftVersion).toBe(0); + expect(session.state.value.diagnostics.length).toBeGreaterThan(0); + if (!result.ok) expect(result.draftVersion).toBe(0); + }); + + it('rejects a stale expectedDraftVersion without mutating the draft', async () => { + const { session } = makeSession(); + await session.execute({ type: 'add-query', queryId: 'q1' }); + const stale = await session.execute({ type: 'add-query', queryId: 'q2' }, { expectedDraftVersion: 0 }); + expect(stale.ok).toBe(false); + if (!stale.ok) expect(stale.diagnostics[0].code).toBe('dashboard-command-stale'); + expect(session.state.value.draftVersion).toBe(1); + expect(session.state.value.document.tiles).toHaveLength(1); + }); + + it('does not clamp an out-of-range move index and fails a duplicate default instance', async () => { + const { session } = makeSession(); + await session.execute({ type: 'add-query', queryId: 'q1' }); + await session.execute({ type: 'add-query', queryId: 'q2' }); + const badMove = await session.execute({ type: 'move-tile', tileId: 'g1', toIndex: 9 }); + expect(badMove.ok).toBe(false); + if (!badMove.ok) expect(badMove.diagnostics[0].code).toBe('dashboard-command-index-out-of-range'); + + const dup = await session.execute({ type: 'add-query', queryId: 'q1' }); + expect(dup.ok).toBe(false); + if (!dup.ok) expect(dup.diagnostics[0].code).toBe('dashboard-command-duplicate-instance'); + }); + + it('runs presentation resolution as part of validation (override deletes a required field)', async () => { + const { session } = makeSession({ withServices: true }); + const added = await session.execute({ type: 'add-query', queryId: 'q1' }); + expect(added.ok).toBe(true); + const before = JSON.stringify(session.state.value.document); + const bad = await session.execute({ + type: 'update-tile', tileId: 'g1', patch: { presentation: { override: { cfg: { x: null } } } }, + }); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.diagnostics.some((d) => d.code === 'schema-required')).toBe(true); + expect(JSON.stringify(session.state.value.document)).toBe(before); + }); + + // These prove the atomic guarantee for the DELEGATED-validation failure + // class — failures raised by validateStoredWorkspaceDocument (structural / + // role / reference / limit), i.e. the stage between applyCommand and + // presentation resolution. + it('rejects add-query on an incompatible-role query without mutating the draft', async () => { + const { session } = makeSession({ workspace: workspaceFixture({ queries: [panelQuery('q1'), filterQuery('f1')] as never }) }); + const before = JSON.stringify(session.state.value.document); + const result = await session.execute({ type: 'add-query', queryId: 'f1' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.diagnostics.some((d) => d.code === 'dashboard-tile-role-incompatible')).toBe(true); + expect(JSON.stringify(session.state.value.document)).toBe(before); + expect(session.state.value.draftVersion).toBe(0); + }); + + it('rejects exceeding the tile limit without mutating the draft', async () => { + const full = Array.from({ length: PORTABLE_LIMITS.maxTilesPerDashboard }, (_, i) => ({ id: `t${i}`, queryId: 'q1' })); + const { session } = makeSession({ workspace: workspaceFixture({ dashboard: { ...emptyDash(), tiles: full } as never }) }); + const before = JSON.stringify(session.state.value.document); + const result = await session.execute({ type: 'add-query-instance', queryId: 'q1' }); + expect(result.ok).toBe(false); + // The tile-count bound is enforced first by the schema's maxItems + // (schema-array-size); the semantic limit-tile-count re-check backs it up. + if (!result.ok) { + expect(result.diagnostics.some((d) => d.code === 'schema-array-size' || d.code === 'limit-tile-count')).toBe(true); + } + expect(JSON.stringify(session.state.value.document)).toBe(before); + expect(session.state.value.draftVersion).toBe(0); + }); + + it('rejects add-query-instance with an undeclared variant name without mutating the draft', async () => { + const { session } = makeSession(); + const before = JSON.stringify(session.state.value.document); + const result = await session.execute({ type: 'add-query-instance', queryId: 'q1', variant: 'nope' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.diagnostics.some((d) => d.code === 'dashboard-variant-missing')).toBe(true); + expect(JSON.stringify(session.state.value.document)).toBe(before); + expect(session.state.value.draftVersion).toBe(0); + }); + + it('fails a change-layout the plugin cannot load, leaving the previous layout intact', async () => { + const { session } = makeSession(); + const result = await session.execute({ type: 'change-layout', layout: { type: 'grid', version: 9 } as never }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.diagnostics[0].code).toBe('dashboard-layout-load-failed'); + expect(session.state.value.document.layout.type).toBe('flow'); + }); +}); + +describe('DashboardAuthoringSession — revision semantics and commit', () => { + it('increments the persisted revision once per successful commit, batching several draft changes', async () => { + const { session } = makeSession(); + // The loaded dashboard is at revision 1, so the first new commit is 2. + await session.execute({ type: 'add-query', queryId: 'q1' }); + const c1 = await session.commit(); + expect(c1.ok && c1.dashboardRevision).toBe(2); + expect(session.state.value.dirty).toBe(false); + + await session.execute({ type: 'add-query', queryId: 'q2' }); + await session.execute({ type: 'move-tile', tileId: 'g1', toIndex: 1 }); + const c2 = await session.commit(); + expect(c2.ok && c2.dashboardRevision).toBe(3); + }); + + it('leaves the draft dirty and the revision unchanged when persistence fails, and supports retry', async () => { + const backing = makeStore(); + const repository = createWorkspaceRepository({ store: backing.store }); + let n = 0; + const session = createDashboardAuthoringSession({ workspace: workspaceFixture(), repository, genId: () => `g${++n}` }); + await session.execute({ type: 'add-query', queryId: 'q1' }); + backing.setFailWrite(true); + const failed = await session.commit(); + expect(failed.ok).toBe(false); + expect(session.state.value.dirty).toBe(true); + backing.setFailWrite(false); + const retry = await session.commit(); + expect(retry.ok && retry.dashboardRevision).toBe(2); // the failed attempt did not consume a revision + }); + + it('starts an empty flow Dashboard (revision 1) when the workspace has none', async () => { + const { session } = makeSession({ workspace: workspaceFixture({ dashboard: null }) }); + expect(session.state.value.document.id).toBe('g1'); + expect(session.state.value.document.revision).toBe(1); + await session.execute({ type: 'add-query', queryId: 'q1' }); + const committed = await session.commit(); + expect(committed.ok && committed.dashboardRevision).toBe(1); + }); +}); + +describe('DashboardAuthoringSession — membership, export, lifecycle', () => { + it('toggles membership through typed commands and dual-writes spec.favorite', async () => { + const { session } = makeSession(); + await session.toggleMembership('q1'); + expect(session.state.value.document.tiles).toHaveLength(1); + const on = await session.commit(); + const favOn = on.ok && on.workspace.queries.find((q) => q.id === 'q1'); + expect(favOn && favOn.spec.favorite).toBe(true); + + await session.toggleMembership('q1'); + expect(session.state.value.document.tiles).toHaveLength(0); + const off = await session.commit(); + const favOff = off.ok && off.workspace.queries.find((q) => q.id === 'q1'); + expect(favOff && favOff.spec.favorite).toBe(false); + }); + + it('builds a portable bundle of only the dependency queries and never increments revision', async () => { + const fixed = makeSession({ nowISO: () => '2020-01-01T00:00:00Z' }); + await fixed.session.execute({ type: 'add-query', queryId: 'q1' }); + const bundle = fixed.session.createPortableBundle(); + expect(bundle.format).toBe('altinity-sql-browser/portable-bundle'); + expect(bundle.exportedAt).toBe('2020-01-01T00:00:00Z'); + expect(bundle.queries.map((q) => q.id)).toEqual(['q1']); + expect(bundle.dashboards).toHaveLength(1); + // Export did not bump the committed revision: the next commit is 2 (one + // past the loaded revision 1), not 3. + const committed = await fixed.session.commit(); + expect(committed.ok && committed.dashboardRevision).toBe(2); + + // Default nowISO produces a real ISO timestamp. + const dflt = makeSession(); + expect(() => new Date(dflt.session.createPortableBundle().exportedAt).toISOString()).not.toThrow(); + }); + + it('tracks the selected tile and rejects work after destroy', async () => { + const { session } = makeSession(); + session.setSelectedTile('t1'); + expect(session.state.value.selectedTileId).toBe('t1'); + session.setSelectedTile(null); + expect(session.state.value.selectedTileId).toBeNull(); + + session.destroy(); + const execAfter = await session.execute({ type: 'add-query', queryId: 'q1' }); + expect(execAfter.ok).toBe(false); + if (!execAfter.ok) expect(execAfter.diagnostics[0].code).toBe('dashboard-session-destroyed'); + const commitAfter = await session.commit(); + expect(commitAfter.ok).toBe(false); + if (!commitAfter.ok) expect(commitAfter.diagnostics[0].code).toBe('dashboard-session-destroyed'); + }); +}); diff --git a/tests/unit/dashboard-boundaries.test.js b/tests/unit/dashboard-boundaries.test.js new file mode 100644 index 00000000..3db5725f --- /dev/null +++ b/tests/unit/dashboard-boundaries.test.js @@ -0,0 +1,86 @@ +// Dependency-boundary tests (#286): the Dashboard model and application layers +// — including the DashboardViewerSession — must be constructible and testable +// without the Workbench UI, the full App controller, global AppState, the +// CodeMirror editors, the src/application services, or the src/net client. This +// mirrors (and double-checks in the unit suite) the `build/check-boundaries.mjs` +// pretest guard: a regression here fails `npm test`, not just `check:arch`. +// +// Node-tooling spec (kept .js like schema-build.test.js / spec-examples.test.js: +// typing the node: imports would need @types/node — a deferred decision). + +import { describe, expect, it } from 'vitest'; +import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const SOURCE_EXT = /\.(ts|tsx|js|mjs)$/; + +const FORBIDDEN = [ + 'src/ui', 'src/editor', 'src/application', 'src/state.ts', 'src/net', 'src/main.ts', +]; + +function collectFiles(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...collectFiles(full)); + else if (SOURCE_EXT.test(entry.name)) out.push(full); + } + return out; +} + +const SPECIFIER = /\bimport\s+(?:type\s+)?[\w*{}\s,]*\s*from\s*['"]([^'"]+)['"]|\bexport\s+(?:type\s+)?[\w*{}\s,]*\s*from\s*['"]([^'"]+)['"]/g; + +function relativeSpecifiers(file) { + const source = readFileSync(file, 'utf8'); + const specs = []; + let match; + SPECIFIER.lastIndex = 0; + while ((match = SPECIFIER.exec(source))) { + const spec = match[1] || match[2]; + if (spec && spec.startsWith('.')) specs.push(spec); + } + return specs; +} + +function resolveSpec(fromFile, spec) { + const target = resolve(dirname(fromFile), spec); + const noExt = target.replace(SOURCE_EXT, ''); + const candidates = [target, `${noExt}.ts`, `${noExt}.js`, join(target, 'index.ts'), join(target, 'index.js')]; + const found = candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile()) ?? target; + return relative(repoRoot, found).split('\\').join('/'); +} + +function violations(dir) { + const abs = join(repoRoot, dir); + const found = []; + for (const file of collectFiles(abs)) { + for (const spec of relativeSpecifiers(file)) { + const resolved = resolveSpec(file, spec); + const hit = FORBIDDEN.find((f) => resolved === f || resolved.startsWith(`${f}/`)); + if (hit) found.push(`${relative(repoRoot, file)} → ${spec} (${hit})`); + } + } + return found; +} + +describe('dashboard dependency boundaries', () => { + it('src/dashboard/application imports no Workbench UI / App / AppState / editor / service / net modules', () => { + expect(violations('src/dashboard/application')).toEqual([]); + }); + + it('src/dashboard/model imports no Workbench UI / App / AppState / editor / service / net modules', () => { + expect(violations('src/dashboard/model')).toEqual([]); + }); + + it('src/dashboard/layouts imports no Workbench UI / App / AppState / editor / service / net modules', () => { + expect(violations('src/dashboard/layouts')).toEqual([]); + }); + + it('the DashboardViewerSession specifically declares its own narrow seams', () => { + const file = join(repoRoot, 'src/dashboard/application/dashboard-viewer-session.ts'); + const specs = relativeSpecifiers(file).map((spec) => resolveSpec(file, spec)); + expect(specs.some((spec) => FORBIDDEN.some((f) => spec === f || spec.startsWith(`${f}/`)))).toBe(false); + }); +}); diff --git a/tests/unit/dashboard-commands.test.ts b/tests/unit/dashboard-commands.test.ts new file mode 100644 index 00000000..74c71a18 --- /dev/null +++ b/tests/unit/dashboard-commands.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { applyCommand } from '../../src/dashboard/application/dashboard-commands.js'; +import type { ApplyCommandContext, DashboardCommand } from '../../src/dashboard/application/dashboard-commands.js'; +import { createQueryResolver } from '../../src/dashboard/application/dashboard-query-resolver.js'; +import { flowLayoutPlugin } from '../../src/dashboard/layouts/flow-layout.js'; +import type { DashboardDocumentV1 } from '../../src/generated/json-schema.types.js'; + +const query = (id: string, dashboard?: Record) => ({ + id, sql: 'SELECT 1', specVersion: 1, spec: { name: id, panel: { cfg: { type: 'bar', x: 0, y: [1] } }, ...(dashboard ? { dashboard } : {}) }, +}); + +const draft = (over: Partial = {}): DashboardDocumentV1 => ({ + documentVersion: 1, id: 'd', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [], ...over, +} as DashboardDocumentV1); + +const makeCtx = (queries: unknown[]): ApplyCommandContext => { + let n = 0; + return { resolver: createQueryResolver(queries), genTileId: () => `tile-${++n}`, plugin: flowLayoutPlugin }; +}; + +const run = (d: DashboardDocumentV1, command: DashboardCommand, queries: unknown[]) => + applyCommand(d, command, makeCtx(queries)); + +describe('applyCommand — add-query / add-query-instance', () => { + it('adds a default tile and derives an initial placement from size hints', () => { + const q = query('q', { sizeHints: { preferred: 'wide' } }); + const result = run(draft(), { type: 'add-query', queryId: 'q' }, [q]); + expect(result.ok).toBe(true); + if (result.ok) { + expect((result.value as { tileId: string }).tileId).toBe('tile-1'); + expect(result.dashboard.tiles).toEqual([{ id: 'tile-1', queryId: 'q' }]); + expect(result.dashboard.layout.items).toEqual({ 'tile-1': { span: 3, height: 'medium' } }); + } + }); + + it('fails for a missing query and for a duplicate default instance', () => { + const missing = run(draft(), { type: 'add-query', queryId: 'gone' }, [query('q')]); + expect(missing.ok).toBe(false); + if (!missing.ok) expect(missing.diagnostics[0].code).toBe('dashboard-command-query-missing'); + + const existing = draft({ tiles: [{ id: 't1', queryId: 'q' }] as never }); + const dup = run(existing, { type: 'add-query', queryId: 'q' }, [query('q')]); + expect(dup.ok).toBe(false); + if (!dup.ok) expect(dup.diagnostics[0].code).toBe('dashboard-command-duplicate-instance'); + }); + + it('add-query-instance allows a second instance and carries a variant, without a placement when there is no hint', () => { + const existing = draft({ tiles: [{ id: 't1', queryId: 'q' }] as never }); + const result = run(existing, { type: 'add-query-instance', queryId: 'q', variant: 'alt' }, [query('q')]); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.dashboard.tiles).toHaveLength(2); + expect(result.dashboard.tiles[1]).toEqual({ id: 'tile-1', queryId: 'q', presentation: { variant: 'alt' } }); + expect(result.dashboard.layout.items).toEqual({}); + } + }); +}); + +describe('applyCommand — remove / move', () => { + const seeded = () => draft({ + tiles: [{ id: 'a', queryId: 'q' }, { id: 'b', queryId: 'q' }, { id: 'c', queryId: 'q' }] as never, + layout: { type: 'flow', version: 1, preset: 'full-width', items: { a: {}, b: {} } } as never, + }); + + it('removes a tile and fails for a missing one', () => { + const result = run(seeded(), { type: 'remove-tile', tileId: 'b' }, [query('q')]); + expect(result.ok && result.dashboard.tiles.map((t) => (t as { id: string }).id)).toEqual(['a', 'c']); + const missing = run(seeded(), { type: 'remove-tile', tileId: 'z' }, [query('q')]); + expect(missing.ok).toBe(false); + if (!missing.ok) expect(missing.diagnostics[0].code).toBe('dashboard-command-tile-missing'); + }); + + it('moves a tile to an index and never clamps an out-of-range index', () => { + const moved = run(seeded(), { type: 'move-tile', tileId: 'a', toIndex: 2 }, [query('q')]); + expect(moved.ok && moved.dashboard.tiles.map((t) => (t as { id: string }).id)).toEqual(['b', 'c', 'a']); + + for (const toIndex of [-1, 3, 1.5]) { + const bad = run(seeded(), { type: 'move-tile', tileId: 'a', toIndex }, [query('q')]); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.diagnostics[0].code).toBe('dashboard-command-index-out-of-range'); + } + const missing = run(seeded(), { type: 'move-tile', tileId: 'z', toIndex: 0 }, [query('q')]); + expect(missing.ok).toBe(false); + if (!missing.ok) expect(missing.diagnostics[0].code).toBe('dashboard-command-tile-missing'); + }); +}); + +describe('applyCommand — update-tile', () => { + const seeded = () => draft({ tiles: [{ id: 't1', queryId: 'q', title: 'old' }] as never }); + + it('applies a merge patch and deletes members set to null', () => { + const result = run(seeded(), { type: 'update-tile', tileId: 't1', patch: { title: 'new', description: 'd' } }, [query('q')]); + expect(result.ok && result.dashboard.tiles[0]).toEqual({ id: 't1', queryId: 'q', title: 'new', description: 'd' }); + const cleared = run(seeded(), { type: 'update-tile', tileId: 't1', patch: { title: null } }, [query('q')]); + expect(cleared.ok && cleared.dashboard.tiles[0]).toEqual({ id: 't1', queryId: 'q' }); + }); + + const seededPres = () => draft({ + tiles: [{ id: 't1', queryId: 'q', presentation: { variant: 'alt', override: { cfg: { y: [0], series: null } } } }] as never, + }); + + it('merges presentation one level: setting variant preserves the override verbatim (nested nulls kept)', () => { + const result = run(seededPres(), { type: 'update-tile', tileId: 't1', patch: { presentation: { variant: 'other' } } }, [query('q')]); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.dashboard.tiles[0]).toEqual({ + id: 't1', queryId: 'q', presentation: { variant: 'other', override: { cfg: { y: [0], series: null } } }, + }); + } + }); + + it('clears only the override sub-field with presentation.override:null, leaving the variant', () => { + const result = run(seededPres(), { type: 'update-tile', tileId: 't1', patch: { presentation: { override: null } } }, [query('q')]); + expect(result.ok && result.dashboard.tiles[0]).toEqual({ id: 't1', queryId: 'q', presentation: { variant: 'alt' } }); + }); + + it('clears the whole presentation field with presentation:null and replaces a non-object presentation wholesale', () => { + const cleared = run(seededPres(), { type: 'update-tile', tileId: 't1', patch: { presentation: null } }, [query('q')]); + expect(cleared.ok && cleared.dashboard.tiles[0]).toEqual({ id: 't1', queryId: 'q' }); + const replaced = run(seededPres(), { type: 'update-tile', tileId: 't1', patch: { presentation: ['x'] as never } }, [query('q')]); + expect(replaced.ok && (replaced.dashboard.tiles[0] as { presentation: unknown }).presentation).toEqual(['x']); + }); + + it('fails for a missing tile, a non-object patch, or a patch touching identity', () => { + const missing = run(seeded(), { type: 'update-tile', tileId: 'z', patch: {} }, [query('q')]); + expect(missing.ok).toBe(false); + const notObject = run(seeded(), { type: 'update-tile', tileId: 't1', patch: 5 as never }, [query('q')]); + expect(notObject.ok).toBe(false); + if (!notObject.ok) expect(notObject.diagnostics[0].code).toBe('dashboard-command-invalid-patch'); + const identity = run(seeded(), { type: 'update-tile', tileId: 't1', patch: { queryId: 'other' } }, [query('q')]); + expect(identity.ok).toBe(false); + if (!identity.ok) expect(identity.diagnostics[0].message).toContain('id or queryId'); + }); +}); + +describe('applyCommand — update-placement / change-layout', () => { + const seeded = () => draft({ tiles: [{ id: 't1', queryId: 'q' }] as never }); + + it('sets a valid placement and rejects an invalid one', () => { + const ok = run(seeded(), { type: 'update-placement', tileId: 't1', placement: { span: 2 } }, [query('q')]); + expect(ok.ok && ok.dashboard.layout.items).toEqual({ t1: { span: 2 } }); + const bad = run(seeded(), { type: 'update-placement', tileId: 't1', placement: { span: 9 } }, [query('q')]); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.diagnostics[0].code).toBe('layout-placement-invalid-span'); + const missing = run(seeded(), { type: 'update-placement', tileId: 'z', placement: {} }, [query('q')]); + expect(missing.ok).toBe(false); + if (!missing.ok) expect(missing.diagnostics[0].code).toBe('dashboard-command-tile-missing'); + }); + + it('installs a new layout document', () => { + const layout = { type: 'flow', version: 1, preset: 'columns-2', items: {} } as never; + const result = run(seeded(), { type: 'change-layout', layout }, [query('q')]); + expect(result.ok && result.dashboard.layout.preset).toBe('columns-2'); + }); +}); + +describe('applyCommand — isolation', () => { + it('never mutates the input draft', () => { + const input = draft({ tiles: [{ id: 't1', queryId: 'q' }] as never }); + const before = JSON.stringify(input); + run(input, { type: 'remove-tile', tileId: 't1' }, [query('q')]); + run(input, { type: 'update-tile', tileId: 't1', patch: { title: 'x' } }, [query('q')]); + expect(JSON.stringify(input)).toBe(before); + }); +}); diff --git a/tests/unit/dashboard-kpi-band.test.ts b/tests/unit/dashboard-kpi-band.test.ts deleted file mode 100644 index 260e55c0..00000000 --- a/tests/unit/dashboard-kpi-band.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - applyKpiSourceResult, buildKpiBand, buildKpiSourceSlot, refreshBandWarnings, - setKpiSourceLoading, setKpiSourceUnfilled, -} from '../../src/ui/dashboard-kpi-band.js'; -import type { KpiSourceWarning } from '../../src/ui/dashboard-kpi-band.js'; -import type { App } from '../../src/ui/app.types.js'; -import type { AppState } from '../../src/state.js'; -import type { Column } from '../../src/core/panel-cfg.js'; -import type { FieldConfig, Panel } from '../../src/generated/json-schema.types.js'; -import { makeApp } from '../helpers/fake-app.js'; - -// applyKpiSourceResult reads exactly `app.state.serverVersion` (see -// dashboard-kpi-band.ts) — everything else on the real `App` contract is -// irrelevant to this suite; `makeApp()`'s own inert defaults cover it. -const testApp = (serverVersion?: string): App => makeApp({ state: { serverVersion } as AppState }); - -const kpiExplicit = (fieldConfig?: FieldConfig): Panel => ({ cfg: { type: 'kpi' }, fieldConfig }); -// core/kpi.js's readKpiFields reads a row either positionally -// (`Array.isArray(row) ? row[columnIndex] : row?.[column.name]`) — both are -// real, supported runtime shapes, but core/panel-cfg.ts's `ResultLike.rows` -// (which `KpiSourceResult` inherits from) types rows as `unknown[][]` only, -// with no keyed-object row variant. That's a genuine type-vs-runtime gap in -// an out-of-scope file (panel-cfg.ts/dashboard-kpi-band.ts) — flagged, not -// fixed here. `row` is positional (matching each `columns` fixture's declared -// order) rather than the original keyed object, which the strict type can't -// express; readKpiFields treats the two forms identically, so every -// downstream assertion (KPI items/diagnostics) is unaffected. -const kpiResult = (columns: Column[], row: unknown[]): { columns: Column[]; rows: unknown[][] } => ({ columns, rows: [row] }); -const warning = (over: Partial & Pick): KpiSourceWarning => - ({ severity: 'warning', code: 'w', ...over }); - -describe('buildKpiBand', () => { - it('builds an accessible stream and a hidden warning host', () => { - const band = buildKpiBand(); - expect(band.el.classList.contains('dash-kpi-band')).toBe(true); - expect(band.stream.classList.contains('dash-kpi-stream')).toBe(true); - expect(band.stream.getAttribute('role')).toBe('group'); - expect(band.stream.getAttribute('aria-label')).toBe('Key performance indicators'); - expect(band.warningHost.style.display).toBe('none'); - expect(band.sources).toEqual([]); - }); -}); - -describe('buildKpiSourceSlot', () => { - it('appends the source host into the band stream in call order and registers it', () => { - const band = buildKpiBand(); - const a = buildKpiSourceSlot(band, {}, 'Query A'); - const b = buildKpiSourceSlot(band, {}, 'Query B'); - expect(band.stream.children[0]).toBe(a.host); - expect(band.stream.children[1]).toBe(b.host); - expect(band.sources).toEqual([a, b]); - expect(a.kind).toBe('kpi-source'); - expect(a.host.classList.contains('dash-kpi-source')).toBe(true); - }); -}); - -describe('setKpiSourceLoading', () => { - it('renders one status card naming the query, with a live progress label', () => { - const band = buildKpiBand(); - const slot = buildKpiSourceSlot(band, {}, 'Query log health'); - const label = setKpiSourceLoading(slot); - const card = slot.host.querySelector('.dash-kpi-state-card')!; - expect(card.getAttribute('role')).toBe('status'); - expect(card.getAttribute('aria-live')).toBe('polite'); - expect(card.getAttribute('aria-label')).toBe('Query log health'); - expect(card.querySelector('.dash-kpi-state-label')!.textContent).toBe('Query log health'); - expect(label.textContent).toBe('Loading…'); - label.textContent = 'Loading… 5 rows'; - expect(card.querySelector('.dash-kpi-state-message')!.textContent).toContain('5 rows'); - }); - it("clears the slot's own warnings but leaves the shared band DOM to the caller's batched refresh", () => { - // setKpiSourceLoading does NOT call refreshBandWarnings itself (a Refresh - // wave marks every affected source loading in one synchronous pass — the - // caller refreshes each touched band exactly once after that pass, not - // once per source; see dashboard.js's runPlan). - const band = buildKpiBand(); - const slot = buildKpiSourceSlot(band, {}, 'Query A'); - slot.warnings = [warning({ message: 'stale', sourceName: 'Query A' })]; - refreshBandWarnings(band); - expect(band.warningHost.style.display).toBe(''); - setKpiSourceLoading(slot); - expect(slot.warnings).toEqual([]); - expect(band.warningHost.style.display).toBe(''); // stale until the caller refreshes - refreshBandWarnings(band); - expect(band.warningHost.style.display).toBe('none'); - }); -}); - -describe('setKpiSourceUnfilled', () => { - it('renders a neutral state card naming the missing filter values', () => { - const band = buildKpiBand(); - const slot = buildKpiSourceSlot(band, {}, 'Query log health'); - setKpiSourceUnfilled(slot, ['from', 'to']); - const card = slot.host.querySelector('.dash-kpi-state-card')!; - expect(card.getAttribute('role')).toBe('status'); - expect(card.hasAttribute('aria-live')).toBe(false); - expect(card.querySelector('.dash-kpi-state-message')!.textContent).toBe('Enter a value for: from, to'); - }); -}); - -describe('applyKpiSourceResult', () => { - it('renders a transport error as an alert state card', () => { - const band = buildKpiBand(); - const slot = buildKpiSourceSlot(band, {}, 'Query log health'); - applyKpiSourceResult(testApp(), kpiExplicit(), slot, { error: 'Connection reset' }); - const card = slot.host.querySelector('.dash-kpi-state-card')!; - expect(card.getAttribute('role')).toBe('alert'); - expect(card.querySelector('.dash-kpi-state-message')!.textContent).toBe('Connection reset'); - expect(slot.status).toBe('error'); - }); - it('renders zero rows as an in-stream no-data state card', () => { - const band = buildKpiBand(); - const slot = buildKpiSourceSlot(band, {}, 'Query log health'); - applyKpiSourceResult(testApp(), kpiExplicit(), slot, { columns: [], rows: [] }); - const card = slot.host.querySelector('.dash-kpi-state-card')!; - expect(card.querySelector('.dash-kpi-state-message')!.textContent).toBe('No data'); - expect(slot.status).toBe('error'); - }); - it('renders a multi-row result as an in-stream error state card', () => { - const band = buildKpiBand(); - const slot = buildKpiSourceSlot(band, {}, 'Query log health'); - applyKpiSourceResult(testApp(), kpiExplicit(), slot, { - columns: [{ name: 'n', type: 'UInt64' }], rows: [[1], [2]], - }); - const card = slot.host.querySelector('.dash-kpi-state-card')!; - expect(card.getAttribute('role')).toBe('alert'); - expect(card.querySelector('.dash-kpi-state-message')!.textContent).toBe('Expected 1 row, got 2'); - }); - it('renders a no-eligible-fields result as an in-stream error state card, keeping every blocking diagnostic (not just one)', () => { - const band = buildKpiBand(); - const slot = buildKpiSourceSlot(band, {}, 'Query log health'); - applyKpiSourceResult(testApp(), kpiExplicit(), slot, kpiResult([{ name: 'label', type: 'String' }], ['hi'])); - const card = slot.host.querySelector('.dash-kpi-state-card')!; - expect(card.getAttribute('role')).toBe('alert'); - // readKpiFields also emits a `kpi-unsupported-field` warning for the ineligible - // column alongside the blocking `kpi-no-eligible-fields` error — both must - // stay visible (the workbench's renderKpiPanel shows the same full list). - const lines = [...card.querySelectorAll('.dash-kpi-state-message > div')].map((n) => n.textContent); - expect(lines).toEqual(['Column label has unsupported KPI type String', 'No eligible KPI fields in this result']); - }); - it('renders success cards and collects warnings tagged with the source name', () => { - const band = buildKpiBand(); - const slot = buildKpiSourceSlot(band, {}, 'SELECT health'); - applyKpiSourceResult(testApp(), kpiExplicit(), slot, kpiResult( - [{ name: 'value', type: 'UInt64' }, { name: 'region', type: 'String' }], - [591, 'us'], - )); - expect(slot.host.querySelectorAll('.kpi-card')).toHaveLength(1); - expect(slot.status).toBe('panel'); - expect(slot.warnings).toEqual([{ - severity: 'warning', code: 'kpi-unsupported-field', - message: 'Column region has unsupported KPI type String', columnName: 'region', sourceName: 'SELECT health', - }]); - refreshBandWarnings(band); - const warningEl = band.warningHost.querySelector('.dash-kpi-warning')!; - expect(warningEl.textContent).toBe('SELECT health: Column region has unsupported KPI type String'); - expect(warningEl.getAttribute('role')).toBe('status'); - }); - it('never removes sibling cards in the same band when one source errors', () => { - const band = buildKpiBand(); - const good = buildKpiSourceSlot(band, {}, 'Good KPI'); - const bad = buildKpiSourceSlot(band, {}, 'Bad KPI'); - applyKpiSourceResult(testApp(), kpiExplicit(), good, kpiResult([{ name: 'value', type: 'UInt64' }], [7])); - applyKpiSourceResult(testApp(), kpiExplicit(), bad, { error: 'boom' }); - expect(good.host.querySelectorAll('.kpi-card')).toHaveLength(1); - expect(bad.host.querySelector('.dash-kpi-state-card')).toBeTruthy(); - }); -}); - -describe('refreshBandWarnings', () => { - it('orders warnings by source order then diagnostic order, and hides when empty', () => { - const band = buildKpiBand(); - const a = buildKpiSourceSlot(band, {}, 'A'); - const b = buildKpiSourceSlot(band, {}, 'B'); - a.warnings = [warning({ message: 'first', sourceName: 'A' }), warning({ message: 'second', sourceName: 'A' })]; - b.warnings = [warning({ message: 'third', sourceName: 'B' })]; - refreshBandWarnings(band); - const texts = [...band.warningHost.querySelectorAll('.dash-kpi-warning')].map((n) => n.textContent); - expect(texts).toEqual(['A: first', 'A: second', 'B: third']); - a.warnings = []; - b.warnings = []; - refreshBandWarnings(band); - expect(band.warningHost.style.display).toBe('none'); - expect(band.warningHost.children).toHaveLength(0); - }); -}); diff --git a/tests/unit/dashboard-query-resolver.test.ts b/tests/unit/dashboard-query-resolver.test.ts new file mode 100644 index 00000000..e05b77d6 --- /dev/null +++ b/tests/unit/dashboard-query-resolver.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { createQueryResolver } from '../../src/dashboard/application/dashboard-query-resolver.js'; + +const query = (id: string, dashboard?: Record) => ({ + id, sql: 'SELECT 1', specVersion: 1, spec: { name: id, ...(dashboard ? { dashboard } : {}) }, +}); + +describe('createQueryResolver', () => { + it('looks up queries and reads role and variants; first duplicate wins', () => { + const resolver = createQueryResolver([ + query('p', { role: 'panel', variants: { alt: {} } }), + query('f', { role: 'filter' }), + { id: 'p', sql: 'x', specVersion: 1, spec: {} }, // duplicate id ignored + 'not-object', + { sql: 'no id' }, + ]); + expect(resolver.has('p')).toBe(true); + expect(resolver.has('missing')).toBe(false); + expect((resolver.get('p') as { spec: { dashboard: unknown } }).spec.dashboard).toEqual({ role: 'panel', variants: { alt: {} } }); + expect(resolver.get('missing')).toBeUndefined(); + expect(resolver.role('p')).toBe('panel'); + expect(resolver.role('f')).toBe('filter'); + expect(resolver.role('missing')).toBeUndefined(); + expect(resolver.variants('p')).toEqual({ alt: {} }); + expect(resolver.variants('f')).toBeUndefined(); // no variants declared + expect(resolver.variants('missing')).toBeUndefined(); + }); +}); diff --git a/tests/unit/dashboard-session.test.ts b/tests/unit/dashboard-session.test.ts deleted file mode 100644 index c65f4b3a..00000000 --- a/tests/unit/dashboard-session.test.ts +++ /dev/null @@ -1,831 +0,0 @@ -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/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts new file mode 100644 index 00000000..ea61192c --- /dev/null +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -0,0 +1,545 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createDashboardViewerSession, VIEWER_TILE_CONCURRENCY, +} from '../../src/dashboard/application/dashboard-viewer-session.js'; +import type { + DashboardViewerDeps, ViewerExecutor, ViewerReadRequest, +} from '../../src/dashboard/application/dashboard-viewer-session.js'; +import type { + DashboardDocumentV1, DashboardFilterDefinitionV1, DashboardTileV1, SavedQueryV2, +} from '../../src/generated/json-schema.types.js'; + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +interface Resp { + columns?: { name: string; type?: string }[]; + rows?: unknown[][]; + error?: string | null; + cancelled?: boolean; + bytes?: number; + progressRows?: number; +} +type Responder = (sql: string, req: ViewerReadRequest) => Resp | Promise; + +function makeExec(responder: Responder = () => ({})) { + const calls: { sql: string; params: Record; format?: string }[] = []; + const exec: ViewerExecutor = { + async executeRead(result, req) { + calls.push({ sql: req.sql, params: req.params ?? {}, format: req.format }); + const resp = (await responder(req.sql, req)) || {}; + if (req.onChunk) { result.progress.rows = resp.progressRows ?? 1; req.onChunk(); } + result.columns = (resp.columns ?? [{ name: 'n' }]) as never; + result.rows = resp.rows ?? [[1]]; + result.progress.bytes = resp.bytes ?? 10; + result.error = resp.error ?? null; + result.cancelled = resp.cancelled ?? false; + }, + }; + return { exec, calls }; +} + +const query = (id: string, sql: string, spec: Record = {}): SavedQueryV2 => + ({ id, sql, specVersion: 1, spec: { name: id, ...spec } } as SavedQueryV2); + +const tile = (id: string, queryId: string, over: Partial = {}): DashboardTileV1 => + ({ id, queryId, ...over }); + +const doc = (over: Partial = {}): DashboardDocumentV1 => ({ + documentVersion: 1, id: 'd', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'columns-2', items: {} }, + filters: [], tiles: [], ...over, +}); + +function makeDeps(over: Partial & Pick): DashboardViewerDeps { + let clock = 1000; + return { + queries: [], + exec: makeExec().exec, + connection: { ensureFreshToken: async () => true }, + now: () => (clock += 5), + wallNow: () => 2000, + ...over, + }; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('createDashboardViewerSession', () => { + it('runs a dashboard document end-to-end with no Workbench construction', async () => { + const { exec, calls } = makeExec((sql) => ({ columns: [{ name: 'n' }], rows: [[sql.length]] })); + const document = doc({ + tiles: [tile('t1', 'q1'), tile('t2', 'q2')], + layout: { type: 'flow', version: 1, preset: 'columns-2', items: { t1: { span: 2 } } }, + }); + const session = createDashboardViewerSession(makeDeps({ + document, queries: [query('q1', 'SELECT 1'), query('q2', 'SELECT 2')], + exec, recordBoundParams: vi.fn(), + })); + await session.start(); + const state = session.state.value; + expect(calls.length).toBe(2); + expect(state.tiles.map((t) => t.status)).toEqual(['ready', 'ready']); + expect(state.tiles[0].columns).toEqual([{ name: 'n' }]); + expect(state.tiles[0].meta?.rows).toBe(1); + expect(state.running).toBe(false); + expect(state.updatedAt).not.toBeNull(); + // Flow model reflects the columns-2 preset and the stored span-2 placement. + expect(state.layout.columns).toBe(2); + expect(state.layout.rows[0].tiles[0].span).toBe(2); + expect(VIEWER_TILE_CONCURRENCY).toBe(6); + }); + + it('marks a tile whose query is missing as an error and reports a presentation diagnostic', async () => { + const document = doc({ tiles: [tile('t1', 'ghost')] }); + const session = createDashboardViewerSession(makeDeps({ document, queries: [] })); + await session.start(); + const state = session.state.value; + expect(state.tiles[0].status).toBe('error'); + expect(state.tiles[0].error).toContain('ghost'); + }); + + it('marks a tile with an invalid selected variant as an error', async () => { + const document = doc({ tiles: [tile('t1', 'q1', { presentation: { variant: 'nope' } })] }); + const session = createDashboardViewerSession(makeDeps({ + document, queries: [query('q1', 'SELECT 1', { panel: { cfg: { type: 'kpi' } } })], + })); + await session.start(); + expect(session.state.value.tiles[0].status).toBe('error'); + }); + + it('renders a text panel tile with no query execution', async () => { + const { exec, calls } = makeExec(); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('q1', 'SELECT 1', { panel: { cfg: { type: 'text', content: 'hi' } } })], + })); + await session.start(); + expect(calls.length).toBe(0); + expect(session.state.value.tiles[0].status).toBe('ready'); + expect(session.state.value.tiles[0].isKpi).toBe(false); + }); + + it('shows an unfilled tile when a required param has no value, issuing no request', async () => { + const { exec, calls } = makeExec(); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('q1', 'SELECT {year:UInt16}')], + })); + await session.start(); + expect(calls.length).toBe(0); + expect(session.state.value.tiles[0].status).toBe('unfilled'); + expect(session.state.value.tiles[0].unfilled).toEqual(['year']); + }); + + it('reports a per-source template error without issuing a request', async () => { + const { exec, calls } = makeExec(); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('q1', 'SELECT 1 /*[ AND 1 = 1 ]*/')], + })); + await session.start(); + expect(calls.length).toBe(0); + expect(session.state.value.tiles[0].status).toBe('error'); + expect(session.state.value.tiles[0].error).toContain('parameter'); + }); + + it('rejects an explicit FORMAT clause on an ordinary tile', async () => { + const { exec, calls } = makeExec(); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('q1', 'SELECT 1 FORMAT JSON')], + })); + await session.start(); + expect(calls.length).toBe(0); + expect(session.state.value.tiles[0].error).toContain('FORMAT'); + }); + + it('runs a KPI panel through the owned KPI transport and rejects a KPI FORMAT clause', async () => { + const { exec, calls } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[7]] })); + const kpiDoc = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document: kpiDoc, exec, queries: [query('q1', 'SELECT 7 AS n', { panel: { cfg: { type: 'kpi' } } })], + })); + await session.start(); + expect(calls[0].format).toBe('KPI'); + expect(session.state.value.tiles[0].isKpi).toBe(true); + + const { exec: exec2, calls: calls2 } = makeExec(); + const badKpi = doc({ tiles: [tile('t1', 'q1')] }); + const s2 = createDashboardViewerSession(makeDeps({ + document: badKpi, exec: exec2, queries: [query('q1', 'SELECT 7 FORMAT JSON', { panel: { cfg: { type: 'kpi' } } })], + })); + await s2.start(); + expect(calls2.length).toBe(0); + expect(s2.state.value.tiles[0].error).toContain('KPI'); + }); + + it('runs an optional-block tile and surfaces a query error / progress', async () => { + const seenProgress: number[] = []; + const { exec } = makeExec((sql) => (sql.includes('boom') + ? { error: 'ch failed' } + : { columns: [{ name: 'n' }], rows: [[1]], progressRows: 42 })); + const document = doc({ tiles: [tile('ok', 'q1'), tile('bad', 'q2')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [query('q1', 'SELECT 1 /*[ AND {x:String} = 1 ]*/'), query('q2', 'SELECT boom')], + })); + session.state.subscribe((s) => { const rows = s.tiles[0].progressRows; if (rows) seenProgress.push(rows); }); + await session.start(); + expect(session.state.value.tiles[0].status).toBe('ready'); + expect(session.state.value.tiles[1].status).toBe('error'); + expect(session.state.value.tiles[1].error).toBe('ch failed'); + expect(seenProgress).toContain(42); + }); + + it('halts before any work when the token preflight fails', async () => { + const { exec, calls } = makeExec(); + const onAuthFailed = vi.fn(); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('q1', 'SELECT 1')], onAuthFailed, + connection: { ensureFreshToken: async () => false }, + })); + await session.start(); + expect(calls.length).toBe(0); + expect(onAuthFailed).toHaveBeenCalledOnce(); + }); +}); + +describe('filters and the #235 execution planner', () => { + // A dashboard with a source-backed filter targeting one tile via parameter + // declaration, plus an unrelated tile the filter can never affect. + const filterDef = (over: Partial = {}): DashboardFilterDefinitionV1 => + ({ id: 'f1', parameter: 'p', sourceQueryId: 'src', defaultActive: true, defaultValue: 'V', ...over }); + + const twoTileDoc = (filters: DashboardFilterDefinitionV1[]) => doc({ + tiles: [tile('affected', 'qa'), tile('unaffected', 'qu')], filters, + }); + const queries = () => [ + query('qa', 'SELECT {p:String} AS n'), + query('qu', 'SELECT 1 AS n'), + query('src', "SELECT 'V' AS p /* source */", { dashboard: { role: 'filter' } }), + ]; + + it('starts the unaffected panel before the filter wave completes; the affected panel sees first-pass values', async () => { + let releaseFilter!: () => void; + const filterGate = new Promise((resolve) => { releaseFilter = resolve; }); + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? filterGate.then(() => ({ columns: [{ name: 'p', type: 'Array(String)' }], rows: [[['V']]] })) + : { columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: twoTileDoc([filterDef()]), exec, queries: queries(), + })); + const done = session.start(); + await flush(); + const sqls = () => calls.map((c) => c.sql); + // #235: the unaffected tile ran while the filter wave is still pending. + expect(sqls().some((s) => s === 'SELECT 1 AS n')).toBe(true); + expect(sqls().some((s) => s.includes('{p:String}') || s.includes('SELECT '))).toBe(true); + expect(calls.some((c) => c.sql.includes('AS n') && 'param_p' in c.params)).toBe(false); + releaseFilter(); + await done; + // The affected tile ran after the filter wave with the active value bound. + const affectedCall = calls.find((c) => 'param_p' in c.params); + expect(affectedCall?.params.param_p).toBe('V'); + expect(session.state.value.filters[0].options).toEqual([{ value: 'V', label: 'V' }]); + expect(session.state.value.activeFilterCount).toBe(1); + }); + + it('honours a filter definition with explicit targets', async () => { + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'p', type: 'Array(String)' }], rows: [[['V']]] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: twoTileDoc([filterDef({ targets: ['affected'] })]), exec, queries: queries(), + })); + await session.start(); + // The explicitly-targeted tile ran with the active value bound. + expect(calls.some((c) => c.params.param_p === 'V')).toBe(true); + expect(session.state.value.filters[0].options).toEqual([{ value: 'V', label: 'V' }]); + }); + + it('marks a filter whose source SQL is invalid as an error', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ + tiles: [tile('t', 'qt')], + filters: [{ id: 'f1', parameter: 'p', sourceQueryId: 'src', defaultActive: true, defaultValue: 'V' }], + }), + exec, + queries: [query('qt', 'SELECT {p:String} AS n'), query('src', '', { dashboard: { role: 'filter' } })], + })); + await session.start(); + expect(session.state.value.filters[0].status).toBe('error'); + }); + + it('marks a filter whose source query returns a runtime error as an error', async () => { + const { exec } = makeExec((sql) => (sql.includes('badsrc') ? { error: 'src down' } : { columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ + tiles: [tile('t', 'qt')], + filters: [{ id: 'f1', parameter: 'p', sourceQueryId: 'srcq' }], + }), + exec, + queries: [ + query('qt', 'SELECT {p:String} AS n'), + query('srcq', 'SELECT 1 AS p /* badsrc */', { dashboard: { role: 'filter' } }), + ], + })); + await session.start(); + expect(session.state.value.filters[0].status).toBe('error'); + }); + + it('blanks a curated-but-inactive parameter on the next wave', async () => { + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? { columns: [{ name: 'p', type: 'Array(String)' }], rows: [[['V', 'W']]] } + : { columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: twoTileDoc([filterDef()]), exec, queries: queries(), + })); + await session.start(); + expect(session.state.value.filters[0].options?.length).toBe(2); + const base = calls.length; + // Deactivating a curated filter blanks its value in the next prepared wave. + await session.clearFilter('f1'); + const affectedCall = calls.slice(base).find((c) => 'param_p' in c.params); + expect(affectedCall).toBeUndefined(); // blank → the affected tile goes unfilled, not bound + }); + + it('setFilter runs only the affected panel wave', async () => { + const { exec, calls } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: twoTileDoc([]), exec, queries: queries(), + })); + await session.start(); + const before = calls.length; + await session.setFilter('missing', 'x'); // unknown filter: no-op + expect(calls.length).toBe(before); + // The document has no filter definition for p; create one so setFilter maps. + const withFilter = createDashboardViewerSession(makeDeps({ + document: twoTileDoc([filterDef({ sourceQueryId: undefined })]), exec, queries: queries(), + })); + await withFilter.start(); + const base = calls.length; + await withFilter.setFilter('f1', 'W'); + // Only the affected tile (declares {p}) re-ran. + const added = calls.slice(base); + expect(added.length).toBe(1); + expect(added[0].params.param_p).toBe('W'); + }); + + it('clearFilter deactivates without discarding the value; reactivation restores it', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: twoTileDoc([filterDef({ sourceQueryId: undefined })]), exec, queries: queries(), + })); + await session.start(); + expect(session.state.value.filters[0].active).toBe(true); + await session.clearFilter('f1'); + expect(session.state.value.filters[0].active).toBe(false); + expect(session.state.value.filters[0].value).toBe('V'); // value retained + expect(session.state.value.activeFilterCount).toBe(0); + await session.setFilter('f1', session.state.value.filters[0].value); // reactivate + expect(session.state.value.filters[0].active).toBe(true); + await session.clearFilter('nope'); // unknown: no-op + }); + + it('clearAllFilters coalesces every reset into one affected-panel wave', async () => { + const { exec, calls } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ + tiles: [tile('a', 'qa'), tile('b', 'qb')], + filters: [ + { id: 'f1', parameter: 'p', defaultActive: true, defaultValue: 'V' }, + { id: 'f2', parameter: 'q', defaultActive: true, defaultValue: 'W' }, + ], + }), + exec, + queries: [query('qa', 'SELECT {p:String} AS n'), query('qb', 'SELECT {q:String} AS n')], + })); + await session.start(); + // Move both filters off their defaults. + await session.setFilter('f1', 'X'); + await session.setFilter('f2', 'Y'); + const base = calls.length; + await session.clearAllFilters(); + const added = calls.slice(base); + // One coalesced wave re-ran both affected tiles (2 tiles, not 2 waves × ...). + expect(added.length).toBe(2); + expect(session.state.value.filters.every((f) => f.active)).toBe(true); + // A second clear-all with nothing changed issues no wave. + const base2 = calls.length; + await session.clearAllFilters(); + expect(calls.length).toBe(base2); + }); + +}); + +describe('filter-bar bridge (controls / getFilterField / applyFilter)', () => { + it('exposes controls + a draft-aware field state, and applyFilter sets value AND active explicitly', async () => { + const { exec, calls } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ tiles: [tile('t', 'q')], filters: [{ id: 'f', parameter: 'p', defaultActive: false, defaultValue: '' }] }), + exec, queries: [query('q', 'SELECT {p:String} AS n')], + })); + await session.start(); + expect(session.controls.map((c) => c.name)).toContain('p'); + // Draft-aware #170 validation: empty required → not ok; a value → ok. + expect(session.getFilterField('p', 'execute', { p: '' }, { p: false }).state).not.toBe('ok'); + expect(session.getFilterField('p', 'execute', { p: 'x' }, { p: true }).state).toBe('ok'); + // applyFilter(value, active=true) → the affected tile re-runs with the value bound. + const before = calls.length; + await session.applyFilter('f', 'x', true); + expect(calls.slice(before).find((c) => 'param_p' in c.params)?.params.param_p).toBe('x'); + expect(session.state.value.filters[0]).toMatchObject({ value: 'x', active: true }); + // applyFilter(value, active=false) keeps the value but deactivates it. + await session.applyFilter('f', 'x', false); + expect(session.state.value.filters[0]).toMatchObject({ value: 'x', active: false }); + // Unknown filter id and post-destroy are no-ops. + await session.applyFilter('nope', 'y', true); + session.destroy(); + await session.applyFilter('f', 'z', true); + expect(session.state.value.filters[0].value).toBe('x'); + }); +}); + +describe('per-tile control and lifecycle', () => { + it('refreshTile re-runs one tile; refreshTile is a no-op for text/missing/invalid tiles', async () => { + const { exec, calls } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ tiles: [tile('t1', 'q1'), tile('txt', 'q2'), tile('ghost', 'gone')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [query('q1', 'SELECT 1'), query('q2', 'SELECT 1', { panel: { cfg: { type: 'text', content: 'x' } } })], + })); + await session.start(); + const base = calls.length; + await session.refreshTile('t1'); + expect(calls.length).toBe(base + 1); + await session.refreshTile('txt'); // text: no-op + await session.refreshTile('ghost'); // missing query: no-op + await session.refreshTile('absent'); // unknown tile: no-op + expect(calls.length).toBe(base + 1); + }); + + it('cancelTile aborts an in-flight request and resets the tile to idle', async () => { + let releaseTile!: () => void; + const gate = new Promise((resolve) => { releaseTile = resolve; }); + const { exec } = makeExec(() => gate.then(() => ({ columns: [{ name: 'n' }], rows: [[1]] }))); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ document, exec, queries: [query('q1', 'SELECT 1')] })); + const done = session.start(); + await flush(); + expect(session.state.value.tiles[0].status).toBe('loading'); + session.cancelTile('t1'); + session.cancelTile('absent'); // unknown: no-op + expect(session.state.value.tiles[0].status).toBe('idle'); + releaseTile(); + await done; + // The superseded run never overwrote the cancelled state. + expect(session.state.value.tiles[0].status).toBe('idle'); + }); + + it('a superseded mid-stream run (stale generation) discards its result', async () => { + let releaseTile!: () => void; + const gate = new Promise((resolve) => { releaseTile = resolve; }); + let call = 0; + const { exec } = makeExec(() => { + call += 1; + return call === 1 ? gate.then(() => ({ columns: [{ name: 'stale' }], rows: [[9]] })) : { columns: [{ name: 'fresh' }], rows: [[1]] }; + }); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ document, exec, queries: [query('q1', 'SELECT 1')] })); + const first = session.start(); + await flush(); + const second = session.refreshTile('t1'); // supersedes the pending first run + releaseTile(); + await Promise.all([first, second]); + expect(session.state.value.tiles[0].columns).toEqual([{ name: 'fresh' }]); + }); + + it('destroy cancels in-flight work and turns later entry points into no-ops', async () => { + let releaseFilter!: () => void; + const filterGate = new Promise((resolve) => { releaseFilter = resolve; }); + const { exec, calls } = makeExec((sql) => (sql.includes('source') + ? filterGate.then(() => ({ columns: [{ name: 'p' }], rows: [['V']] })) + : { columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('affected', 'qa'), tile('unaffected', 'qu')], + filters: [{ id: 'f1', parameter: 'p', sourceQueryId: 'src', defaultActive: true, defaultValue: 'V' }], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('qa', 'SELECT {p:String} AS n'), query('qu', 'SELECT 1 AS n'), + query('src', "SELECT 'V' AS p /* source */", { dashboard: { role: 'filter' } }), + ], + })); + const done = session.start(); + await flush(); + session.destroy(); + releaseFilter(); + await done; + const after = calls.length; + await session.refresh(); + await session.refreshTile('affected'); + await session.setFilter('f1', 'Z'); + await session.clearFilter('f1'); + await session.clearAllFilters(); + expect(calls.length).toBe(after); // nothing ran post-destroy + expect(session.state.value.updatedAt).toBeNull(); + }); + + it('syncDocument reorders/resizes in place without re-running tiles', async () => { + const { exec, calls } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('a', 'qa'), tile('b', 'qb')], + layout: { type: 'flow', version: 1, preset: 'columns-2', items: {} }, + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('qa', 'SELECT 1'), query('qb', 'SELECT 2')], + })); + await session.start(); + const base = calls.length; + expect(session.state.value.tiles.map((t) => t.tileId)).toEqual(['a', 'b']); + // Reorder to [b, a] and give b a span of 2 — no re-execution. + session.syncDocument({ + ...document, + tiles: [tile('b', 'qb'), tile('a', 'qa')], + layout: { type: 'flow', version: 1, preset: 'columns-2', items: { b: { span: 2 } } }, + }); + expect(calls.length).toBe(base); + expect(session.state.value.tiles.map((t) => t.tileId)).toEqual(['b', 'a']); + expect(session.state.value.tiles[0].status).toBe('ready'); // result preserved + expect(session.state.value.layout.rows[0].tiles[0]).toMatchObject({ tileId: 'b', span: 2 }); + // An unknown tile id in the next document is dropped defensively. + session.syncDocument({ ...document, tiles: [tile('a', 'qa'), tile('ghostly', 'x')] }); + expect(session.state.value.tiles.map((t) => t.tileId)).toEqual(['a']); + session.destroy(); + session.syncDocument(document); // no-op after destroy + expect(session.state.value.tiles.map((t) => t.tileId)).toEqual(['a']); + }); + + it('normalizes the flow layout on mobile and coerces filter values to strings', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + let mobile = true; + const session = createDashboardViewerSession(makeDeps({ + document: doc({ + tiles: [tile('a', 'qa')], + layout: { type: 'flow', version: 1, preset: 'columns-3', items: { a: { span: 3 } } }, + filters: [{ id: 'f1', parameter: 'p', defaultValue: 5 }], + }), + exec, queries: [query('qa', 'SELECT {p:String} AS n')], isMobile: () => mobile, + })); + await session.start(); + expect(session.state.value.layout.columns).toBe(1); + expect(session.state.value.layout.rows[0].tiles[0].span).toBe(1); + // A numeric default coerces to a string; setting null clears it. + await session.setFilter('f1', 5); + expect(session.state.value.filters[0].active).toBe(true); + await session.setFilter('f1', null); + expect(session.state.value.filters[0].active).toBe(false); + mobile = false; + }); +}); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index 1ea507e7..3408861a 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -224,1710 +224,409 @@ describe('auth-handoff message predicates', () => { }); }); -// ── ui/dashboard.js ────────────────────────────────────────────────────────── -/** The logical tile outcome a `runTile` spy returns — folded onto the real - * streaming `app.exec.executeRead` seam by `streamInto` below. */ -interface TileOutcome { - columns?: Column[]; - rows?: unknown[][]; - meta?: { rows?: number; ms?: number; bytes?: number; truncated?: boolean }; - error?: string; - cancelled?: boolean; -} -type TileSpy = (sql: string, params: Record) => Promise; +// ── ui/dashboard.js (viewer-driven render, #286 — reads dashboard.tiles[]) ──── +// The favorites-derived render was replaced by a DashboardViewerSession bound +// to the persisted StoredWorkspaceV1; these tests drive renderDashboard through +// a fake `loadDashboardWorkspace` (a controlled workspace) + a fake streaming +// `executeRead`, exactly as the app wires the real repository + exec seam. + type ExecuteReadResult = Parameters[0]; type ExecuteReadOpts = Parameters[1]; -/** A saved-favorite fixture, loose enough for every shape this suite builds - * (`spec`/extension fields ride through `savedQuery`'s own untyped rest). */ -interface FavoriteInput { - id: string; - name: string; - sql: string; - favorite?: boolean; - description?: string; - dashboard?: { role: string }; - panel?: { cfg?: Record; key?: string; fieldConfig?: Record }; +interface ExecResp { + columns?: Column[]; + rows?: unknown[][]; + error?: string; + bytes?: number; + capped?: boolean; } +type ExecResponder = (sql: string, params: Record) => ExecResp | Promise; -const chartResult = (meta: TileOutcome['meta'] = { rows: 2, ms: 5, bytes: 100 }): TileOutcome => ({ - columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], - rows: [['a', 1], ['b', 2]], meta, -}); -const kpiResult = (): TileOutcome => - ({ columns: [{ name: 'value', type: 'UInt64' }], rows: [[42]], meta: { rows: 1, ms: 1, bytes: 10 } }); - -// A `runTile`/`spy` fixture, forced to the real (sql, params) `TileSpy` shape -// regardless of how few of those the closure passed in actually reads (a -// fewer-params implementation is an assignable direction, not a fixture gap) -// — so later `.mock.calls[i][1]`-style tuple reads on the SAME variable -// type-check, matching what every one of these mocks is actually invoked -// with (see `streamInto` below). -const tile = (impl?: TileSpy): ReturnType> => vi.fn(impl); - -// Bridge the legacy tile-outcome fixtures onto the streaming `app.exec.executeRead` -// seam (#193): `spy(sql, param_* args)` returns the logical tile outcome -// ({columns, rows, meta} | {error, cancelled}); `streamInto` folds it into the -// caller-owned result exactly as ClickHouse's JSONStrings…Progress stream would -// (columns, rows, progress bytes, and the `capped` flag from meta.truncated), -// then fires onChunk once. Keeping the spy's (sql, params) signature lets the -// existing call-count/arg assertions ride unchanged — only the param_* subset -// reaches the spy (the seam's readonly:2 / max_result_bytes / rowLimit are -// asserted separately, on the executeRead opts). Returns the executeRead mock. -function streamInto(spy: TileSpy) { - return vi.fn(async (result: ExecuteReadResult, opts: ExecuteReadOpts = {} as ExecuteReadOpts) => { +function makeExec(responder: ExecResponder = () => ({})) { + const calls: { sql: string; params: Record; format?: string }[] = []; + const executeRead = vi.fn(async (result: ExecuteReadResult, opts: ExecuteReadOpts = {} as ExecuteReadOpts) => { const params = (opts.params ?? {}) as Record; const paramArgs = Object.fromEntries(Object.entries(params).filter(([k]) => k.startsWith('param_'))); - const out = await spy(opts.sql as string, paramArgs); - if (out.error != null) { result.error = out.error; return result; } - if (out.cancelled) { result.cancelled = true; return result; } - const rows = (out.rows || []).slice(); - result.columns = out.columns || []; - result.rows = rows; - result.progress = { ...result.progress, rows: rows.length, bytes: (out.meta && out.meta.bytes) || 0 }; - result.capped = !!(out.meta && out.meta.truncated); - if (opts.onChunk) opts.onChunk(); + calls.push({ sql: opts.sql as string, params, format: opts.format as string | undefined }); + const resp = (await responder(opts.sql as string, paramArgs)) || {}; + if (opts.onChunk) { result.progress = { ...result.progress, rows: 3 }; opts.onChunk(); } + result.columns = resp.columns ?? [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }]; + result.rows = resp.rows ?? [['a', 1], ['b', 2]]; + result.progress = { ...result.progress, bytes: resp.bytes ?? 10, rows: (resp.rows ?? [[]]).length }; + result.capped = !!resp.capped; + result.error = resp.error ?? null; return result; }); + return { executeRead, calls }; } -// Build a dashboard app whose tiles run through the seam via `streamInto`. The -// `runTile` spy is exposed as `app.tileSpy` for the few call-count assertions -// that referenced the old `app.runTile`. -function dashApp(favorites: FavoriteInput[], runTile: TileSpy) { - const executeRead = streamInto(runTile); - const app = makeApp({ exec: { executeRead } }) as TestApp; - app.tileSpy = runTile; - setSaved(app, favorites); - return app; -} -const setSaved = (app: App, queries: FavoriteInput[]): void => { - app.state.savedQueries = queries.map((q) => savedQuery(q as SavedQueryFixture)) as AppState['savedQueries']; -}; - -describe('renderDashboard', () => { - it('runs Filter sources before Panels, creates no Filter tile, and upgrades the matching field', async () => { - const calls: string[] = []; - const runTile = tile(async (sql, params) => { - calls.push(sql); - if (sql === 'SELECT filter_options') return { - columns: [{ name: 'origin', type: 'Array(String)' }], rows: [[['ATL', 'JFK']]], meta: { rows: 1, bytes: 10 }, - }; - expect(params).toEqual({ param_origin: 'ATL' }); - return chartResult(); - }); - const app = dashApp([ - { id: 'f', name: 'Airport options', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } }, - { id: 'p', name: 'Flights', sql: 'SELECT * FROM flights WHERE origin={origin:String}', favorite: true }, - ], runTile); - app.state.varValues.origin = 'ATL'; - app.state.filterActive.origin = true; - await renderDashboard(app); - expect(calls).toEqual(['SELECT filter_options', 'SELECT * FROM flights WHERE origin={origin:String}']); - expect(qsa(app.root, '.dash-tile')).toHaveLength(1); - const curated = qs(app.root, '.filter-select .var-input'); - expect(curated).not.toBeNull(); - expect(curated.value).toBe('ATL'); - expect(rootEl(app).textContent).not.toContain('Airport optionsLoading'); - }); - - it('seeds curated fields from the persisted cache for an immediate combobox and re-persists the live bundle (#234)', async () => { - const runTile = tile(async (sql) => (sql === 'SELECT filter_options' - ? { columns: [{ name: 'origin', type: 'Array(String)' }], rows: [[['ATL', 'JFK']]], meta: { rows: 1, bytes: 1 } } - : chartResult())); - const app = dashApp([ - { id: 'f', name: 'Options', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } }, - { id: 'p', name: 'Panel', sql: 'SELECT * FROM t WHERE origin={origin:String}', favorite: true }, - ], runTile); - app.state.filterCurated = { origin: { options: [{ value: 'ATL', label: 'ATL' }], sourceType: 'Array(String)' } }; - // The first synchronous paint (before the async Filter wave resolves) must - // already show the curated combobox from cache — not a plain-text field. - const pending = renderDashboard(app); - expect(qs(app.root, '.filter-select .var-input')).not.toBeNull(); - await pending; - // …and the live wave persists its own bundle for the next load. - expect(app.saveJSON).toHaveBeenCalledWith('asb:filterCurated', expect.objectContaining({ - origin: expect.objectContaining({ options: [{ value: 'ATL', label: 'ATL' }, { value: 'JFK', label: 'JFK' }] }), - })); - }); - - it('deactivates a stale curated value without replacing it and gates a required Panel', async () => { - const runTile = tile(async (sql) => sql === 'SELECT filter_options' - ? { columns: [{ name: 'origin', type: 'Array(String)' }], rows: [[['JFK']]], meta: { rows: 1, bytes: 1 } } - : chartResult()); - const app = dashApp([ - { id: 'f', name: 'Options', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } }, - { id: 'p', name: 'Panel', sql: 'SELECT * FROM t WHERE origin={origin:String}', favorite: true }, - ], runTile); - app.state.varValues.origin = 'ATL'; - app.state.filterActive.origin = true; - await renderDashboard(app); - expect(app.state.varValues.origin).toBe('ATL'); - expect(app.state.filterActive.origin).toBe(false); - expect(app.params.saveFilterActive).toHaveBeenCalled(); - expect(runTile.mock.calls.map(([sql]) => sql)).toEqual(['SELECT filter_options']); - expect(qs(app.root, '.dash-tile-unfilled').textContent).toContain('origin'); - expect(qs(app.root, '.filter-select .var-input').placeholder).toBe('Not set'); - }); - - it('falls back per target on duplicate providers and still runs Panels', async () => { - const runTile = tile(async (sql) => sql.includes('filter_') - ? { columns: [{ name: 'x', type: 'Array(String)' }], rows: [[['a']]], meta: { rows: 1, bytes: 1 } } - : chartResult()); - const app = dashApp([ - { id: 'f1', name: 'One', sql: 'SELECT filter_one', favorite: true, dashboard: { role: 'filter' } }, - { id: 'f2', name: 'Two', sql: 'SELECT filter_two', favorite: true, dashboard: { role: 'filter' } }, - { id: 'p', name: 'Panel', sql: 'SELECT {x:String}', favorite: true }, - ], runTile); - app.state.varValues.x = 'a'; - app.state.filterActive.x = true; - await renderDashboard(app); - expect(qs(app.root, '.filter-select .var-input')).toBeNull(); - expect(qs(app.root, '.dash-filter-diagnostics').textContent).toContain('Multiple Filter queries provide "x": One, Two'); - expect(qsa(app.root, '.dash-tile')).toHaveLength(1); - expect(runTile).toHaveBeenCalledTimes(3); - }); - - it('uses ordinary fallback controls on a failed Filter request and exposes source Retry', async () => { - const runTile = tile(async (sql) => sql === 'SELECT filter_options' ? { error: 'boom' } : chartResult()); - const app = dashApp([ - { id: 'f', name: 'Options', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } }, - { id: 'p', name: 'Panel', sql: 'SELECT {x:String}', favorite: true }, - ], runTile); - app.state.varValues.x = 'a'; - app.state.filterActive.x = true; - await renderDashboard(app); - expect(qs(app.root, '.filter-select .var-input')).toBeNull(); - expect(qs(app.root, '.dash-filter-diagnostics').textContent).toContain('Options: boom'); - expect(qs(app.root, '.dash-filter-diagnostics button').textContent).toBe('Retry'); - expect(qsa(app.root, '.dash-tile')).toHaveLength(1); - }); +const q = (id: string, sql: string, extra: Partial = {}): SavedQueryFixture['id'] extends never ? never : ReturnType => + savedQuery({ id, name: id, sql, ...extra }); - it('reports an invalid Filter source without sending it and still runs Panels', async () => { - const runTile = tile(async () => chartResult()); - const app = dashApp([ - { id: 'f', name: 'Bad options', sql: 'SELECT 1 FORMAT CSV', favorite: true, dashboard: { role: 'filter' } }, - { id: 'p', name: 'Panel', sql: 'SELECT 1', favorite: true }, - ], runTile); - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(1); - expect(runTile).toHaveBeenCalledWith('SELECT 1', {}); - expect(qs(app.root, '.dash-filter-diagnostics').textContent).toContain('cannot include a trailing FORMAT'); - expect(qsa(app.root, '.dash-tile')).toHaveLength(1); - }); +interface WsOver { + id?: string; + tiles?: { id: string; queryId: string }[]; + filters?: Record[]; + layout?: Record; + queries?: ReturnType[]; + title?: string; +} +const wsWith = (over: WsOver = {}) => ({ + storageVersion: 1 as const, id: 'w', name: 'W', + queries: over.queries ?? [], + dashboard: { + documentVersion: 1 as const, id: over.id ?? 'd', title: over.title ?? 'My Dash', revision: 1, + layout: over.layout ?? { type: 'flow', version: 1, preset: 'columns-2', items: {} }, + filters: over.filters ?? [], tiles: over.tiles ?? [], + }, +}); - it('keeps Setup and unknown future roles out of Panel execution with diagnostics', async () => { - const runTile = tile(); - const app = dashApp([ - { id: 's', name: 'Prepare', sql: 'CREATE TABLE t', favorite: true, dashboard: { role: 'setup' } }, - { id: 'u', name: 'Future', sql: 'SELECT 1', favorite: true, dashboard: { role: 'future-role' } }, - ], runTile); - await renderDashboard(app); - expect(runTile).not.toHaveBeenCalled(); - expect(qsa(app.root, '.dash-tile')).toHaveLength(0); - expect(rootEl(app).textContent).toContain('Prepare uses Setup, which is not implemented yet.'); - expect(rootEl(app).textContent).toContain('Future has unknown Dashboard role "future-role".'); - }); +function dashApp(opts: { + workspace?: ReturnType | null; + responder?: ExecResponder; + commit?: ReturnType; + savedQueries?: ReturnType[]; +} = {}) { + const { executeRead, calls } = makeExec(opts.responder); + const commit = opts.commit ?? vi.fn(async () => ({ ok: true, workspace: {} as never, dashboardRevision: 2 })); + const app = makeApp({ + exec: { executeRead }, + workspace: { commit }, + loadDashboardWorkspace: async () => (opts.workspace === undefined ? null : opts.workspace) as never, + }) as TestApp; + if (opts.savedQueries) app.state.savedQueries = opts.savedQueries as AppState['savedQueries']; + return { app, calls, commit }; +} - it('retries only the failed Filter source and re-runs Panels affected by reconciliation', async () => { - let filterAttempt = 0; - const runTile = tile(async (sql) => { - if (sql === 'SELECT filter_options') { - filterAttempt++; - if (filterAttempt === 1) return { error: 'temporary' }; - return { columns: [{ name: 'x', type: 'Array(String)' }], rows: [[['new']]], meta: { rows: 1, bytes: 1 } }; - } - return chartResult(); +const render = (app: TestApp): Promise => renderDashboard(app as unknown as Parameters[0]); +const seg = (root: ParentNode | null, label: string): HTMLElement | undefined => + qsa(root, '.dash-seg-layout .dash-seg-btn').find((b) => b.textContent === label); + +describe('renderDashboard — read-flip to dashboard.tiles (#286)', () => { + it('renders one tile per dashboard.tiles entry — independent of spec.favorite', async () => { + // Neither query is favorited; both are tiles. Membership is dashboard.tiles. + const { app, calls } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a', { favorite: false }), q('q2', 'SELECT k, v FROM b', { favorite: false })], + tiles: [{ id: 't1', queryId: 'q1' }, { id: 't2', queryId: 'q2' }], + }), }); - const app = dashApp([ - { id: 'f', name: 'Options', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } }, - { id: 'p', name: 'Panel', sql: 'SELECT {x:String}', favorite: true }, - ], runTile); - app.state.varValues.x = 'stale'; - app.state.filterActive.x = true; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(2); - qs(app.root, '.dash-filter-diagnostics button').dispatchEvent(new Event('click', { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(app.conn.ensureFreshToken).toHaveBeenCalledTimes(3); - expect(filterAttempt).toBe(2); - expect(app.state.filterActive.x).toBe(false); - expect(app.params.saveFilterActive).toHaveBeenCalled(); - expect(runTile).toHaveBeenCalledTimes(3); - expect(qs(app.root, '.filter-select .var-input')).not.toBeNull(); - }); - it('renders a header + a chart tile per chartable favorite', async () => { - const favorites = [ - { id: '1', name: 'Chart A', sql: 'chartA', favorite: true }, - { id: '2', name: 'Chart B', sql: 'chartB', favorite: true }, - ]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - await renderDashboard(app); - const header = qs(app.root, '.dash-header'); - const back = qs(header, '.dash-back'); - const refresh = qs(header, '.dash-refresh'); - expect(back.getAttribute('aria-label')).toBe('Back to SQL Browser'); - expect(qs(back, '.dash-back-label').textContent).toBe('SQL Browser'); - expect(refresh.getAttribute('aria-label')).toBe('Refresh dashboard'); - expect(qs(refresh, '.dash-refresh-label').textContent).toBe('Refresh'); - expect(qs(header, '.dash-icobtn').getAttribute('aria-label')).toBe('Toggle theme'); - expect(qs(app.root, '.dash-fav').textContent).toContain('2 favorites'); - expect(qs(app.root, '.dash-toolbar').classList.contains('has-filters')).toBe(false); + await render(app); expect(qsa(app.root, '.dash-tile').length).toBe(2); - expect(qs(app.root, '.dash-tile canvas')).not.toBeNull(); - expect(qs(app.root, '.dash-tile-foot').textContent).toContain('rows'); - }); - - it('renders the saved description as a tile subtitle when present, omits it otherwise', async () => { - const favorites = [ - { id: '1', name: 'With desc', sql: 'a', favorite: true, description: 'Daily totals by category' }, - { id: '2', name: 'No desc', sql: 'b', favorite: true }, - ]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - await renderDashboard(app); - const descs = [...qsa(app.root, '.dash-tile-desc')]; - expect(descs).toHaveLength(1); - expect(descs[0].textContent).toBe('Daily totals by category'); - expect(descs[0].getAttribute('title')).toBe('Daily totals by category'); - }); - - it('uses the singular chip label with exactly one favorite', async () => { - const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], vi.fn(async () => chartResult())); - await renderDashboard(app); - expect(qs(app.root, '.dash-fav').textContent).toContain('1 favorite'); - }); - - it('auto-renders eligible single-row favorites as KPI cards', async () => { - const favorites = [ - { id: '1', name: 'Chart', sql: 'chart', favorite: true }, - { id: '2', name: 'Kpi', sql: 'kpi', favorite: true }, - ]; - const runTile = tile(async (sql) => (sql === 'kpi' ? kpiResult() : chartResult())); - const app = dashApp(favorites, runTile); - await renderDashboard(app); - const tiles = [...qsa(app.root, '.dash-tile')]; - expect(tiles.length).toBe(2); - expect(tiles.filter((t) => t.style.display !== 'none')).toHaveLength(2); - expect(qs(tiles[1], '.kpi-value').textContent).toBe('42'); - expect(tiles[1].classList.contains('is-kpi')).toBe(true); - const note = qs(app.root, '.dash-skip'); - expect(note.style.display).toBe('none'); + expect(qs(app.root, '.dash-fav span:last-child')?.textContent).toBe('2 tiles'); + const sqls = calls.map((c) => c.sql); + expect(sqls).toContain('SELECT k, v FROM a'); + expect(sqls).toContain('SELECT k, v FROM b'); + expect(qs(app.root, '.dash-title')?.textContent).toBe('My Dash'); + expect(qsa(app.root, '.dash-tile canvas').length).toBeGreaterThan(0); }); - it('shows a per-tile error when the query fails', async () => { - const app = dashApp([{ id: '1', name: 'Bad', sql: 'boom', favorite: true }], vi.fn(async () => ({ error: 'Cannot execute' }))); - await renderDashboard(app); - expect(qs(app.root, '.dash-tile-error').textContent).toBe('Cannot execute'); - expect(qs(app.root, '.dash-skip').style.display).toBe('none'); // an error is not a skip + it('shows the empty state for a dashboard with no tiles', async () => { + const { app } = dashApp({ workspace: wsWith({ tiles: [] }) }); + await render(app); + expect((qs(app.root, '.dash-empty') as HTMLElement).style.display).toBe(''); + expect(qs(app.root, '.dash-fav span:last-child')?.textContent).toBe('0 tiles'); }); - it('the footer always shows rows · ms · bytes on the streaming seam (#193: wall-clock ms + progress bytes)', async () => { - // Unlike the old FORMAT-JSON path (which omitted stats CH did not report), - // the streaming seam always has a wall-clock ms and a progress byte count - // (0 when none streamed), so the footer row is unconditional — three spans. - const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], - vi.fn(async () => chartResult({ rows: 2, bytes: 0 }))); - await renderDashboard(app); - const foot = qs(app.root, '.dash-tile-foot'); - expect(foot.children.length).toBe(3); - expect(foot.textContent).toContain('0 ms'); - expect(foot.textContent).toContain('scanned'); + it('falls back to an empty dashboard when no workspace resolves', async () => { + const { app } = dashApp({ workspace: null, savedQueries: [q('q1', 'SELECT 1', { favorite: true })] }); + await render(app); + expect(qsa(app.root, '.dash-tile').length).toBe(0); + expect((qs(app.root, '.dash-empty') as HTMLElement).style.display).toBe(''); }); - it('a fetch-truncated tile gets the honest "first N rows fetched" footer note (#149 D9)', async () => { - const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], - vi.fn(async () => chartResult({ rows: 5000, ms: 5, bytes: 100, truncated: true }))); - await renderDashboard(app); - expect(qs(app.root, '.dash-tile-foot').textContent) - .toContain('first ' + DASH_TILE_ROW_CAP.toLocaleString() + ' rows fetched — sorting/charts cover this prefix only'); + it('renders an error tile, an unfilled tile, and a fetch-truncated footer', async () => { + const { app } = dashApp({ + responder: (sql) => (sql.includes('boom') ? { error: 'ch down' } : { capped: true }), + workspace: wsWith({ + queries: [q('ok', 'SELECT k, v FROM t'), q('bad', 'SELECT boom'), q('need', 'SELECT {yr:UInt16}')], + tiles: [{ id: 't1', queryId: 'ok' }, { id: 't2', queryId: 'bad' }, { id: 't3', queryId: 'need' }], + }), + }); + await render(app); + expect(qs(app.root, '.dash-tile-error')?.textContent).toBe('ch down'); + expect(qs(app.root, '.dash-tile-unfilled')?.textContent).toContain('yr'); + expect(qsa(app.root, '.dash-tile-foot span').some((s) => /rows fetched/.test(s.textContent || ''))).toBe(true); }); - it('has a theme toggle wired to app.toggleTheme', async () => { - const toggleTheme = vi.fn(); - const app = makeApp({ exec: { executeRead: streamInto(vi.fn(async () => chartResult())) }, toggleTheme }); - app.state.theme = 'dark'; // exercise the dark-theme icon branch - setSaved(app, [{ id: '1', name: 'Q', sql: 'q', favorite: true }]); - await renderDashboard(app); + it('has a theme toggle wired to app.toggleTheme and shows the sun icon in dark mode', async () => { + const { app } = dashApp({ workspace: wsWith({ tiles: [] }) }); + app.state.theme = 'dark'; + await render(app); const btn = qs(app.root, '.dash-icobtn'); - expect(btn).toBeTruthy(); + expect(btn).not.toBeNull(); btn.dispatchEvent(new Event('click', { bubbles: true })); - expect(toggleTheme).toHaveBeenCalled(); + expect(app.toggleTheme).toHaveBeenCalled(); }); - it('redirects to login once (no tiles) when the session cannot be refreshed', async () => { - const onSignedOut = vi.fn(); - const ensureFreshToken = vi.fn(async () => false); - const app = makeApp({ - exec: { executeRead: streamInto(vi.fn(async () => chartResult())) }, - conn: { ensureFreshToken }, - chCtx: { onSignedOut }, + it('Refresh re-runs the tiles and re-paints without a schema reset', async () => { + const { app, calls } = dashApp({ + workspace: wsWith({ queries: [q('q1', 'SELECT k, v FROM a')], tiles: [{ id: 't1', queryId: 'q1' }] }), }); - setSaved(app, [ - { id: '1', name: 'Q', sql: 'q', favorite: true }, - { id: '2', name: 'R', sql: 'r', favorite: true }, - ]); - await renderDashboard(app); - expect(onSignedOut).toHaveBeenCalledTimes(1); // one redirect, not one per tile - expect(app.exec.executeRead).not.toHaveBeenCalled(); - expect(qsa(app.root, '.dash-tile').length).toBe(0); - }); - - it('tears down the previous tiles Chart.js instances on Refresh (no leak)', async () => { - const charts: FakeChart[] = []; - const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], vi.fn(async () => chartResult())); - const Base = app.Chart; - app.Chart = class extends Base { constructor(...a: ConstructorParameters) { super(...a); charts.push(this); } }; - await renderDashboard(app); - expect(charts).toHaveLength(1); - await runOnclick(qs(app.root, '.dash-btn')); - expect(charts).toHaveLength(2); - expect(charts[0].destroyed).toBe(true); // prior instance destroyed, not orphaned - }); - - it('a tile that flips chart -> KPI on Refresh clears its old chart DOM', async () => { - const runTile = tile(async () => chartResult()); - const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], runTile); - await renderDashboard(app); - expect(qs(app.root, '.dash-tile canvas')).not.toBeNull(); - runTile.mockImplementation(async () => kpiResult()); - await runOnclick(qs(app.root, '.dash-btn')); - expect(qs(app.root, '.dash-tile').style.display).toBe(''); - expect(qs(app.root, '.dash-tile canvas')).toBeNull(); // stale chart DOM cleared, not just hidden - expect(qs(app.root, '.kpi-card')).not.toBeNull(); - }); - - it('Refresh marks every tile loading immediately (no stale content lingers beyond the concurrency window)', async () => { - const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - const favorites = Array.from({ length: 8 }, (_, i) => ({ id: String(i), name: 'Q' + i, sql: 'q' + i, favorite: true })); - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - await renderDashboard(app); - expect(qsa(app.root, '.dash-tile canvas').length).toBe(8); - - const resolvers: ((v: TileOutcome) => void)[] = []; - runTile.mockImplementation(() => new Promise((resolve) => resolvers.push(resolve))); - const refreshed = runOnclick(qs(app.root, '.dash-btn')); - await flush(); - // All 8 tiles show "Loading…" up front, even though TILE_CONCURRENCY (6) - // means only 6 queries are actually in flight — none show the prior chart. - expect(qsa(app.root, '.dash-tile-load').length).toBe(8); - expect(qsa(app.root, '.dash-tile canvas').length).toBe(0); - // TILE_CONCURRENCY (6) means only 6 of the 8 queries are in flight yet; - // resolving them frees pool slots for the remaining 2 — drain in rounds. - for (let round = 0; round < 4; round++) { - resolvers.splice(0).forEach((r) => r(chartResult())); - await flush(); - } - await refreshed; - expect(qsa(app.root, '.dash-tile canvas').length).toBe(8); - }); - - it('shows an empty state when there are no favorites', async () => { - const app = dashApp([], vi.fn()); - await renderDashboard(app); - expect(qs(app.root, '.dash-empty').style.display).toBe(''); - expect(qsa(app.root, '.dash-tile').length).toBe(0); - }); - - it('Refresh re-runs every tile', async () => { - const runTile = tile(async () => chartResult()); - const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], runTile); - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(1); - await runOnclick(qs(app.root, '.dash-btn')); - expect(runTile).toHaveBeenCalledTimes(2); - }); - - it('renders read-only tiles with no interactive chart-config bar (D1)', async () => { - const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], vi.fn(async () => chartResult())); - await renderDashboard(app); - expect(qs(app.root, '.dash-tile canvas')).not.toBeNull(); - expect(qs(app.root, '.dash-tile .chart-config')).toBeNull(); // controls omitted, not hidden - expect(qs(app.root, '.dash-tile .chart-select')).toBeNull(); - }); - - // ── #184: one four-way layout switcher (Full width | Report | 2/3 columns) ─── - const oneFav = () => dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], vi.fn(async () => chartResult())); - const seg = (root: ParentNode | null, label: string): HTMLElement | undefined => - qsa(root, '.dash-seg-layout .dash-seg-btn').find((b) => b.textContent === label); - const layoutBtns = (root: ParentNode | null): HTMLElement[] => qsa(root, '.dash-seg-layout .dash-seg-btn'); - - it('renders exactly four layout buttons, an accessible group label, and no separate Columns control', async () => { - const app = oneFav(); - await renderDashboard(app); - expect(layoutBtns(app.root).map((b) => b.textContent)) - .toEqual(['Full width', 'Report', '2 columns', '3 columns']); - expect(qs(app.root, '.dash-seg-layout').getAttribute('aria-label')).toBe('Dashboard layout'); - // The old right-aligned Columns control is gone entirely. - expect(qs(app.root, '.dash-cols-wrap')).toBeNull(); - expect(qs(app.root, '.dash-seg-cols')).toBeNull(); - expect([...qsa(app.root, '.dash-seg-label')].map((s) => s.textContent)).toEqual(['Layout']); - }); - - it('exactly one button is active/aria-pressed at a time', async () => { - const app = oneFav(); - await renderDashboard(app); - const pressed = () => layoutBtns(app.root).filter((b) => b.getAttribute('aria-pressed') === 'true'); - expect(pressed().map((b) => b.textContent)).toEqual(['3 columns']); // default arrange + 3 cols - seg(app.root, 'Full width')!.dispatchEvent(new Event('click', { bubbles: true })); - expect(pressed().map((b) => b.textContent)).toEqual(['Full width']); - expect(layoutBtns(app.root).filter((b) => b.classList.contains('is-active'))).toHaveLength(1); - }); - - it('activates 2 columns / 3 columns from the persisted arrange + dashCols state', async () => { - const app2 = oneFav(); - app2.state.dashLayout = 'arrange'; - app2.state.dashCols = 2; - await renderDashboard(app2); - expect(seg(app2.root, '2 columns')!.getAttribute('aria-pressed')).toBe('true'); - - const app3 = oneFav(); - app3.state.dashLayout = 'arrange'; - app3.state.dashCols = 3; - await renderDashboard(app3); - expect(seg(app3.root, '3 columns')!.getAttribute('aria-pressed')).toBe('true'); - }); - - it('reflects a persisted wide/report layout on first render', async () => { - const appW = oneFav(); - appW.state.dashLayout = 'wide'; - await renderDashboard(appW); - expect(qs(appW.root, '.dash-grid').classList.contains('is-wide')).toBe(true); - expect(seg(appW.root, 'Full width')!.getAttribute('aria-pressed')).toBe('true'); - - const appR = oneFav(); - appR.state.dashLayout = 'report'; - await renderDashboard(appR); - expect(qs(appR.root, '.dash-grid').classList.contains('is-report')).toBe(true); - expect(seg(appR.root, 'Report')!.getAttribute('aria-pressed')).toBe('true'); - }); - - it('Full width stores dashLayout=wide and toggles is-wide (only that key persists)', async () => { - const app = oneFav(); - await renderDashboard(app); - seg(app.root, 'Full width')!.dispatchEvent(new Event('click', { bubbles: true })); - const grid = qs(app.root, '.dash-grid'); - expect(grid.classList.contains('is-wide')).toBe(true); - expect(grid.classList.contains('is-report')).toBe(false); - expect(app.state.dashLayout).toBe('wide'); - expect(app.prefs.save).toHaveBeenCalledWith('dashLayout', 'wide'); - expect(app.prefs.save).not.toHaveBeenCalledWith('dashCols', expect.anything()); - }); - - it('Report stores dashLayout=report and toggles is-report', async () => { - const app = oneFav(); - await renderDashboard(app); - seg(app.root, 'Report')!.dispatchEvent(new Event('click', { bubbles: true })); - const grid = qs(app.root, '.dash-grid'); - expect(grid.classList.contains('is-report')).toBe(true); - expect(app.state.dashLayout).toBe('report'); - expect(app.prefs.save).toHaveBeenCalledWith('dashLayout', 'report'); - }); - - it('2 columns stores dashLayout=arrange + dashCols=2 (persisting both when both change)', async () => { - const app = oneFav(); - app.state.dashLayout = 'wide'; // start off-arrange so both keys change - await renderDashboard(app); - seg(app.root, '2 columns')!.dispatchEvent(new Event('click', { bubbles: true })); - const grid = qs(app.root, '.dash-grid'); - expect(grid.classList.contains('is-wide')).toBe(false); - expect(grid.style.getPropertyValue('--dash-cols')).toBe('2'); - expect(app.state.dashLayout).toBe('arrange'); - expect(app.state.dashCols).toBe(2); - expect(app.prefs.save).toHaveBeenCalledWith('dashLayout', 'arrange'); - expect(app.prefs.save).toHaveBeenCalledWith('dashCols', 2); - }); - - it('3 columns stores dashLayout=arrange + dashCols=3', async () => { - const app = oneFav(); - app.state.dashLayout = 'wide'; - app.state.dashCols = 2; // start at 2 so the dashCols save path runs - await renderDashboard(app); - seg(app.root, '3 columns')!.dispatchEvent(new Event('click', { bubbles: true })); - expect(app.state.dashLayout).toBe('arrange'); - expect(app.state.dashCols).toBe(3); - expect(app.prefs.save).toHaveBeenCalledWith('dashLayout', 'arrange'); - expect(app.prefs.save).toHaveBeenCalledWith('dashCols', 3); - }); - - it('picking the same column count keeps dashLayout untouched (only dashCols persists)', async () => { - const app = oneFav(); // default arrange + 3 - await renderDashboard(app); - seg(app.root, '2 columns')!.dispatchEvent(new Event('click', { bubbles: true })); - expect(app.prefs.save).toHaveBeenCalledWith('dashCols', 2); - expect(app.prefs.save).not.toHaveBeenCalledWith('dashLayout', expect.anything()); - }); - - it('clicking the already-active view is a no-op (no persist)', async () => { - const app = oneFav(); // default arrange + 3 → "3 columns" active - await renderDashboard(app); - seg(app.root, '3 columns')!.dispatchEvent(new Event('click', { bubbles: true })); - expect(app.prefs.save).not.toHaveBeenCalled(); - }); - - it('changing layout never re-runs tile queries', async () => { - const app = oneFav(); - await renderDashboard(app); - expect(app.exec.executeRead).toHaveBeenCalledTimes(1); // the initial render - seg(app.root, 'Full width')!.dispatchEvent(new Event('click', { bubbles: true })); - seg(app.root, 'Report')!.dispatchEvent(new Event('click', { bubbles: true })); - seg(app.root, '2 columns')!.dispatchEvent(new Event('click', { bubbles: true })); - expect(app.exec.executeRead).toHaveBeenCalledTimes(1); // presentation-only — no refetch - }); -}); - -// ── #193: tiles on the shared streaming app.exec.executeRead seam ───────────────── -describe('renderDashboard — streaming seam (#193)', () => { - const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - const yearInput = (root: ParentNode | null): HTMLInputElement => qs(root, '.var-field input[aria-label="year"]'); - const commit = (input: HTMLInputElement, value: string): void => { - input.value = value; - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - }; - const paramFav = (id: string, table: string = id): FavoriteInput => - ({ id, name: id, sql: `SELECT * FROM ${table} WHERE y = {year:UInt16}`, favorite: true }); - - it('streams read-only with the row-cap split, readonly/byte caps, param args, and a signal (req 1/3)', async () => { - const app = dashApp([{ id: '1', name: 'Q', sql: 'SELECT {year:UInt16} AS n', favorite: true }], - vi.fn(async () => chartResult())); - app.state.varValues = { year: '2024' }; - await renderDashboard(app); - expect(app.exec.executeRead).toHaveBeenCalledTimes(1); - const [result, opts = {} as ExecuteReadOpts] = app.exec.executeRead.mock.calls[0]; - expect(opts.format).toBe('Table'); - expect(opts.rowLimit).toBe(DASH_TILE_ROW_CAP + 1); // server max_result_rows = CAP + 1 (sentinel) - expect(result.rowLimit).toBe(DASH_TILE_ROW_CAP); // client-side trim = CAP - expect(opts.params).toMatchObject({ readonly: 2, max_result_bytes: DASH_TILE_BYTE_CAP, param_year: '2024' }); - expect(opts.signal).toBeTruthy(); // an AbortController signal → real per-tile cancellation + await render(app); + const before = calls.length; + await (runOnclick(qs(app.root, '.dash-refresh')) as Promise); + expect(calls.length).toBeGreaterThan(before); + expect(qsa(app.root, '.dash-tile canvas').length).toBeGreaterThan(0); }); - it('uses the same owned typed transport and two-row sentinel for an explicit KPI, rendered in a KPI band (#240)', async () => { - const app = dashApp([{ id: '1', name: 'KPI', sql: 'SELECT 42 AS n', favorite: true, panel: { cfg: { type: 'kpi' } } }], vi.fn(async () => kpiResult())); - await renderDashboard(app); - const [result, opts = {} as ExecuteReadOpts] = app.exec.executeRead.mock.calls[0]; - expect(result.rawFormat).toBe('KPI'); - expect(result.rowLimit).toBe(2); - expect(opts).toMatchObject({ format: 'KPI', rowLimit: 2 }); - expect(opts.params).toMatchObject({ readonly: 2, output_format_json_named_tuples_as_objects: 1, output_format_json_quote_decimals: 1 }); - expect(qs(app.root, '.kpi-value').textContent).toBe('42'); - // An explicit KPI favorite never gets an ordinary gray tile — it renders - // directly inside a full-width band's shared card stream (#240). - expect(qs(app.root, '.dash-tile')).toBeNull(); - expect(qs(app.root, '.dash-kpi-band')).not.toBeNull(); - expect(qs(app.root, '.dash-kpi-stream .kpi-card')).not.toBeNull(); - }); - - it('uses the KPI-specific authored FORMAT diagnostic and sends no request, as an in-band state card (#240)', async () => { - const app = dashApp([{ id: '1', name: 'KPI', sql: 'SELECT 1 FORMAT CSV', favorite: true, panel: { cfg: { type: 'kpi' } } }], vi.fn()); - await renderDashboard(app); - expect(app.exec.executeRead).not.toHaveBeenCalled(); - const card = qs(app.root, '.dash-kpi-state-card'); - expect(card.getAttribute('role')).toBe('alert'); - expect(qs(card, '.dash-kpi-state-message').textContent) - .toBe('KPI panel owns the result format. Remove FORMAT CSV from the SQL.'); - }); - - it('shows an in-band unfilled state card for an explicit KPI with a missing {name:Type} value, sending no request (#240)', async () => { - const app = dashApp([{ id: '1', name: 'KPI', sql: 'SELECT {year:UInt16} AS n', favorite: true, panel: { cfg: { type: 'kpi' } } }], vi.fn()); - await renderDashboard(app); - expect(app.exec.executeRead).not.toHaveBeenCalled(); - const card = qs(app.root, '.dash-kpi-state-card'); - expect(card.getAttribute('role')).toBe('status'); - expect(qs(card, '.dash-kpi-state-message').textContent).toBe('Enter a value for: year'); - }); - - it('per-source gating (#173/#240): a KPI source value that cannot serialize errors only its own card, not a sibling in the same band', async () => { - const app = dashApp([ - { id: '1', name: 'KPI A', sql: 'SELECT {db:String} AS n', favorite: true, panel: { cfg: { type: 'kpi' } } }, - { id: '2', name: 'KPI B', sql: 'SELECT 7 AS n', favorite: true, panel: { cfg: { type: 'kpi' } } }, - ], vi.fn(async () => kpiResult())); - // A deliberately wrong-shaped fixture (#173/#240's structural-error path): - // varValues is declared Record, but a real filter/KPI - // source value CAN structurally be an array — through a Record local, not `any`/`as unknown as`. - const badValues: Record = { db: ['not', 'scalar'] }; - app.state.varValues = badValues as Record; // array value, scalar declaration → structural error - await renderDashboard(app); - const stateCards = [...qsa(app.root, '.dash-kpi-state-card')]; - expect(stateCards).toHaveLength(1); - expect(stateCards[0].getAttribute('role')).toBe('alert'); - expect(qs(stateCards[0], '.dash-kpi-state-message').textContent).toContain('array value'); - // The sibling KPI in the same band still rendered its card. - expect(qsa(app.root, '.dash-kpi-stream .kpi-card')).toHaveLength(1); - expect(qsa(app.root, '.dash-kpi-band')).toHaveLength(1); // one shared band, not two - }); - - it('a filter-triggered runAffected wave re-runs an explicit KPI favorite through its band, never the ordinary tile path (#240)', async () => { - const runTile = tile(async () => kpiResult()); - const app = dashApp([{ id: '1', name: 'KPI', sql: 'SELECT {year:UInt16} AS n', favorite: true, panel: { cfg: { type: 'kpi' } } }], runTile); - app.state.varValues = { year: '2024' }; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(1); - expect(qsa(app.root, '.dash-kpi-stream .kpi-card')).toHaveLength(1); - const input = yearInput(app.root); - commit(input, '2025'); - await flush(); - expect(runTile).toHaveBeenCalledTimes(2); - expect(runTile.mock.calls[1][1]).toEqual({ param_year: '2025' }); - // Still routed through the KPI band on the affected wave — never - // misrouted into the ordinary tile path (planWave/runPlan share the - // same slot.kind dispatch for both runAll and runAffected). - expect(qs(app.root, '.dash-tile')).toBeNull(); - expect(qsa(app.root, '.dash-kpi-stream .kpi-card')).toHaveLength(1); - }); - - it('exactly-CAP is not truncated; CAP+1 is trimmed AND flagged (req 1, via the real applyStreamLine)', async () => { - // Stream N single-column rows through the REAL accumulator so the client cap - // (newResult('Table', CAP)) trims + flags exactly as production would. - const streamN = (n: number) => vi.fn(async (result: ExecuteReadResult, opts: ExecuteReadOpts) => { - applyStreamLine({ meta: [{ name: 'n', type: 'UInt64' }] }, result); - for (let i = 0; i < n; i++) applyStreamLine({ row: { n: i } }, result); - applyStreamLine({ progress: { read_rows: n, read_bytes: 10 } }, result); - opts.onChunk?.(); - return result; + it('signs out when the token preflight fails, running no tiles', async () => { + const { app, calls } = dashApp({ + workspace: wsWith({ queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }), }); - const fav: FavoriteInput[] = [{ id: '1', name: 'Q', sql: 'q', favorite: true, panel: { cfg: { type: 'table' } } }]; - - const exact = makeApp({ exec: { executeRead: streamN(DASH_TILE_ROW_CAP) } }); - setSaved(exact, fav); - await renderDashboard(exact); - expect(qs(exact.root, '.dash-tile-foot').textContent).not.toContain('rows fetched'); - - const over = makeApp({ exec: { executeRead: streamN(DASH_TILE_ROW_CAP + 1) } }); - setSaved(over, fav); - await renderDashboard(over); - const foot = qs(over.root, '.dash-tile-foot').textContent; - expect(foot).toContain('first ' + DASH_TILE_ROW_CAP.toLocaleString() + ' rows fetched'); - expect(foot).toContain(DASH_TILE_ROW_CAP.toLocaleString() + ' rows'); // rows SHOWN = trimmed CAP, not CAP+1 + const onSignedOut = vi.fn(); + app.conn.ensureFreshToken = vi.fn(async () => false); + app.conn.chCtx.onSignedOut = onSignedOut; + await render(app); + expect(onSignedOut).toHaveBeenCalled(); + expect(calls.length).toBe(0); }); +}); - it('updates only the loading placeholder as rows stream — never classifies mid-stream (req 4)', async () => { - const executeRead = vi.fn((result: ExecuteReadResult, opts: ExecuteReadOpts) => { - applyStreamLine({ meta: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }] }, result); - applyStreamLine({ row: { k: 'a', v: '1' } }, result); - applyStreamLine({ progress: { read_rows: 1420, read_bytes: 10 } }, result); - opts.onChunk?.(); // mid-stream repaint - return new Promise(() => {}); // never settles — stay in the loading state +describe('renderDashboard — flow layout + preset switcher (#280)', () => { + it('packs tiles into the preset columns and switches preset via a change-layout command', async () => { + const { app, commit } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a'), q('q2', 'SELECT k, v FROM b')], + tiles: [{ id: 't1', queryId: 'q1' }, { id: 't2', queryId: 'q2' }], + layout: { type: 'flow', version: 1, preset: 'columns-2', items: {} }, + }), }); - const app = makeApp({ exec: { executeRead } }); - setSaved(app, [{ id: '1', name: 'Q', sql: 'q', favorite: true }]); - renderDashboard(app); - await flush(); - const load = qs(app.root, '.dash-tile-load'); - expect(load).not.toBeNull(); - expect(load.textContent).toBe('Loading… 1.4K rows'); // progress row count (formatRows compact), placeholder only - expect(qs(app.root, '.dash-tile canvas')).toBeNull(); // NOT classified/charted yet - expect(qs(app.root, '.dash-tile-foot').textContent).toBe(''); // no footer mid-stream - }); - - it('rejects an explicit FORMAT clause with a clear error and issues no request (req 5)', async () => { - const spy = tile(async () => chartResult()); - const app = dashApp([{ id: '1', name: 'Q', sql: 'SELECT 1 FORMAT JSON', favorite: true }], spy); - await renderDashboard(app); - expect(qs(app.root, '.dash-tile-error').textContent) - .toContain('Remove the explicit FORMAT clause'); - expect(app.exec.executeRead).not.toHaveBeenCalled(); // never mis-parsed as a structured stream - }); - - it('full Refresh performs exactly one token preflight before fanning out (req 2)', async () => { - const app = dashApp([ - { id: '1', name: 'A', sql: 'a', favorite: true }, - { id: '2', name: 'B', sql: 'b', favorite: true }, - ], vi.fn(async () => chartResult())); - await renderDashboard(app); - expect(app.conn.ensureFreshToken).toHaveBeenCalledTimes(1); // once for the whole wave, not per tile - }); - - it('an affected-filter wave preflights once; a failed preflight issues no requests and signs out (req 2)', async () => { - const app = dashApp([paramFav('1', 't')], vi.fn(async () => chartResult())); - app.state.varValues = { year: '1' }; - await renderDashboard(app); - expect(app.conn.ensureFreshToken).toHaveBeenCalledTimes(1); // the initial refresh - app.exec.executeRead.mockClear(); - app.conn.ensureFreshToken.mockResolvedValue(false); // session lost before the affected wave - commit(yearInput(app.root), '2'); - await flush(); - expect(app.conn.ensureFreshToken).toHaveBeenCalledTimes(2); // one preflight for the affected wave - expect(app.conn.chCtx.onSignedOut).toHaveBeenCalledTimes(1); - expect(app.exec.executeRead).not.toHaveBeenCalled(); // failed preflight → no tile requests - }); - - it('a newer wave aborts the previous slot request at wave creation (generation reserved up front, req 3/5)', async () => { - const signals: (AbortSignal | undefined)[] = []; - const resolvers: (() => void)[] = []; - const executeRead = vi.fn((result: ExecuteReadResult, opts: ExecuteReadOpts) => { - signals.push(opts.signal); - return new Promise((res) => resolvers.push(() => { result.columns = [{ name: 'k', type: 'String' }]; result.rows = [['a']]; res(result); })); + await render(app); + expect(seg(app.root, '2 columns')?.getAttribute('aria-pressed')).toBe('true'); + const rows = qsa(app.root, '.dash-row'); + expect((rows[0].style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(2'); + // Switch to full-width — one column. + seg(app.root, 'Full width')!.dispatchEvent(new Event('click', { bubbles: true })); + expect(seg(app.root, 'Full width')?.getAttribute('aria-pressed')).toBe('true'); + expect((qsa(app.root, '.dash-row')[0].style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(1'); + expect(commit).toHaveBeenCalled(); + }); + + it('defaults the preset to full-width when the layout omits it', async () => { + const { app } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a')], + tiles: [{ id: 't1', queryId: 'q1' }], + layout: { type: 'flow', version: 1 } as unknown as Record, + }), }); - const app = makeApp({ exec: { executeRead } }); - setSaved(app, [paramFav('1', 't')]); - app.state.varValues = { year: '1' }; - const rendered = renderDashboard(app); - await flush(); - expect(signals).toHaveLength(1); - resolvers.splice(0).forEach((r) => r()); - await rendered; - const input = yearInput(app.root); - commit(input, '11'); // wave A - await flush(); - expect(signals).toHaveLength(2); - commit(input, '22'); // wave B — created before A's request settled - await flush(); - expect(signals).toHaveLength(3); - expect(signals[1]!.aborted).toBe(true); // A superseded at B's CREATION, before A resolved - expect(signals[2]!.aborted).toBe(false); - resolvers.splice(0).forEach((r) => r()); // drain (both A and B) — no throw - await flush(); - }); - - it('a queued Refresh worker superseded by a newer wave discards itself without issuing (req 3/5/7)', async () => { - const calls: string[] = []; - const resolvers: (() => void)[] = []; - const executeRead = vi.fn((result: ExecuteReadResult, opts: ExecuteReadOpts) => { - calls.push((opts.params as Record).param_year); - return new Promise((res) => resolvers.push(() => { result.columns = [{ name: 'k', type: 'String' }]; result.rows = [['a']]; res(result); })); + await render(app); + expect(seg(app.root, 'Full width')?.getAttribute('aria-pressed')).toBe('true'); + expect((qsa(app.root, '.dash-row')[0].style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(1'); + expect(qsa(app.root, '.dash-tile').length).toBe(1); + }); + + it('normalizes to one column on the mobile breakpoint and restores on desktop', async () => { + const { app } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a'), q('q2', 'SELECT k, v FROM b')], + tiles: [{ id: 't1', queryId: 'q1' }, { id: 't2', queryId: 'q2' }], + layout: { type: 'flow', version: 1, preset: 'columns-2', items: { t1: { span: 2 } } }, + }), }); - const app = makeApp({ exec: { executeRead } }); - setSaved(app, Array.from({ length: 8 }, (_, i) => paramFav(String(i), 't' + i))); - app.state.varValues = { year: '1' }; - const rendered = renderDashboard(app); // wave A (full Refresh) - await flush(); - expect(calls.filter((v) => v === '1')).toHaveLength(6); // TILE_CONCURRENCY: 6 in flight, 2 queued - // Wave B (a filter change) supersedes every slot at CREATION — before A's - // queued workers ever reach tiles 6 & 7. - commit(yearInput(app.root), '2'); - await flush(); - expect(calls.filter((v) => v === '2')).toHaveLength(6); // B fans out its own 6 - // Drain everything; A's two queued workers dequeue AFTER B superseded them, - // see the stale generation, and discard WITHOUT issuing a year=1 request. - while (resolvers.length) { resolvers.splice(0).forEach((r) => r()); await flush(); } - await rendered; - expect(calls.filter((v) => v === '1')).toHaveLength(6); // A never issued the 2 queued - expect(calls.filter((v) => v === '2')).toHaveLength(8); // B issued all 8 - }); - - it('an affected wave is bounded to the same 6-way pool as full Refresh (req 7)', async () => { - const resolvers: (() => void)[] = []; - const executeRead = vi.fn((result: ExecuteReadResult, opts: ExecuteReadOpts) => - new Promise((res) => resolvers.push(() => { result.columns = [{ name: 'k', type: 'String' }]; result.rows = [['a']]; res(result); }))); - const app = makeApp({ exec: { executeRead } }); - setSaved(app, Array.from({ length: 8 }, (_, i) => paramFav(String(i), 't' + i))); - app.state.varValues = { year: '1' }; - const rendered = renderDashboard(app); - await flush(); - expect(resolvers).toHaveLength(6); // initial full refresh caps at 6 - while (resolvers.length) { resolvers.splice(0).forEach((r) => r()); await flush(); } - await rendered; - const before = executeRead.mock.calls.length; // 8 - commit(yearInput(app.root), '2'); - await flush(); - expect(executeRead.mock.calls.length - before).toBe(6); // the affected wave also caps at 6 concurrent - // …but every affected tile shows the loading placeholder up front (not just - // the 6 in flight) — no queued tile lingers on stale content while waiting. - expect(qsa(app.root, '.dash-tile-load')).toHaveLength(8); - while (resolvers.length) { resolvers.splice(0).forEach((r) => r()); await flush(); } - }); - - it('a stale (superseded) response neither renders nor records recents (req 6)', async () => { - const resolvers: ((out: Partial) => void)[] = []; - const executeRead = vi.fn((result: ExecuteReadResult, opts: ExecuteReadOpts) => new Promise((res) => resolvers.push((out) => { - Object.assign(result, out); - res(result); - }))); - const app = makeApp({ exec: { executeRead } }); - setSaved(app, [paramFav('1', 't')]); - app.state.varValues = { year: '1' }; - const rendered = renderDashboard(app); - await flush(); - // ≥2 rows so the tile renders a table (a 1-row unconfigured result is a KPI skip). - resolvers.splice(0).forEach((r) => r({ columns: [{ name: 'k', type: 'String' }], rows: [['a'], ['a2']] })); - await rendered; - app.params.recordBoundParams.mockClear(); - const input = yearInput(app.root); - commit(input, '11'); // wave A (superseded below) - await flush(); - commit(input, '22'); // wave B supersedes A - await flush(); - // B resolves first (current), then the stale A resolves late. - resolvers[1]({ columns: [{ name: 'k', type: 'String' }], rows: [['B'], ['B2']] }); - await flush(); - resolvers[0]({ columns: [{ name: 'k', type: 'String' }], rows: [['A-stale'], ['A2']] }); - await flush(); - expect(qs(app.root, '.dash-tile').textContent).toContain('B'); // B rendered - expect(qs(app.root, '.dash-tile').textContent).not.toContain('A-stale'); - expect(app.params.recordBoundParams).toHaveBeenCalledTimes(1); // only B recorded; the stale A did not - }); - - it('never touches workbench run state — tiles own their own results (req: isolation)', async () => { - const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], vi.fn(async () => chartResult())); - await renderDashboard(app); - expect(app.state.running.value).toBe(false); // dashboard tiles never flip the workbench run signal - expect(app.activeTab().result).toBeFalsy(); // no active-tab result written + app.state.isMobile.value = true; + await render(app); + for (const row of qsa(app.root, '.dash-row')) { + expect((row.style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(1'); + } + // Flip back to desktop — the effect republishes and restores 2 columns. + app.state.isMobile.value = false; + await Promise.resolve(); + expect((qsa(app.root, '.dash-row')[0].style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(2'); }); }); -// ── D3: global filter bar ──────────────────────────────────────────────────── -// ── #166: panel tiles — table/logs/text, partition before execution ───────── -describe('renderDashboard — panel tiles (#166, absorbs #164 D9)', () => { - const tableResult = (meta: TileOutcome['meta'] = { rows: 2, ms: 5, bytes: 100, truncated: false }): TileOutcome => ({ - columns: [{ name: 'a', type: 'String' }, { name: 'b', type: 'String' }], - rows: [['x', 'y2'], ['z', 'y1']], meta, - }); - const logsResult = (): TileOutcome => ({ - columns: [ - { name: 'event_time', type: 'DateTime' }, - { name: 'level', type: 'String' }, - { name: 'message', type: 'String' }, - ], - rows: [['2026-01-01 00:00:00', 'Error', 'boom'], ['2026-01-01 00:00:01', 'Info', 'ok']], - meta: { rows: 2, ms: 5, bytes: 100, truncated: false }, - }); - const emptyResult = (): TileOutcome => ({ - columns: [{ name: 'a', type: 'String' }], rows: [], - meta: { rows: 0, ms: 1, bytes: 10, truncated: false }, - }); - const oneFav = (runTile: TileSpy, over: Partial = {}) => - dashApp([{ id: '1', name: 'T', sql: 't', favorite: true, ...over }], runTile); - const firstCell = (root: ParentNode | null): HTMLElement => qs(root, '.res-table tbody tr .cell'); - - it('renders a non-chartable favorite as a grid table tile (not skipped), with footer stats', async () => { - const app = oneFav(vi.fn(async () => tableResult())); - await renderDashboard(app); - const tile = qs(app.root, '.dash-tile'); - expect(tile.style.display).not.toBe('none'); - expect(qs(tile, '.res-table-wrap')).not.toBeNull(); - expect(qs(tile, 'canvas')).toBeNull(); - expect(qs(app.root, '.dash-skip').style.display).toBe('none'); // a table tile is not a skip - expect(qs(app.root, '.dash-tile-foot').textContent).toContain('2 rows'); - }); - - it('an explicit table panel renders a plain grid even for a chartable favorite', async () => { - const app = oneFav(vi.fn(async () => chartResult()), { panel: { cfg: { type: 'table' } } }); - await renderDashboard(app); - expect(qs(app.root, '.dash-tile .res-table-wrap')).not.toBeNull(); - expect(qs(app.root, '.dash-tile canvas')).toBeNull(); - }); - - it('an explicit chart panel with a stale key still renders (rederived note, not a fallback)', async () => { - const app = oneFav(vi.fn(async () => chartResult()), - { panel: { cfg: { type: 'pie', x: 0, y: [1], series: null }, key: 'STALE' } }); - await renderDashboard(app); - expect(qs(app.root, '.dash-tile canvas')).not.toBeNull(); - expect(qs(app.root, '.panel-note').textContent).toContain('re-detected'); - }); - - it('applies complete saved stacked Area presentation through the shared Dashboard renderer', async () => { - const charts: FakeChart[] = []; - const app = oneFav(vi.fn(async () => chartResult()), { panel: { cfg: { - type: 'area', x: 0, y: [1], series: null, - style: { curve: 'smooth', points: 'hide', stack: 'stacked', - scale: 'zero', legend: 'show', grid: 'show', axes: 'hide' }, - } } }); - const Base = app.Chart; - app.Chart = class extends Base { constructor(...args: ConstructorParameters) { super(...args); charts.push(this); } }; - await renderDashboard(app); - expect(charts).toHaveLength(1); - expect(charts[0].config.data.datasets[0]).toMatchObject({ - tension: 0, stepped: false, cubicInterpolationMode: 'monotone', - pointRadius: 0, pointHoverRadius: 3, pointHitRadius: 8, fill: true, stack: 'chart', +describe('renderDashboard — reorder (drag only) + sort (#153/#280)', () => { + const twoTiles = () => wsWith({ + queries: [q('q1', 'SELECT k, v FROM a'), q('q2', 'SELECT k, v FROM b')], + tiles: [{ id: 't1', queryId: 'q1' }, { id: 't2', queryId: 'q2' }], + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + }); + const order = (app: TestApp): string[] => qsa(app.root, '.dash-tile .dash-tile-name').map((n) => n.textContent || ''); + + it('has no in-tile move / span / height chrome (owner override — drag only)', async () => { + const { app } = dashApp({ workspace: twoTiles() }); + await render(app); + expect(qsa(app.root, '.dash-tile-move').length).toBe(0); + expect(qsa(app.root, '.dash-tile-span').length).toBe(0); + expect(qsa(app.root, '.dash-tile-height').length).toBe(0); + }); + + it('pointer drag reorders tiles and persists the new dashboard.tiles[] order', async () => { + const { app, commit } = dashApp({ workspace: twoTiles() }); + await render(app); + expect(order(app)).toEqual(['q1', 'q2']); + const cards = qsa(app.root, '.dash-tile'); + cards[1].dispatchEvent(new Event('dragstart', { bubbles: true })); + cards[0].dispatchEvent(new Event('dragover', { bubbles: true })); + cards[0].dispatchEvent(new Event('drop', { bubbles: true })); + expect(order(app)).toEqual(['q2', 'q1']); // move-tile applied + expect(commit).toHaveBeenCalled(); // new order persisted + // A drop with no active drag is a harmless no-op. + qsa(app.root, '.dash-tile')[0].dispatchEvent(new Event('drop', { bubbles: true })); + expect(order(app)).toEqual(['q2', 'q1']); + }); + + it('a table header click re-sorts locally without re-querying', async () => { + const { app, calls } = dashApp({ + responder: () => ({ columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'String' }], rows: [['x', '1'], ['z', '2']] }), + workspace: wsWith({ queries: [q('q1', 'SELECT k, v FROM a', { panel: { cfg: { type: 'table' } } })], tiles: [{ id: 't1', queryId: 'q1' }] }), }); - expect(charts[0].config.options.plugins.legend.display).toBe(true); - expect(charts[0].config.options.scales!.y).toMatchObject({ - display: false, beginAtZero: true, grid: { display: true }, stacked: true, + await render(app); + const before = calls.length; + qsa(app.root, '.res-table th')[1].dispatchEvent(new Event('click', { bubbles: true })); + expect(calls.length).toBe(before); // local re-paint (rerender → paintForce), no re-query + expect(qs(app.root, '.res-table .h-sort')).not.toBeNull(); // sort applied locally + // A value-cell click is a harmless no-op (onCell). + qs(app.root, '.res-table tbody td.cell')?.dispatchEvent(new Event('click', { bubbles: true })); + expect(calls.length).toBe(before); + }); + + it('drives the shared rich fields: relative-time preview (wallNow) and Clear recent', async () => { + const { app } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a WHERE s = {s:String} AND d > {d:Date}')], + tiles: [{ id: 't1', queryId: 'q1' }], + }), }); - }); - - it('applies the same saved chart field metadata on Dashboard tiles', async () => { - const charts: FakeChart[] = []; - const app = oneFav(vi.fn(async () => chartResult()), { panel: { - cfg: { type: 'line', x: 0, y: [1], series: null }, - fieldConfig: { columns: { v: { displayName: 'Requests', unit: ' req', decimals: 0 } } }, - } }); - const Base = app.Chart; - app.Chart = class extends Base { constructor(...args: ConstructorParameters) { super(...args); charts.push(this); } }; - await renderDashboard(app); - expect(charts).toHaveLength(1); - expect(charts[0].config.data.datasets[0].label).toBe('Requests'); - expect(charts[0].config.options.plugins.tooltip.callbacks.label({ - datasetIndex: 0, dataset: charts[0].config.data.datasets[0], raw: 2, - })).toBe('Requests: 2 req'); - expect(qs(app.root, '.chart-config')).toBeNull(); - }); - - it('applies field metadata to an auto-derived Dashboard chart when panel.cfg is absent', async () => { - const charts: FakeChart[] = []; - const panel = { fieldConfig: { columns: { v: { displayName: 'Requests', unit: ' req', decimals: 0 } } } }; - const app = oneFav(vi.fn(async () => chartResult()), { panel }); - const Base = app.Chart; - app.Chart = class extends Base { constructor(...args: ConstructorParameters) { super(...args); charts.push(this); } }; - await renderDashboard(app); - expect(charts).toHaveLength(1); - expect(charts[0].config.data.datasets[0].label).toBe('Requests'); - expect(charts[0].config.options.plugins.tooltip.callbacks.label({ - datasetIndex: 0, dataset: charts[0].config.data.datasets[0], raw: 2, - })).toBe('Requests: 2 req'); - expect(app.state.savedQueries[0].spec.panel).toEqual(panel); - }); - - it('a header click sorts locally — no re-query — and a cell click is a harmless no-op', async () => { - const runTile = tile(async () => tableResult()); - const app = oneFav(runTile); - await renderDashboard(app); - expect(firstCell(app.root).textContent).toBe('x'); // query order (unsorted) - const thB = qsa(app.root, '.res-table th')[2]; // [0] is '#' - thB.dispatchEvent(new Event('click', { bubbles: true })); - expect(firstCell(app.root).textContent).toBe('z'); // ascending by b: y1 first - expect(qs(app.root, '.res-table .h-sort')).not.toBeNull(); - qsa(app.root, '.res-table th')[2] - .dispatchEvent(new Event('click', { bubbles: true })); // re-rendered th → desc - expect(firstCell(app.root).textContent).toBe('x'); - expect(() => firstCell(app.root).dispatchEvent(new Event('click', { bubbles: true }))).not.toThrow(); - expect(runTile).toHaveBeenCalledTimes(1); // sort + cell clicks never re-ran the query - }); - - it('sort survives a Refresh with the same schema; grid state resets when the schema changes', async () => { - const runTile = tile(async () => tableResult()); - const app = oneFav(runTile); - await renderDashboard(app); - qsa(app.root, '.res-table th')[2].dispatchEvent(new Event('click', { bubbles: true })); - expect(firstCell(app.root).textContent).toBe('z'); - await runOnclick(qs(app.root, '.dash-btn')); - expect(firstCell(app.root).textContent).toBe('z'); // sort kept across the re-run - expect(qs(app.root, '.res-table .h-sort')).not.toBeNull(); - runTile.mockImplementation(async () => ({ - columns: [{ name: 'c', type: 'String' }, { name: 'd', type: 'String' }], - rows: [['m', 'n'], ['o', 'p']], meta: { rows: 2, ms: 1, bytes: 10, truncated: false }, - })); - await runOnclick(qs(app.root, '.dash-btn')); - expect(qs(app.root, '.res-table .h-sort')).toBeNull(); // fresh sort state - }); - - it('a log-shaped favorite renders the logs view with per-level row classes', async () => { - const app = oneFav(vi.fn(async () => logsResult())); - await renderDashboard(app); - const logs = qs(app.root, '.dash-tile .dash-logs'); - expect(logs).not.toBeNull(); - expect(qs(app.root, '.res-table-wrap')).toBeNull(); // logs mode, not the grid - expect(qsa(logs, '.log-row')).toHaveLength(2); - expect(qs(logs, '.log-row.log-error .log-msg').textContent).toBe('boom'); - }); - - it('an explicit logs panel names roles by column name', async () => { - const app = oneFav(vi.fn(async () => ({ - columns: [{ name: 'ts', type: 'DateTime' }, { name: 'note', type: 'String' }], - rows: [['2026-01-01 00:00:00', 'hello']], - meta: { rows: 1, ms: 1, bytes: 10, truncated: false }, - })), { panel: { cfg: { type: 'logs', msg: 'note' } } }); - await renderDashboard(app); - expect(qs(app.root, '.dash-logs .log-msg').textContent).toBe('hello'); - }); - - it('a text favorite renders immediately with ZERO queries (partition before execution)', async () => { - const runTile = tile(async () => chartResult()); - const app = dashApp([ - { id: '1', name: 'Note', sql: '', favorite: true, panel: { cfg: { type: 'text', content: '# Team KPIs\n\nsee **docs**' } } }, - { id: '2', name: 'C', sql: 'chart', favorite: true }, - ], runTile); - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(1); // only the chart favorite ran SQL - expect(runTile).not.toHaveBeenCalledWith('', expect.anything()); - const md = qs(app.root, '.dash-tile .md-view'); - expect(qs(md, 'h1').textContent).toBe('Team KPIs'); - expect(qs(md, 'strong').textContent).toBe('docs'); - expect(qs(app.root, '.dash-skip').style.display).toBe('none'); // text is shown, not skipped - }); - - it('ignores attached text-panel SQL during filter analysis and targeted reruns', async () => { - const runTile = tile(async () => chartResult()); - const app = dashApp([ - { id: '1', name: 'Note', sql: 'SELECT {region:String}', favorite: true, - panel: { cfg: { type: 'text', content: 'static' } } }, - { id: '2', name: 'Chart', sql: 'SELECT {year:UInt16}', favorite: true }, - ], runTile); - app.state.varValues = { year: '2024', region: 'us' }; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(1); - expect(qs(app.root, '.dash-filters').textContent).not.toContain('region'); - const year = qs( - qsa(app.root, '.var-field').find((el) => (el.textContent || '').includes('year')) as HTMLElement, - 'input', - ); - year.value = '2025'; - year.dispatchEvent(new Event('input', { bubbles: true })); - year.dispatchEvent(new Event('change', { bubbles: true })); - await Promise.resolve(); - expect(runTile.mock.calls.every((call) => !String(call[0]).includes('region'))).toBe(true); - }); - - it('explicit zero-row panels stay visible with a "0 rows" state; unconfigured empties skip', async () => { - const runTile = tile(async () => emptyResult()); - const app = dashApp([ - { id: '1', name: 'E1', sql: 'a', favorite: true, panel: { cfg: { type: 'table' } } }, - { id: '2', name: 'E2', sql: 'b', favorite: true }, - ], runTile); - await renderDashboard(app); - const tiles = [...qsa(app.root, '.dash-tile')]; - expect(tiles[0].style.display).not.toBe('none'); - expect(qs(tiles[0], '.dash-tile-empty').textContent).toBe('0 rows'); - expect(tiles[1].style.display).toBe('none'); // unconfigured empty → still a skip - expect(qs(app.root, '.dash-skip').textContent).toBe('1 not shown'); - }); - - it('an unknown panel type (newer build) falls back via autoPanel with a diagnostic', async () => { - const app = oneFav(vi.fn(async () => chartResult()), { panel: { cfg: { type: 'gauge', max: 9 } } }); - await renderDashboard(app); - expect(qs(app.root, '.panel-note.is-fallback').textContent).toContain('gauge'); - expect(qs(app.root, '.dash-tile canvas')).not.toBeNull(); // fell back to the auto chart - }); - - it('an explicit single-row table panel remains a table instead of auto-selecting KPI', async () => { - const app = oneFav(vi.fn(async () => kpiResult()), { panel: { cfg: { type: 'table' } } }); - await renderDashboard(app); - expect(qs(app.root, '.dash-tile').style.display).not.toBe('none'); - expect(qsa(app.root, '.res-table tbody tr')).toHaveLength(1); - }); - - it('a tile that flips table → KPI on Refresh clears its old grid DOM', async () => { - const runTile = tile(async () => tableResult()); - const app = oneFav(runTile); - await renderDashboard(app); - expect(qs(app.root, '.res-table-wrap')).not.toBeNull(); - runTile.mockImplementation(async () => kpiResult()); - await runOnclick(qs(app.root, '.dash-btn')); - expect(qs(app.root, '.dash-tile').style.display).toBe(''); - expect(qs(app.root, '.res-table-wrap')).toBeNull(); // stale grid DOM cleared, not just hidden - expect(qs(app.root, '.kpi-card')).not.toBeNull(); - expect(qs(app.root, '.dash-tile').classList.contains('is-kpi')).toBe(true); - }); - - it('grid/logs tiles cap displayed rows at DASH_TABLE_DISPLAY_CAP with the in-body footer', async () => { - const rows = Array.from({ length: DASH_TABLE_DISPLAY_CAP + 5 }, (_, i) => [String(i), 'y']); - const app = oneFav(vi.fn(async () => ({ - columns: [{ name: 'a', type: 'String' }, { name: 'b', type: 'String' }], - rows, meta: { rows: rows.length, ms: 1, bytes: 10, truncated: false }, - }))); - await renderDashboard(app); - expect(qsa(app.root, '.res-table tbody tr')).toHaveLength(DASH_TABLE_DISPLAY_CAP); - expect(rootEl(app).textContent).toContain('+ 5 more rows truncated for display'); + app.state.varRecent = recordRecent(emptyRecentMap(), 's', 'foo'); + await render(app); + const fieldFor = (name: string) => qsa(app.root, '.dash-filter-host .var-field') + .find((f) => qs(f, '.var-name')?.textContent === name)!; + // Type a relative value into the Date field so its preview reads the shim wallNow. + const dInput = qs(fieldFor('d'), 'input'); + dInput.dispatchEvent(new Event('focus')); + dInput.value = 'now-1h'; + dInput.dispatchEvent(new Event('input', { bubbles: true })); + // Focus the recents (String) field and Clear recent → shim clearVarRecent. + const sField = fieldFor('s'); + qs(sField, 'input').dispatchEvent(new Event('focus')); + qs(sField, '.var-combo-footer button')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + expect(app.params.clearVarRecent).toHaveBeenCalledWith('s'); }); }); -describe('renderDashboard — global filter bar (#149 D3)', () => { - const paramFav = (id: string, sql: string): FavoriteInput => ({ id, name: id, sql, favorite: true }); - const setInput = (el: HTMLInputElement, value: string): void => { - el.value = value; - el.dispatchEvent(new Event('input', { bubbles: true })); - }; - const pressEnter = (el: HTMLElement): boolean => el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - // A macrotask tick — flushes every pending microtask (including chained - // awaits across runSlotTile/runPool), unlike a single `await Promise.resolve()`. - const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - const fieldInput = (root: ParentNode | null, name: string): HTMLInputElement => - qs(root, '.var-field input[aria-label="' + name + '"]'); - - it('shows no filter row when no favorite has a {name:Type} param', async () => { - const app = dashApp([{ id: '1', name: 'Q', sql: 'SELECT 1', favorite: true }], vi.fn(async () => chartResult())); - await renderDashboard(app); - const filters = qs(app.root, '.dash-filters'); - expect(filters.style.display).toBe('none'); - expect(qsa(filters, '.var-field').length).toBe(0); - expect(qs(app.root, '.dash-toolbar').classList.contains('has-filters')).toBe(false); - }); - - it('a param declared with conflicting types across two favorites renders a plain input with a visible warning (#173 acceptance, review F1)', async () => { - const favorites = [ - paramFav('1', 'SELECT * FROM t WHERE i = {id:UInt64}'), - paramFav('2', 'SELECT * FROM u WHERE i = {id:String}'), - ]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - app.state.varValues = { id: '7' }; - await renderDashboard(app); - const input = fieldInput(app.root, 'id'); - expect(input.classList.contains('is-conflict')).toBe(true); // visible warning, distinct from is-invalid - expect(input.title).toContain('Conflicting type declarations: UInt64 vs String'); - }); - - it('a conflicted Enum-declared filter degrades to a plain input — the member dropdown is disabled (review F1)', async () => { - const favorites = [ - paramFav('1', "SELECT * FROM t WHERE s = {s:Enum8('a' = 1, 'b' = 2)}"), - paramFav('2', 'SELECT * FROM u WHERE s = {s:String}'), - ]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - await renderDashboard(app); - const input = fieldInput(app.root, 's'); - input.dispatchEvent(new Event('focus', { bubbles: true })); - expect(qsa(app.root, '[role="option"]')).toHaveLength(0); // no member dropdown - expect(input.classList.contains('is-conflict')).toBe(true); - // A non-conflicted enum filter keeps its dropdown (control degradation is per-field). - const app2 = dashApp([paramFav('1', "SELECT * FROM t WHERE s = {s:Enum8('a' = 1, 'b' = 2)}")], vi.fn(async () => chartResult())); - await renderDashboard(app2); - const input2 = fieldInput(app2.root, 's'); - input2.dispatchEvent(new Event('focus', { bubbles: true })); - expect([...qsa(app2.root, '[role="option"]')].map((o) => o.textContent)).toEqual(['a', 'b']); - }); - - it('renders one field per param detected across favorites, first-appearance order', async () => { - const favorites = [ - paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}'), - paramFav('2', 'SELECT * FROM u WHERE r = {region:String}'), - ]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - app.state.varValues = { year: '2024', region: 'us' }; - await renderDashboard(app); - const filters = qs(app.root, '.dash-filters'); - expect(filters.style.display).not.toBe('none'); - expect(qs(app.root, '.dash-toolbar').classList.contains('has-filters')).toBe(true); - expect([...qsa(filters, '.var-name')].map((n) => n.textContent)).toEqual(['year', 'region']); - expect(fieldInput(app.root, 'year').value).toBe('2024'); - }); - - it('typing debounces before the affected tile(s) re-run; an unaffected tile is untouched', async () => { - vi.useFakeTimers(); - try { - const favorites = [ - paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}'), - paramFav('2', 'SELECT * FROM u WHERE y = {year:UInt16}'), - paramFav('3', 'SELECT * FROM v WHERE r = {region:String}'), - ]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '2023', region: 'us' }; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(3); - - setInput(fieldInput(app.root, 'year'), '2024'); - expect(runTile).toHaveBeenCalledTimes(3); // debounced — no re-run yet - await vi.advanceTimersByTimeAsync(499); - expect(runTile).toHaveBeenCalledTimes(3); - await vi.advanceTimersByTimeAsync(1); - expect(runTile).toHaveBeenCalledTimes(5); // only the 2 'year' tiles re-ran - expect(runTile.mock.calls.filter((c) => c[0] === favorites[2].sql)).toHaveLength(1); // region tile untouched - expect(app.state.varValues.year).toBe('2024'); // shared with the workbench's varValues - expect(app.params.saveVarValues).toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it('Enter fires the re-run immediately, bypassing the debounce', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '2023' }; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(1); - const input = fieldInput(app.root, 'year'); - setInput(input, '2024'); - pressEnter(input); - await flush(); - expect(runTile).toHaveBeenCalledTimes(2); - }); - - it('Enter/blur with no pending edit is a no-op (nothing to commit)', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '2023' }; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(1); - const input = fieldInput(app.root, 'year'); - pressEnter(input); // no prior 'input' event — no pending debounce to fire - input.dispatchEvent(new Event('blur', { bubbles: true })); - await flush(); - expect(runTile).toHaveBeenCalledTimes(1); - }); - - it('editing a filter before the dashboard has ever run a tile is a no-op', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '2023' }; - app.conn.ensureFreshToken = vi.fn(async () => false); // session can't be refreshed — no slots built - await renderDashboard(app); - expect(runTile).not.toHaveBeenCalled(); - const input = fieldInput(app.root, 'year'); - setInput(input, '2024'); - pressEnter(input); - await flush(); - expect(runTile).not.toHaveBeenCalled(); // still a no-op — nothing to update - }); - - it('blur fires the re-run immediately, bypassing the debounce', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '2023' }; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(1); - const input = fieldInput(app.root, 'year'); - setInput(input, '2024'); - input.dispatchEvent(new Event('blur', { bubbles: true })); - await flush(); - expect(runTile).toHaveBeenCalledTimes(2); - }); - - it('a tile with an unfilled param shows a placeholder and never calls runTile; filling it runs the tile', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); // no varValues set — 'year' unfilled - await renderDashboard(app); - expect(runTile).not.toHaveBeenCalled(); - const placeholder = qs(app.root, '.dash-tile-unfilled'); - expect(placeholder.textContent).toBe('Enter a value for: year'); - // An unfilled tile is not counted in the "N not shown" note. - expect(qs(app.root, '.dash-skip').style.display).toBe('none'); - - const input = fieldInput(app.root, 'year'); - setInput(input, '2024'); - pressEnter(input); - await flush(); - expect(runTile).toHaveBeenCalledTimes(1); - expect(qs(app.root, '.dash-tile canvas')).not.toBeNull(); - expect(qs(app.root, '.dash-tile-unfilled')).toBeNull(); - }); - - it('per-source gating (#173): a value that cannot serialize errors only its own tile', async () => { - const favorites = [ - paramFav('1', 'SELECT * FROM t WHERE db = {db:String}'), - { id: '2', name: 'Good', sql: 'SELECT k, v FROM good', favorite: true }, - ]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - const badValues: Record = { db: ['not', 'scalar'] }; - app.state.varValues = badValues as Record; // array value, scalar declaration → structural - await renderDashboard(app); - // the broken tile never fetched, the sibling did — one bad source blocks nothing else - expect(runTile).toHaveBeenCalledTimes(1); - expect(runTile.mock.calls[0][0]).toBe('SELECT k, v FROM good'); - expect(qs(app.root, '.dash-tile-error').textContent).toContain('array value'); - expect(qs(app.root, '.dash-tile canvas')).not.toBeNull(); - }); - - it('tiles fetch with the wave\'s prepared args (#173), not by re-deriving per tile', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '2024' }; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledWith('SELECT * FROM t WHERE y = {year:UInt16}', { param_year: '2024' }); - }); - - it('discards a stale response when a newer edit\'s response arrives first (last edit wins)', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; - const resolvers: ((v: TileOutcome) => void)[] = []; - const runTile = tile(() => new Promise((resolve) => resolvers.push(resolve))); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '2023' }; - const rendered = renderDashboard(app); - await flush(); - expect(resolvers).toHaveLength(1); - resolvers[0](chartResult()); - await rendered; - - // Distinct but still UInt16-valid values (#170: an invalid value would - // never reach the seam at all, short-circuiting this race). - const input = fieldInput(app.root, 'year'); - setInput(input, '11'); - pressEnter(input); - await flush(); - expect(resolvers).toHaveLength(2); - setInput(input, '22'); - pressEnter(input); - await flush(); - expect(resolvers).toHaveLength(3); - - // The newer edit ('B') resolves first; the superseded ('A') resolves after. - resolvers[2]({ error: 'B wins' }); - await flush(); - resolvers[1]({ error: 'A is stale — must be discarded' }); - await flush(); - - expect(qs(app.root, '.dash-tile-error').textContent).toBe('B wins'); - }); - - // ── #170: typed client-side validation ────────────────────────────────────── - it('#170: an invalid value shows the inline error and gates the tile like an unfilled one', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '2023' }; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(1); - const input = fieldInput(app.root, 'year'); - setInput(input, 'abc'); - pressEnter(input); - await flush(); - expect(runTile).toHaveBeenCalledTimes(1); // never re-fetched with the bad value - expect(input.classList.contains('is-invalid')).toBe(true); - expect(qs(app.root, '.dash-tile-unfilled').textContent).toBe('Enter a value for: year'); - }); - it("#170: a plausible mid-typing prefix stays neutral while typing, hardens on blur", () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:Int32}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '5' }; - return renderDashboard(app).then(async () => { - const input = fieldInput(app.root, 'year'); - setInput(input, '-'); - expect(input.classList.contains('is-invalid')).toBe(false); // neutral — could still become '-5' - input.dispatchEvent(new Event('blur', { bubbles: true })); - await flush(); - expect(input.classList.contains('is-invalid')).toBe(true); // blur hardens it +describe('renderDashboard — KPI bands (#240)', () => { + it('groups consecutive KPI tiles into one full-width band of cards', async () => { + const { app } = dashApp({ + responder: () => ({ columns: [{ name: 'value', type: 'UInt64' }], rows: [[42]] }), + workspace: wsWith({ + queries: [ + q('k1', 'SELECT 1 AS value', { panel: { cfg: { type: 'kpi' } } }), + q('k2', 'SELECT 2 AS value', { panel: { cfg: { type: 'kpi' } } }), + ], + tiles: [{ id: 't1', queryId: 'k1' }, { id: 't2', queryId: 'k2' }], + }), }); - }); - it('#170: correcting an invalid value clears the affordance and re-runs the tile', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '2023' }; - await renderDashboard(app); - const input = fieldInput(app.root, 'year'); - setInput(input, 'abc'); - pressEnter(input); - await flush(); - expect(qs(app.root, '.dash-tile-unfilled')).not.toBeNull(); - setInput(input, '2025'); - pressEnter(input); - await flush(); - expect(input.classList.contains('is-invalid')).toBe(false); - expect(qs(app.root, '.dash-tile-unfilled')).toBeNull(); - expect(runTile).toHaveBeenCalledTimes(2); // initial + the corrected re-run - }); - - // ── #165: optional blocks on the dashboard ──────────────────────────────── - const optFav = (id: string): FavoriteInput => paramFav(id, 'SELECT * FROM t WHERE 1 /*[ AND d = {d:String} ]*/'); - - it('#165: a block-only param is listed in the filter bar with the optional affordance', async () => { - const app = dashApp([optFav('1')], vi.fn(async () => chartResult())); - await renderDashboard(app); - const field = qs(app.root, '.dash-filters .var-field'); - expect(field.classList.contains('is-optional')).toBe(true); - expect(qs(field, '.var-name').textContent).toBe('d'); - expect(fieldInput(app.root, 'd').title).toContain('optional'); - }); - - it('#165: a blank optional filter deactivates the predicate instead of blocking — the tile runs materialized', async () => { - const runTile = tile(async () => chartResult()); - const app = dashApp([optFav('1')], runTile); // no value, no activation - await renderDashboard(app); - expect(qs(app.root, '.dash-tile-unfilled')).toBeNull(); // NOT gated - expect(runTile).toHaveBeenCalledTimes(1); - const [sql, args] = runTile.mock.calls[0]; - expect(sql).toBe('SELECT * FROM t WHERE 1 '); // block omitted from the wire text - expect(args).toEqual({}); // param_d never sent - }); - - it('#165: typing a value activates the block — the affected tile re-runs with the predicate + arg', async () => { - const runTile = tile(async () => chartResult()); - const app = dashApp([optFav('1')], runTile); - await renderDashboard(app); - const input = fieldInput(app.root, 'd'); - setInput(input, 'abc'); - expect(app.state.filterActive.d).toBe(true); // text control syncs activation - expect(app.params.saveFilterActive).toHaveBeenCalled(); - pressEnter(input); - await flush(); - expect(runTile).toHaveBeenCalledTimes(2); - const [sql, args] = runTile.mock.calls[1]; - expect(sql).toBe('SELECT * FROM t WHERE 1 AND d = {d:String} '); - expect(args).toEqual({ param_d: 'abc' }); - // …and blanking it flips activation off and re-runs without the predicate. - setInput(input, ''); - expect(app.state.filterActive.d).toBe(false); - pressEnter(input); - await flush(); - expect(runTile).toHaveBeenCalledTimes(3); - expect(runTile.mock.calls[2]).toEqual(['SELECT * FROM t WHERE 1 ', {}]); - }); - - it('#165: a required (non-block) param still blocks the tile with the placeholder', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {y:UInt16} /*[ AND d = {d:String} ]*/')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - await renderDashboard(app); - expect(runTile).not.toHaveBeenCalled(); - expect(qs(app.root, '.dash-tile-unfilled').textContent).toBe('Enter a value for: y'); - // the required field carries no optional affordance - expect(fieldInput(app.root, 'y').closest('.var-field')!.classList.contains('is-optional')).toBe(false); - }); - - it('#165: a stale persisted value with activation off keeps the block omitted', async () => { - const runTile = tile(async () => chartResult()); - const app = dashApp([optFav('1')], runTile); - app.state.varValues = { d: 'stale' }; - app.state.filterActive = { d: false }; - await renderDashboard(app); - expect(runTile.mock.calls[0]).toEqual(['SELECT * FROM t WHERE 1 ', {}]); - }); - - it('#165: a block-free favorite keeps its exact bytes on the wire', async () => { - const runTile = tile(async () => chartResult()); - const sql = 'SELECT * FROM t WHERE y = {year:UInt16};'; // trailing ; kept verbatim - const app = dashApp([paramFav('1', sql)], runTile); - app.state.varValues = { year: '2024' }; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledWith(sql, { param_year: '2024' }); + await render(app); + expect(qs(app.root, '.dash-kpi-band')).not.toBeNull(); + expect(qsa(app.root, '.dash-kpi-stream .kpi-card').length).toBe(2); + }); + + it('shows a KPI member state card for an errored or unfilled KPI source', async () => { + const { app } = dashApp({ + responder: (sql) => (sql.includes('boom') ? { error: 'kpi down' } : { columns: [{ name: 'value', type: 'UInt64' }], rows: [[1]] }), + workspace: wsWith({ + queries: [ + q('k1', 'SELECT boom AS value', { panel: { cfg: { type: 'kpi' } } }), + q('k2', 'SELECT {p:String} AS value', { panel: { cfg: { type: 'kpi' } } }), + ], + tiles: [{ id: 't1', queryId: 'k1' }, { id: 't2', queryId: 'k2' }], + }), + }); + await render(app); + const cards = qsa(app.root, '.dash-kpi-state-card').map((c) => c.textContent); + expect(cards).toContain('kpi down'); + expect(cards.some((c) => /Enter a value/.test(c || ''))).toBe(true); }); - describe('#169 relative time', () => { - it('a date-like param gets the preset+preview combobox; a non-date param gets the #171 recents-only combobox (no preview)', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE d >= {from:DateTime} AND r = {region:String}')]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - app.state.varValues = { from: '-1h', region: 'us' }; - await renderDashboard(app); - const fromInput = fieldInput(app.root, 'from'); - const regionInput = fieldInput(app.root, 'region'); - expect(fromInput.getAttribute('role')).toBe('combobox'); - expect(regionInput.getAttribute('role')).toBe('combobox'); // #171: every field is a combobox now - expect(qs(fromInput.closest('.var-field') as HTMLElement, '.var-combo-preview')).not.toBeNull(); - expect(qs(regionInput.closest('.var-field') as HTMLElement, '.var-combo-preview')).toBeNull(); - }); - it('picking a preset inserts the expression, persists it, and commits IMMEDIATELY — bypassing the debounce', async () => { - vi.useFakeTimers(); - try { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE d >= {from:DateTime}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { from: 'now' }; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(1); - const input = fieldInput(app.root, 'from'); - input.dispatchEvent(new Event('focus', { bubbles: true })); - // The field already holds 'now' (the current value), so opening on - // focus filters to presets matching it — the first match is 'now/d'. - const opt = qs(app.root, '[role="option"]'); - opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); - expect(input.value).toBe('now/d'); - expect(app.state.varValues.from).toBe('now/d'); - await vi.advanceTimersByTimeAsync(0); // let the immediate commit's microtasks settle — no 500ms wait needed - expect(runTile).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } - }); - it('an invalid (near-miss) expression shows the tile placeholder and never calls runTile', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE d >= {from:DateTime}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { from: 'now' }; - await renderDashboard(app); - expect(runTile).toHaveBeenCalledTimes(1); - const input = fieldInput(app.root, 'from'); - setInput(input, 'now/q'); - input.dispatchEvent(new Event('blur', { bubbles: true })); - await flush(); - expect(runTile).toHaveBeenCalledTimes(1); // no new run — the invalid value never bound - expect(qs(app.root, '.dash-tile-unfilled')).not.toBeNull(); - expect(input.classList.contains('is-invalid')).toBe(true); - }); - it('Enter with the list closed hardens/gates via the same keydown path as a plain filter field', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE d >= {from:DateTime}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { from: 'now' }; - await renderDashboard(app); - const input = fieldInput(app.root, 'from'); - setInput(input, 'now/q'); - pressEnter(input); - await flush(); - expect(input.classList.contains('is-invalid')).toBe(true); - expect(runTile).toHaveBeenCalledTimes(1); // never re-ran with the invalid value - }); - it('Enter with an active preset option commits it via keydown instead of hardening the prior text', async () => { - vi.useFakeTimers(); - try { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE d >= {from:DateTime}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { from: 'now' }; - await renderDashboard(app); - const input = fieldInput(app.root, 'from'); - // app.root isn't attached to `document` in this test harness, so a - // real input.focus() can't land — dispatch the synthetic event - // combo's own 'focus' listener reacts to, same as this file's other - // combobox tests. - input.dispatchEvent(new Event('focus', { bubbles: true })); - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); - expect(input.value).toBe('now/d'); // filtered to 'now'-matching presets; first match - expect(input.getAttribute('aria-expanded')).toBe('false'); - await vi.advanceTimersByTimeAsync(0); - expect(runTile).toHaveBeenCalledTimes(2); // committed immediately, bypassing the debounce - } finally { - vi.useRealTimers(); - } - }); - it('a Refresh resolves one wallNow across every tile; a later Refresh with an advanced clock moves the window', async () => { - const favorites = [ - paramFav('1', 'SELECT * FROM t WHERE d >= {from:DateTime}'), - paramFav('2', 'SELECT * FROM u WHERE d >= {from:DateTime}'), - ]; - const runTile = tile(async () => chartResult()); - // `wallNow` pre-declared (not inlined): an inline `vi.fn()` sibling to - // another generic call (`streamInto(...)`) in the SAME `makeApp(...)` - // argument defeats TS's generic inference for `makeApp`'s own - // `overrides` type parameter (a doubly-generic — vi.fn()'s own T, - // contextually dependent on O's unification — inference collapse); - // resolving `vi.fn()`'s type first, standalone, sidesteps it. - const wallNow = vi.fn(() => 1751200000000); - const app = makeApp({ exec: { executeRead: streamInto(runTile) }, wallNow }); - setSaved(app, favorites); - app.state.varValues = { from: '-1h' }; - await renderDashboard(app); - const expected1 = String(Math.round((1751200000000 - 3600000) / 1000)); - expect(runTile.mock.calls[0][1]).toEqual({ param_from: expected1 }); - expect(runTile.mock.calls[1][1]).toEqual({ param_from: expected1 }); // same instant, both tiles - app.wallNow = vi.fn(() => 1751200000000 + 3600000); // advance the clock, then Refresh - const refreshBtn = qs(app.root, '.dash-btn'); - refreshBtn.click(); - await flush(); - const expected2 = String(Math.round(1751200000000 / 1000)); - expect(runTile.mock.calls[2][1]).toEqual({ param_from: expected2 }); - expect(runTile.mock.calls[3][1]).toEqual({ param_from: expected2 }); - }); - it('the stored expression persists and restores — not the resolved value', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE d >= {from:DateTime}')]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - app.state.varValues = { from: '-1h' }; - await renderDashboard(app); - expect(fieldInput(app.root, 'from').value).toBe('-1h'); - expect(app.params.saveVarValues).not.toHaveBeenCalled(); // nothing edited yet — just restored + it('shows the KPI zero-data state card when a KPI source returns no rows', async () => { + const { app } = dashApp({ + responder: () => ({ columns: [{ name: 'value', type: 'UInt64' }], rows: [] }), + workspace: wsWith({ queries: [q('k1', 'SELECT value', { panel: { cfg: { type: 'kpi' } } })], tiles: [{ id: 't1', queryId: 'k1' }] }), }); + await render(app); + expect(qs(app.root, '.dash-kpi-state-card')).not.toBeNull(); }); +}); - // #172 v1 — the Dashboard only ever gets the declared-type dropdown (the - // declaration travels with the tile SQL); v2's schema-cache inference is - // workbench-only (no schema cache here). - describe('#172 enum variables (v1, from the tile SQL declaration)', () => { - const ENUM_TYPE = "Enum8('active' = 1, 'deleted' = 2, 'banned' = 3)"; - it('renders a dropdown of the declared members', async () => { - const favorites = [paramFav('1', `SELECT * FROM t WHERE status = {status:${ENUM_TYPE}}`)]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - await renderDashboard(app); - const input = fieldInput(app.root, 'status'); - input.dispatchEvent(new Event('focus', { bubbles: true })); - const opts = [...qsa(app.root, '[role="option"]')].map((o) => o.textContent); - expect(opts).toEqual(['active', 'deleted', 'banned']); +describe('renderDashboard — shared rich filter bar over the viewer (#188)', () => { + it('renders the shared rich field family — one var-field per declared param type', async () => { + const { app } = dashApp({ + workspace: wsWith({ + queries: [q('q1', "SELECT k, v FROM a WHERE s = {s:String} AND e = {e:Enum('a','b')} AND d > {d:Date}")], + tiles: [{ id: 't1', queryId: 'q1' }], + }), }); - it('gates a non-member value inline (blocking, since the declared type is a real Enum)', async () => { - const favorites = [paramFav('1', `SELECT * FROM t WHERE status = {status:${ENUM_TYPE}}`)]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - await renderDashboard(app); - const input = fieldInput(app.root, 'status'); - setInput(input, 'nope'); - input.dispatchEvent(new Event('blur', { bubbles: true })); - await flush(); - expect(input.classList.contains('is-invalid')).toBe(true); - expect(qs(app.root, '.dash-tile-unfilled')).not.toBeNull(); + await render(app); + const names = qsa(app.root, '.dash-filter-host .var-field .var-name').map((n) => n.textContent); + expect(names).toEqual(expect.arrayContaining(['s', 'e', 'd'])); + // Every field is a combobox-backed input (the shared rich field builders — + // recents / enum / relative-time), not the old bare text/select swap. + expect(qsa(app.root, '.dash-filter-host .var-field input').length).toBeGreaterThanOrEqual(3); + }); + + it('commits a curated (source-backed) selection through the viewer in one affected-panel wave', async () => { + const { app, calls } = dashApp({ + responder: (sql) => (sql.includes('opts') + ? { columns: [{ name: 'p', type: 'Array(String)' }], rows: [[['x', 'y']]] } + : { columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], rows: [['a', 1]] }), + workspace: wsWith({ + queries: [ + q('q1', 'SELECT k, v FROM a WHERE x = {p:String}'), + q('src', "SELECT ['x','y'] AS p -- opts", { dashboard: { role: 'filter' } }), + ], + tiles: [{ id: 't1', queryId: 'q1' }], + filters: [{ id: 'f1', parameter: 'p', sourceQueryId: 'src' }], + }), }); - it('a bare numeric code matching a declared code is accepted (live-server fact)', async () => { - const favorites = [paramFav('1', `SELECT * FROM t WHERE status = {status:${ENUM_TYPE}}`)]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - await renderDashboard(app); - const input = fieldInput(app.root, 'status'); - setInput(input, '2'); - pressEnter(input); - await flush(); - expect(input.classList.contains('is-invalid')).toBe(false); - expect(runTile).toHaveBeenLastCalledWith(expect.any(String), { param_status: '2' }); + await render(app); + // The source query's options upgraded the field to the curated combobox. + const field = qs(app.root, '.dash-filter-host .var-field.is-curated'); + expect(field).not.toBeNull(); + const before = calls.length; + qs(field, 'input').dispatchEvent(new Event('focus')); + qs(field, '[role="option"]')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + await Promise.resolve(); await Promise.resolve(); + const added = calls.slice(before).filter((c) => 'param_p' in c.params); + expect(added.length).toBe(1); // one affected-panel wave, tile re-run with the picked value + expect(added[0].params.param_p).toBe('x'); + }); + + it('shows the N-active count and clear-all when a filter is active; both hidden otherwise', async () => { + const { app } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a WHERE n = {n:UInt8}')], + tiles: [{ id: 't1', queryId: 'q1' }], + filters: [{ id: 'n', parameter: 'n', defaultValue: 5, defaultActive: true }], + }), }); - it('a non-enum String param keeps the plain recents combobox (no v2 here — no schema cache on the Dashboard)', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE r = {region:String}')]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - await renderDashboard(app); - const input = fieldInput(app.root, 'region'); - input.dispatchEvent(new Event('focus', { bubbles: true })); - expect(qsa(app.root, '[role="option"]')).toHaveLength(0); // no recents recorded, no enum values - }); - }); -}); - -// ── D3 + #171: recent-value recording + the recents dropdown ──────────────── -describe('renderDashboard — recent values (#171)', () => { - const paramFav = (id: string, sql: string): FavoriteInput => ({ id, name: id, sql, favorite: true }); - const fieldInput = (root: ParentNode | null, name: string): HTMLInputElement => - qs(root, '.var-field input[aria-label="' + name + '"]'); - - it('records the wave\'s boundParams on a successful tile completion', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '2024' }; - await renderDashboard(app); - expect(app.params.recordBoundParams).toHaveBeenCalledTimes(1); - expect(app.params.recordBoundParams.mock.calls[0][0]).toEqual([ - expect.objectContaining({ name: 'year', rawValue: '2024' }), - ]); - }); - - it('never records on a failed tile', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; - const runTile = tile(async () => ({ error: 'boom' })); - const app = dashApp(favorites, runTile); - app.state.varValues = { year: '2024' }; - await renderDashboard(app); - expect(app.params.recordBoundParams).not.toHaveBeenCalled(); - }); - - it('an omitted-optional-block param is never in the recorded boundParams', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE 1 /*[ AND d = {d:String} ]*/')]; - const runTile = tile(async () => chartResult()); - const app = dashApp(favorites, runTile); - await renderDashboard(app); // d blank → block inactive → not bound at all - expect(app.params.recordBoundParams).toHaveBeenCalledTimes(1); - expect(app.params.recordBoundParams.mock.calls[0][0]).toEqual([]); - }); - - it('a non-date field shows recorded recents on focus, newest-first, filtered as you type', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE r = {region:String}')]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - let map = emptyRecentMap(); - map = recordRecent(map, 'region', 'us'); - map = recordRecent(map, 'region', 'eu'); - app.state.varRecent = map; - await renderDashboard(app); - const input = fieldInput(app.root, 'region'); - input.dispatchEvent(new Event('focus', { bubbles: true })); - expect([...qsa(app.root, '[role="option"]')].map((o) => o.textContent)).toEqual(['eu', 'us']); - input.value = 'us'; - input.dispatchEvent(new Event('input', { bubbles: true })); - expect([...qsa(app.root, '[role="option"]')].map((o) => o.textContent)).toEqual(['us']); - }); - - it('clicking a recent inserts it; "Clear recent" calls app.params.clearVarRecent(name)', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE r = {region:String}')]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - app.state.varRecent = recordRecent(emptyRecentMap(), 'region', 'us'); - await renderDashboard(app); - const input = fieldInput(app.root, 'region'); - input.dispatchEvent(new Event('focus', { bubbles: true })); - const opt = qs(app.root, '[role="option"]'); - opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); - expect(input.value).toBe('us'); - input.dispatchEvent(new Event('focus', { bubbles: true })); - const clearBtn = qs(app.root, 'button.var-combo-clear'); - clearBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); - expect(app.params.clearVarRecent).toHaveBeenCalledWith('region'); + await render(app); + expect(qs(app.root, '.dash-filter-count')?.textContent).toBe('1 active'); + const clearAll = qs(app.root, '.dash-filter-clear-all'); + expect(clearAll.style.display).toBe(''); + // Clear-all resets to defaults (one coalesced wave; a no-op here since the + // filter is already at its default) — exercises the toolbar action. + clearAll.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + expect(qs(app.root, '.dash-filter-count')?.textContent).toBe('1 active'); }); - it('a date-like field composes ONE dropdown: Recent first, then Presets (user decision, phase-7 feedback)', async () => { - const favorites = [paramFav('1', 'SELECT * FROM t WHERE d >= {from:DateTime}')]; - const app = dashApp(favorites, vi.fn(async () => chartResult())); - app.state.varRecent = recordRecent(emptyRecentMap(), 'from', '-3h'); // not a built-in preset - await renderDashboard(app); - const input = fieldInput(app.root, 'from'); - input.dispatchEvent(new Event('focus', { bubbles: true })); - const groups = [...qsa(app.root, '.combo-group')].map((g) => g.textContent); - expect(groups).toEqual(['Recent', 'Presets']); - expect([...qsa(app.root, '[role="option"]')].map((o) => o.textContent)).toContain('-3h'); + it('renders no per-filter "required/invalid" badge (owner decision — dropped as noise)', async () => { + const { app } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a WHERE x = {p:String}')], + tiles: [{ id: 't1', queryId: 'q1' }], + }), + }); + await render(app); + expect(qs(app.root, '.dash-filter-blocking')).toBeNull(); + expect((qs(app.root, '.dash-filter-count') as HTMLElement).style.display).toBe('none'); + expect((qs(app.root, '.dash-filter-clear-all') as HTMLElement).style.display).toBe('none'); }); }); -// ── app.js: dashboard render + auth handoff wiring ─────────────────────────── function jwt(payload: Record): string { // btoa/atob (not node:crypto's Buffer — no @types/node in this project) — // the same base64url shape core/jwt.js's decodeJwtPayload expects. @@ -2047,6 +746,44 @@ describe('app config base on the dashboard route', () => { }); }); +// A minimal in-memory fake IDBFactory (mirrors indexeddb-workspace-store.test's) +// — enough for the migration + loadCurrent round-trip through the real store. +function fakeIndexedDb(): IDBFactory { + const stores = new Map>(); + const req = (result?: unknown): Record => + ({ result, error: null, onsuccess: null, onerror: null, onupgradeneeded: null }); + const objectStore = (name: string) => ({ + get: (key: string) => { const r = req(); queueMicrotask(() => { (r as { result: unknown }).result = stores.get(name)!.get(key); (r.onsuccess as (() => void) | null)?.(); }); return r; }, + put: (value: unknown, key: string) => { stores.get(name)!.set(key, value); return req(); }, + delete: (key: string) => { stores.get(name)!.delete(key); return req(); }, + }); + return { + open() { + const db = { + objectStoreNames: { contains: (n: string) => stores.has(n) }, + createObjectStore: (n: string) => { stores.set(n, new Map()); }, + transaction: () => { const tx: Record = { error: null, oncomplete: null, onerror: null, onabort: null, objectStore }; queueMicrotask(() => (tx.oncomplete as (() => void) | null)?.()); return tx; }, + }; + const r = req(db); + queueMicrotask(() => { (r.onupgradeneeded as (() => void) | null)?.(); (r.onsuccess as (() => void) | null)?.(); }); + return r as unknown as IDBOpenDBRequest; + }, + } as unknown as IDBFactory; +} + +describe('app.loadDashboardWorkspace (read-flip source, #286)', () => { + it('migrates the legacy favorites into an aggregate workspace, then reads it back', async () => { + const app = realApp(appEnv({ indexedDB: fakeIndexedDb() })); + app.state.savedQueries = [savedQuery({ id: '1', name: 'Q', sql: 'SELECT 1', favorite: true })] as AppState['savedQueries']; + const ws = await app.loadDashboardWorkspace(); + expect(ws?.dashboard?.tiles.length).toBe(1); + expect(ws?.dashboard?.tiles[0].queryId).toBe('1'); + // Idempotent: a second call finds the aggregate and returns it unchanged. + const again = await app.loadDashboardWorkspace(); + expect(again?.id).toBe(ws?.id); + }); +}); + describe('app.renderDashboard', () => { it('renders the favorites dashboard into the root — streaming the tile through the real seam (#193)', async () => { // End-to-end through createApp's real app.exec.executeRead → ch.runQuery → the @@ -2060,7 +797,13 @@ describe('app.renderDashboard', () => { ]), })]]); const app = realApp(appEnv({ fetch: asFetch(fetch) })); - setSaved(app, [{ id: '1', name: 'Q', sql: 'SELECT k, v FROM mychart', favorite: true }]); + // Drive the read-flip deterministically: a StoredWorkspaceV1 whose one tile + // references the query (bypassing IndexedDB), then the real exec seam runs it. + const query = savedQuery({ id: '1', name: 'Q', sql: 'SELECT k, v FROM mychart' }); + app.loadDashboardWorkspace = async () => ({ + storageVersion: 1, id: 'w', name: 'W', queries: [query], + dashboard: { documentVersion: 1, id: 'd', title: 'D', revision: 1, layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [{ id: 't1', queryId: '1' }] }, + }); await app.renderDashboard(); expect(qs(app.root, '.dash-tile canvas')).not.toBeNull(); // The read-only tile guard (readonly=2) + the row-cap sentinel reach the wire. diff --git a/tests/unit/filter-bar.test.ts b/tests/unit/filter-bar.test.ts index e73441a4..27005709 100644 --- a/tests/unit/filter-bar.test.ts +++ b/tests/unit/filter-bar.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { analyzeParameterizedSources, fieldControls } from '../../src/core/param-pipeline.js'; import type { FieldControl, PreparedFieldState } from '../../src/core/param-pipeline.js'; import { buildFilterBar, FILTER_DEBOUNCE_MS } from '../../src/ui/filter-bar.js'; +import { emptyRecentMap, recordRecent } from '../../src/core/recent-values.js'; import { makeApp } from '../helpers/fake-app.js'; // The field-family construction, debounce, commit, conflict, and optional @@ -50,6 +51,24 @@ describe('buildFilterBar (shared filter row)', () => { expect(FILTER_DEBOUNCE_MS).toBe(500); }); + it('a recent-value pick commits immediately and Clear recent clears the field recents (#171)', () => { + const app = makeApp(); + app.state.varRecent = recordRecent(emptyRecentMap(), 'x', 'foo'); + const onCommit = vi.fn(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), onCommit, okField, { document }); + document.body.appendChild(bar.el); + const input = bar.el.querySelector('input')!; + input.dispatchEvent(new Event('focus')); + const opt = bar.el.querySelector('[role="option"]'); + expect(opt).not.toBeNull(); + opt!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + expect(onCommit).toHaveBeenCalledWith('x'); // onPick — immediate commit + input.dispatchEvent(new Event('focus')); + bar.el.querySelector('.var-combo-footer button')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + expect(app.params.clearVarRecent).toHaveBeenCalledWith('x'); // onClearRecent + bar.el.remove(); + }); + it('persists and commits curated selections', () => { const app = makeApp(); const onCommit = vi.fn(); diff --git a/tests/unit/flow-layout.test.ts b/tests/unit/flow-layout.test.ts new file mode 100644 index 00000000..7b572c9c --- /dev/null +++ b/tests/unit/flow-layout.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_FLOW_PLACEMENT, FLOW_MOBILE_BREAKPOINT, FLOW_PRESET_COLUMNS, + computeFlowLayout, deriveFlowPlacement, effectiveSpan, flowLayoutPlugin, + presetColumns, resolveActiveLayoutPlugin, resolvePlacement, setFlowPlacement, +} from '../../src/dashboard/layouts/flow-layout.js'; +import type { DashboardDocumentV1 } from '../../src/generated/json-schema.types.js'; + +const flowLayout = (items: Record> = {}) => ({ type: 'flow', version: 1, preset: 'full-width', items }); +const doc = (over: Partial = {}): DashboardDocumentV1 => ({ + documentVersion: 1, id: 'd', title: 'D', revision: 1, layout: flowLayout(), filters: [], tiles: [], ...over, +} as DashboardDocumentV1); + +describe('deriveFlowPlacement', () => { + it('maps preferred size hints to a span', () => { + expect(deriveFlowPlacement({ preferred: 'wide' })).toEqual({ span: 3, height: 'medium' }); + expect(deriveFlowPlacement({ preferred: 'medium' })).toEqual({ span: 2, height: 'medium' }); + expect(deriveFlowPlacement({ preferred: 'compact' })).toEqual({ span: 1, height: 'medium' }); + }); + + it('returns undefined without a usable hint', () => { + expect(deriveFlowPlacement(undefined)).toBeUndefined(); + expect(deriveFlowPlacement({ preferred: 'other' })).toBeUndefined(); + expect(deriveFlowPlacement({})).toBeUndefined(); + }); +}); + +describe('setFlowPlacement', () => { + it('sets a placement on a flow layout', () => { + const layout = flowLayout(); + setFlowPlacement(layout, 't1', { span: 2 }); + expect(layout.items).toEqual({ t1: { span: 2 } }); + }); + + it('creates the items map when the flow layout is missing one', () => { + const layout: Record = { type: 'flow', version: 1, preset: 'full-width' }; + setFlowPlacement(layout, 't1', { span: 1 }); + expect(layout.items).toEqual({ t1: { span: 1 } }); + }); + + it('targets a valid flow@1 fallback of an unsupported layout', () => { + const layout: Record = { type: 'grid', version: 9, fallback: flowLayout() }; + setFlowPlacement(layout, 't1', { span: 3 }); + expect((layout.fallback as { items: unknown }).items).toEqual({ t1: { span: 3 } }); + }); + + it('creates the items map on a fallback that lacks one', () => { + const layout: Record = { type: 'grid', version: 9, fallback: { type: 'flow', version: 1, preset: 'full-width' } }; + setFlowPlacement(layout, 't1', { span: 1 }); + expect((layout.fallback as { items: unknown }).items).toEqual({ t1: { span: 1 } }); + }); + + it('is a no-op when there is no flow surface', () => { + const layout: Record = { type: 'grid', version: 9 }; + expect(() => setFlowPlacement(layout, 't1', { span: 1 })).not.toThrow(); + expect(layout.items).toBeUndefined(); + // A non-object layout is tolerated too. + expect(() => setFlowPlacement(null, 't1', { span: 1 })).not.toThrow(); + }); +}); + +describe('flowLayoutPlugin.normalize', () => { + it('prunes placements that no longer name a tile, without mutating the input', () => { + const input = doc({ tiles: [{ id: 't1', queryId: 'q' }] as never, layout: flowLayout({ t1: { span: 1 }, ghost: {} }) }); + const result = flowLayoutPlugin.normalize(input); + expect(result.layout.items).toEqual({ t1: { span: 1 } }); + expect((input.layout.items as Record).ghost).toBeDefined(); // input untouched + }); + + it('normalizes an unsupported layout with a fallback and tolerates a missing flow surface', () => { + const withFallback = doc({ + tiles: [{ id: 't1', queryId: 'q' }] as never, + layout: { type: 'grid', version: 9, items: {}, fallback: flowLayout({ t1: {}, ghost: {} }) } as never, + }); + const result = flowLayoutPlugin.normalize(withFallback); + expect((result.layout.fallback as { items: unknown }).items).toEqual({ t1: {} }); + + // A non-flow layout with no fallback: nothing to prune, no throw. + const noSurface = doc({ tiles: [{ queryId: 'q' }] as never, layout: { type: 'grid', version: 9 } as never }); + expect(() => flowLayoutPlugin.normalize(noSurface)).not.toThrow(); + + // A non-array tiles field is tolerated — every placement is then an orphan. + const noTiles = doc({ tiles: undefined as never, layout: flowLayout({ ghost: {} }) }); + expect(flowLayoutPlugin.normalize(noTiles).layout.items).toEqual({}); + }); +}); + +describe('flowLayoutPlugin.validatePlacement', () => { + it('accepts a valid placement', () => { + expect(flowLayoutPlugin.validatePlacement({ span: 2, height: 'large' })).toEqual([]); + expect(flowLayoutPlugin.validatePlacement(DEFAULT_FLOW_PLACEMENT)).toEqual([]); + }); + + it('rejects a non-object placement', () => { + const diagnostics = flowLayoutPlugin.validatePlacement('nope', ['layout', 'items', 't1']); + expect(diagnostics.map((d) => d.code)).toEqual(['layout-placement-invalid']); + }); + + it('rejects unknown fields and invalid span/height', () => { + const diagnostics = flowLayoutPlugin.validatePlacement({ span: 4, height: 'huge', bogus: true }); + const codes = diagnostics.map((d) => d.code); + expect(codes).toContain('layout-placement-unknown-field'); + expect(codes).toContain('layout-placement-invalid-span'); + expect(codes).toContain('layout-placement-invalid-height'); + }); +}); + +describe('resolveActiveLayoutPlugin', () => { + it('loads flow@1 and unsupported-with-valid-fallback layouts', () => { + expect(resolveActiveLayoutPlugin(flowLayout()).ok).toBe(true); + expect(resolveActiveLayoutPlugin({ type: 'grid', version: 9, fallback: flowLayout() }).ok).toBe(true); + expect(flowLayoutPlugin.type).toBe('flow'); + expect(flowLayoutPlugin.version).toBe(1); + }); + + it('fails for an unsupported layout without a valid fallback and for a non-object layout', () => { + const noFallback = resolveActiveLayoutPlugin({ type: 'grid', version: 9 }); + expect(noFallback.ok).toBe(false); + if (!noFallback.ok) expect(noFallback.diagnostics[0].code).toBe('dashboard-layout-load-failed'); + expect(resolveActiveLayoutPlugin(null).ok).toBe(false); + expect(resolveActiveLayoutPlugin({ type: 'grid', version: 9, fallback: { type: 'flow', version: 2 } }).ok).toBe(false); + }); +}); + +// ── Phase 4: the normative flow@1 render model (#280) ────────────────────── + +describe('presetColumns', () => { + it('maps each preset to its desktop column count', () => { + expect(presetColumns('full-width')).toBe(1); + expect(presetColumns('report')).toBe(1); + expect(presetColumns('columns-2')).toBe(2); + expect(presetColumns('columns-3')).toBe(3); + expect(FLOW_PRESET_COLUMNS['columns-3']).toBe(3); + }); + + it('falls back to full-width (1) for an unknown or non-string preset', () => { + expect(presetColumns('masonry')).toBe(1); + expect(presetColumns(undefined)).toBe(1); + expect(presetColumns(2)).toBe(1); + }); +}); + +describe('effectiveSpan', () => { + it('clamps the stored span to the active column count (min)', () => { + expect(effectiveSpan(3, 3)).toBe(3); + expect(effectiveSpan(3, 2)).toBe(2); // preset change does not rewrite the stored span + expect(effectiveSpan(2, 1)).toBe(1); + }); + + it('treats an absent/invalid stored span as the default 1', () => { + expect(effectiveSpan(undefined, 3)).toBe(1); + expect(effectiveSpan(4, 3)).toBe(1); + expect(effectiveSpan('2', 3)).toBe(1); + }); + + it('never returns less than one even with a zero column count', () => { + expect(effectiveSpan(2, 0)).toBe(1); + }); +}); + +describe('resolvePlacement', () => { + it('merges a stored placement with the defaults', () => { + expect(resolvePlacement({ span: 2, height: 'large' })).toEqual({ span: 2, height: 'large' }); + expect(resolvePlacement({ span: 3 })).toEqual({ span: 3, height: 'medium' }); + expect(resolvePlacement({ height: 'compact' })).toEqual({ span: 1, height: 'compact' }); + }); + + it('falls back to the defaults for a missing or invalid placement', () => { + expect(resolvePlacement(undefined)).toEqual(DEFAULT_FLOW_PLACEMENT); + expect(resolvePlacement({ span: 9, height: 'huge' })).toEqual({ span: 1, height: 'medium' }); + expect(resolvePlacement('nope')).toEqual({ span: 1, height: 'medium' }); + }); +}); + +describe('computeFlowLayout', () => { + const tiles = (...ids: string[]) => ids.map((id) => ({ id })); + const flow = (preset: string, items: Record> = {}) => + ({ type: 'flow', version: 1, preset, items }); + + it('defaults a missing placement to span 1, medium height', () => { + const model = computeFlowLayout({ tiles: tiles('a'), layout: flow('columns-3') }); + expect(model.rows[0].tiles[0]).toMatchObject({ tileId: 'a', span: 1, height: 'medium' }); + }); + + it('clamps effective span per preset without mutating stored spans', () => { + const layout = flow('columns-2', { a: { span: 3 } }); + const model = computeFlowLayout({ tiles: tiles('a', 'b'), layout }); + expect(model.columns).toBe(2); + expect(model.rows[0].tiles[0].span).toBe(2); // 3 clamped to 2 + expect(layout.items.a.span).toBe(3); // persisted span untouched + }); + + it('packs tiles row-major, starting a new row when the next span does not fit, with no overlaps', () => { + // columns-3, spans [2,2,1]: row1 = [2] (remaining 1 < 2 → wrap), row2 = [2,1] + const layout = flow('columns-3', { a: { span: 2 }, b: { span: 2 }, c: { span: 1 } }); + const model = computeFlowLayout({ tiles: tiles('a', 'b', 'c'), layout }); + expect(model.rows.map((row) => row.tiles.map((tile) => tile.tileId))).toEqual([['a'], ['b', 'c']]); + for (const row of model.rows) { + const used = row.tiles.reduce((sum, tile) => sum + tile.span, 0); + expect(used).toBeLessThanOrEqual(row.columns); + } + }); + + it('keeps semantic = DOM = visual row-major = keyboard = print order', () => { + const layout = flow('columns-2', { a: { span: 1 }, b: { span: 1 }, c: { span: 2 } }); + const model = computeFlowLayout({ tiles: tiles('a', 'b', 'c'), layout }); + const visual = model.rows.flatMap((row) => row.tiles.map((tile) => tile.tileId)); + expect(model.order).toEqual(['a', 'b', 'c']); + expect(visual).toEqual(model.order); // row-major visual order == semantic order + }); + + it('normalizes to one column on mobile without mutating persistence', () => { + const layout = flow('columns-3', { a: { span: 3 }, b: { span: 2 } }); + const model = computeFlowLayout({ tiles: tiles('a', 'b'), layout, mobile: true }); + expect(model.mobile).toBe(true); + expect(model.columns).toBe(1); + expect(model.rows.map((row) => row.tiles.map((tile) => tile.span))).toEqual([[1], [1]]); + expect(layout.items.a.span).toBe(3); // still authored + expect(layout.preset).toBe('columns-3'); // preset unchanged + // Desktop restores the authored placement. + const desktop = computeFlowLayout({ tiles: tiles('a', 'b'), layout }); + expect(desktop.rows[0].tiles[0].span).toBe(3); + }); + + it('groups a maximal consecutive KPI run into one full-width band row', () => { + const model = computeFlowLayout({ + tiles: [{ id: 'k1', isKpi: true }, { id: 'k2', isKpi: true }, { id: 't1' }, { id: 'k3', isKpi: true }], + layout: flow('columns-2'), + }); + expect(model.rows[0]).toMatchObject({ kind: 'kpi-band', columns: 2 }); + expect(model.rows[0].tiles.map((tile) => tile.tileId)).toEqual(['k1', 'k2']); + expect(model.rows[1]).toMatchObject({ kind: 'tiles' }); + expect(model.rows[2]).toMatchObject({ kind: 'kpi-band' }); + expect(model.rows[2].tiles.map((tile) => tile.tileId)).toEqual(['k3']); + // The band interrupts packing but preserves the overall order. + expect(model.rows.flatMap((row) => row.tiles.map((tile) => tile.tileId))).toEqual(model.order); + }); + + it('reads the preset/items from a valid flow@1 fallback of an unsupported layout', () => { + const model = computeFlowLayout({ + tiles: tiles('a'), + layout: { type: 'grid', version: 9, fallback: flow('columns-2', { a: { span: 2 } }) }, + }); + expect(model.columns).toBe(2); + expect(model.rows[0].tiles[0].span).toBe(2); + }); + + it('falls back to full-width with defaults when the layout has no flow surface', () => { + const model = computeFlowLayout({ tiles: tiles('a', 'b'), layout: { type: 'grid', version: 9 } }); + expect(model.preset).toBe('full-width'); + expect(model.columns).toBe(1); + expect(model.rows.map((row) => row.tiles.map((tile) => tile.tileId))).toEqual([['a'], ['b']]); + // A non-object layout is tolerated too. + expect(computeFlowLayout({ tiles: tiles('a'), layout: null }).preset).toBe('full-width'); + // An unknown preset string on a flow surface degrades to full-width. + expect(computeFlowLayout({ tiles: tiles('a'), layout: flow('bogus') }).preset).toBe('full-width'); + }); + + it('exposes the mobile breakpoint constant', () => { + expect(FLOW_MOBILE_BREAKPOINT).toBe(768); + }); +}); diff --git a/tests/unit/indexeddb-workspace-store.test.ts b/tests/unit/indexeddb-workspace-store.test.ts new file mode 100644 index 00000000..287f2c07 --- /dev/null +++ b/tests/unit/indexeddb-workspace-store.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; +import { createIndexedDbWorkspaceStore } from '../../src/workspace/indexeddb-workspace-store.js'; + +// ── A minimal in-memory fake IDBFactory ────────────────────────────────────── +// Implements exactly the IndexedDB surface the adapter touches (open with an +// upgrade callback, a keyed object store, and readonly/readwrite transactions), +// with knobs to drive every error branch. Events fire on a microtask so the +// adapter's handlers are attached before they run — the same ordering a real +// IndexedDB guarantees. +type Handler = (() => void) | null; + +interface Cfg { + failOpen?: boolean; openError?: unknown; + failGet?: boolean; getError?: unknown; + failTx?: 'abort' | 'error' | null; txError?: unknown; + preexistingStore?: boolean; + storeName?: string; recordKey?: string; + seed?: string; +} + +class FakeRequest { + result: unknown; + error: unknown = null; + onsuccess: Handler = null; + onerror: Handler = null; + onupgradeneeded: Handler = null; +} + +class FakeObjectStore { + data: Map; + cfg: Cfg; + constructor(data: Map, cfg: Cfg) { this.data = data; this.cfg = cfg; } + get(key: string): FakeRequest { + const req = new FakeRequest(); + queueMicrotask(() => { + if (this.cfg.failGet) { req.error = this.cfg.getError ?? null; req.onerror?.(); } else { + req.result = this.data.get(key); + req.onsuccess?.(); + } + }); + return req; + } + put(value: unknown, key: string): FakeRequest { this.data.set(key, value); return new FakeRequest(); } + delete(key: string): FakeRequest { this.data.delete(key); return new FakeRequest(); } +} + +class FakeTx { + error: unknown = null; + oncomplete: Handler = null; + onerror: Handler = null; + onabort: Handler = null; + db: FakeDB; + cfg: Cfg; + constructor(db: FakeDB, cfg: Cfg) { + this.db = db; + this.cfg = cfg; + queueMicrotask(() => { + if (this.cfg.failTx === 'abort') { this.error = this.cfg.txError ?? null; this.onabort?.(); } else if (this.cfg.failTx === 'error') { this.error = this.cfg.txError ?? null; this.onerror?.(); } else this.oncomplete?.(); + }); + } + objectStore(name: string): FakeObjectStore { return new FakeObjectStore(this.db.stores.get(name)!, this.cfg); } +} + +class FakeDB { + stores = new Map>(); + objectStoreNames = { contains: (n: string) => this.stores.has(n) }; + cfg: Cfg; + constructor(cfg: Cfg) { this.cfg = cfg; } + createObjectStore(name: string): void { this.stores.set(name, new Map()); } + transaction(_names: string[], _mode: string): FakeTx { return new FakeTx(this, this.cfg); } +} + +function fakeFactory(cfg: Cfg = {}): IDBFactory { + const db = new FakeDB(cfg); + if (cfg.preexistingStore) { + const map = new Map(); + if (cfg.seed !== undefined) map.set(cfg.recordKey ?? 'current', cfg.seed); + db.stores.set(cfg.storeName ?? 'workspace', map); + } + return { + open(_name: string, _version?: number) { + const req = new FakeRequest(); + queueMicrotask(() => { + if (cfg.failOpen) { req.error = cfg.openError ?? null; req.onerror?.(); return; } + req.result = db; + req.onupgradeneeded?.(); + req.onsuccess?.(); + }); + return req as unknown as IDBOpenDBRequest; + }, + } as unknown as IDBFactory; +} + +describe('createIndexedDbWorkspaceStore', () => { + it('reads null when the record is absent, after a normal open + store creation', async () => { + const store = createIndexedDbWorkspaceStore(fakeFactory()); + expect(await store.read()).toBeNull(); + }); + + it('writes then reads back the same text (one atomic transaction) and caches the open', async () => { + const store = createIndexedDbWorkspaceStore(fakeFactory()); + await store.write('{"hello":"world"}'); + // Second op reuses the cached open promise (no re-open). + expect(await store.read()).toBe('{"hello":"world"}'); + }); + + it('clears the record', async () => { + const factory = fakeFactory({ preexistingStore: true, seed: 'seeded' }); + const store = createIndexedDbWorkspaceStore(factory); + expect(await store.read()).toBe('seeded'); // contains()===true branch + seeded read + await store.clear(); + expect(await store.read()).toBeNull(); + }); + + it('honors custom db/store/record option names', async () => { + const store = createIndexedDbWorkspaceStore( + fakeFactory({ storeName: 'agg', recordKey: 'ws' }), + { dbName: 'custom', storeName: 'agg', recordKey: 'ws' }, + ); + await store.write('x'); + expect(await store.read()).toBe('x'); + }); + + it('rejects every operation when no IndexedDB factory is available', async () => { + const store = createIndexedDbWorkspaceStore(undefined); + await expect(store.read()).rejects.toThrow('IndexedDB is unavailable'); + }); + + it('rejects when the database open fails (with and without a request error)', async () => { + const boom = createIndexedDbWorkspaceStore(fakeFactory({ failOpen: true, openError: new Error('open boom') })); + await expect(boom.read()).rejects.toThrow('open boom'); + const silent = createIndexedDbWorkspaceStore(fakeFactory({ failOpen: true })); + await expect(silent.read()).rejects.toThrow('IndexedDB open failed'); + }); + + it('does not poison the store when an open fails — a same-session retry can reopen', async () => { + // A factory whose first open rejects, then succeeds — proving the failed + // open promise is never cached (no permanent poison). + const db = new FakeDB({}); + db.stores.set('workspace', new Map()); + let attempt = 0; + const factory = { + open() { + const req = new FakeRequest(); + const failThis = attempt++ === 0; + queueMicrotask(() => { + if (failThis) { req.error = new Error('transient open'); req.onerror?.(); return; } + req.result = db; + req.onsuccess?.(); + }); + return req as unknown as IDBOpenDBRequest; + }, + } as unknown as IDBFactory; + const store = createIndexedDbWorkspaceStore(factory); + await expect(store.read()).rejects.toThrow('transient open'); + // Second call reopens (cache was cleared on the rejection) and succeeds. + expect(await store.read()).toBeNull(); + expect(attempt).toBe(2); + }); + + it('rejects the open when it is blocked (unreachable at version 1; guarded defensively)', async () => { + const factory = { + open() { + const req = new FakeRequest(); + queueMicrotask(() => { (req as unknown as { onblocked?: () => void }).onblocked?.(); }); + return req as unknown as IDBOpenDBRequest; + }, + } as unknown as IDBFactory; + const store = createIndexedDbWorkspaceStore(factory); + await expect(store.read()).rejects.toThrow('IndexedDB open blocked'); + }); + + it('rejects when the get request fails (with and without a request error)', async () => { + const boom = createIndexedDbWorkspaceStore(fakeFactory({ failGet: true, getError: new Error('get boom') })); + await expect(boom.read()).rejects.toThrow('get boom'); + const silent = createIndexedDbWorkspaceStore(fakeFactory({ failGet: true })); + await expect(silent.read()).rejects.toThrow('IndexedDB request failed'); + }); + + it('rejects a write when the transaction aborts or errors (with and without a tx error)', async () => { + const aborted = createIndexedDbWorkspaceStore(fakeFactory({ failTx: 'abort', txError: new Error('abort boom') })); + await expect(aborted.write('x')).rejects.toThrow('abort boom'); + const erroredSilently = createIndexedDbWorkspaceStore(fakeFactory({ failTx: 'error' })); + await expect(erroredSilently.clear()).rejects.toThrow('IndexedDB transaction failed'); + const abortedSilently = createIndexedDbWorkspaceStore(fakeFactory({ failTx: 'abort' })); + await expect(abortedSilently.write('x')).rejects.toThrow('IndexedDB transaction aborted'); + }); +}); diff --git a/tests/unit/json-limits.test.ts b/tests/unit/json-limits.test.ts new file mode 100644 index 00000000..f7a4cb7a --- /dev/null +++ b/tests/unit/json-limits.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + parseJsonWithLimits, scanJsonDepth, utf8ByteLength, +} from '../../src/dashboard/model/json-limits.js'; +import { PORTABLE_LIMITS } from '../../src/dashboard/model/portable-limits.js'; + +describe('utf8ByteLength', () => { + it('counts bytes per WHATWG TextEncoder, incl. surrogate handling', () => { + const encoder = new TextEncoder(); + for (const sample of ['', 'ascii', 'é', 'ü2', '€', 'a€b', '😀', 'x😀y', '中文字']) { + expect(utf8ByteLength(sample)).toBe(encoder.encode(sample).length); + } + // Unpaired high surrogate → U+FFFD (3 bytes); high surrogate at EOS. + expect(utf8ByteLength('\uD800')).toBe(3); + expect(utf8ByteLength('\uD800x')).toBe(3 + 1); + // Lone low surrogate → U+FFFD (3 bytes). + expect(utf8ByteLength('\uDC00')).toBe(3); + }); +}); + +describe('scanJsonDepth', () => { + it('measures max container nesting, ignoring braces inside strings', () => { + expect(scanJsonDepth('1')).toBe(0); + expect(scanJsonDepth('{}')).toBe(1); + expect(scanJsonDepth('{"a":[1,2]}')).toBe(2); + expect(scanJsonDepth('[[[]]]')).toBe(3); + expect(scanJsonDepth('{"a":"}}}}","b":{"c":1}}')).toBe(2); + // Escaped quote inside a string keeps the string open; braces stay data. + expect(scanJsonDepth('{"a":"x\\"{{{"}')).toBe(1); + // Backslash-escaped backslash then closing quote ends the string. + expect(scanJsonDepth('{"a":"x\\\\"}')).toBe(1); + }); +}); + +describe('parseJsonWithLimits', () => { + it('parses valid JSON within limits', () => { + expect(parseJsonWithLimits('{"a":1}')).toEqual({ ok: true, value: { a: 1 } }); + }); + + it('rejects non-string input and JSON syntax errors with a stable diagnostic', () => { + expect(parseJsonWithLimits(42)).toEqual({ + ok: false, + diagnostics: [{ path: [], severity: 'error', code: 'json-syntax', message: 'Not a valid JSON file' }], + }); + expect(parseJsonWithLimits('{bad')).toEqual({ + ok: false, + diagnostics: [{ path: [], severity: 'error', code: 'json-syntax', message: 'Not a valid JSON file' }], + }); + }); + + it('enforces the UTF-8 byte cap before parsing, at boundary and boundary+1', () => { + const atLimit = '"' + 'a'.repeat(8) + '"'; // 10 bytes + expect(parseJsonWithLimits(atLimit, { maxBytes: 10 })).toEqual({ ok: true, value: 'aaaaaaaa' }); + const overLimit = '"' + 'a'.repeat(9) + '"'; // 11 bytes + const result = parseJsonWithLimits(overLimit, { maxBytes: 10 }); + expect(result.ok).toBe(false); + expect(!result.ok && result.diagnostics[0].code).toBe('limit-json-bytes'); + expect(!result.ok && result.diagnostics[0].message).toContain('11 UTF-8 bytes'); + }); + + it('enforces the depth cap before parsing, at boundary and boundary+1', () => { + const atDepth = '[[[1]]]'; // depth 3 + expect(parseJsonWithLimits(atDepth, { maxDepth: 3 })).toEqual({ ok: true, value: [[[1]]] }); + const overDepth = '[[[[1]]]]'; // depth 4 + const result = parseJsonWithLimits(overDepth, { maxDepth: 3 }); + expect(result.ok).toBe(false); + expect(!result.ok && result.diagnostics[0].code).toBe('limit-json-depth'); + expect(!result.ok && result.diagnostics[0].message).toContain('4 levels'); + }); + + it('defaults to the #280 portable limits', () => { + // A byte-cap breach uses the default maxDecodedJsonBytes message. + const huge = '"' + 'a'.repeat(PORTABLE_LIMITS.maxDecodedJsonBytes) + '"'; + const result = parseJsonWithLimits(huge); + expect(result.ok).toBe(false); + expect(!result.ok && result.diagnostics[0].code).toBe('limit-json-bytes'); + }); +}); diff --git a/tests/unit/json-merge-patch.test.ts b/tests/unit/json-merge-patch.test.ts new file mode 100644 index 00000000..ec59b642 --- /dev/null +++ b/tests/unit/json-merge-patch.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { applyMergePatch, isMergePatchObject } from '../../src/dashboard/model/json-merge-patch.js'; + +describe('isMergePatchObject', () => { + it('accepts only plain objects', () => { + expect(isMergePatchObject({})).toBe(true); + expect(isMergePatchObject({ a: 1 })).toBe(true); + expect(isMergePatchObject([])).toBe(false); + expect(isMergePatchObject(null)).toBe(false); + expect(isMergePatchObject('s')).toBe(false); + expect(isMergePatchObject(3)).toBe(false); + }); +}); + +describe('applyMergePatch — RFC 7396', () => { + it('merges object members recursively', () => { + const target = { a: { x: 1, y: 2 }, b: 1 }; + const result = applyMergePatch(target, { a: { y: 9, z: 3 }, c: 4 }); + expect(result).toEqual({ a: { x: 1, y: 9, z: 3 }, b: 1, c: 4 }); + }); + + it('deletes a member whose patch value is null', () => { + expect(applyMergePatch({ a: 1, b: 2 }, { a: null })).toEqual({ b: 2 }); + // Deleting an absent member is a no-op. + expect(applyMergePatch({ b: 2 }, { a: null })).toEqual({ b: 2 }); + }); + + it('replaces arrays wholesale — never index-merges or concatenates', () => { + expect(applyMergePatch({ y: [1, 2, 3] }, { y: [9] })).toEqual({ y: [9] }); + }); + + it('replaces the whole value when the patch is not an object', () => { + expect(applyMergePatch({ a: 1 }, [1, 2])).toEqual([1, 2]); + expect(applyMergePatch({ a: 1 }, 'x')).toBe('x'); + expect(applyMergePatch({ a: 1 }, null)).toBe(null); + }); + + it('treats a non-object target as an empty object when the patch is an object', () => { + expect(applyMergePatch(5, { a: 1 })).toEqual({ a: 1 }); + expect(applyMergePatch(null, { a: { b: 2 } })).toEqual({ a: { b: 2 } }); + }); + + it('shares no structure with either input', () => { + const target = { a: { x: [1] } }; + const patch = { a: { y: { z: 2 } } }; + const result = applyMergePatch(target, patch) as { a: { x: number[]; y: { z: number } } }; + result.a.x.push(99); + result.a.y.z = 100; + expect(target.a.x).toEqual([1]); + expect(patch.a.y.z).toBe(2); + }); +}); diff --git a/tests/unit/json-schema-validation.test.ts b/tests/unit/json-schema-validation.test.ts index be52e73f..61858ee0 100644 --- a/tests/unit/json-schema-validation.test.ts +++ b/tests/unit/json-schema-validation.test.ts @@ -35,12 +35,15 @@ describe('JSON Schema diagnostic normalization', () => { ['pattern', '/list/0', {}, 'schema-invalid-string', 'list[0] has an invalid string value'], ['minItems', '/list', { limit: 1 }, 'schema-array-size', 'list must contain at least 1 item'], ['maxItems', '/list', { limit: 2 }, 'schema-array-size', 'list must contain at most 2 items'], + ['minProperties', '/object', { limit: 1 }, 'schema-object-size', 'object must contain at least 1 property'], + ['maxProperties', '/object', { limit: 2 }, 'schema-object-size', 'object must contain at most 2 properties'], ['uniqueItems', '/list', { i: 0 }, 'schema-array-duplicate', 'list[0] must not contain duplicate items'], ['anyOf', '/list/0', {}, 'schema-invalid-variant', 'list[0] must match an allowed variant'], ['additionalProperties', '/object', { additionalProperty: 'extra' }, 'schema-unknown-property', 'object.extra is not an allowed property'], ['unevaluatedProperties', '/object', { unevaluatedProperty: 'extra' }, 'schema-unknown-property', 'object.extra is not an allowed property'], ['format', '/list/0', { format: 'date-time' }, 'schema-invalid-format', 'list[0] must match format "date-time"'], ['$ref', '/list/0', {}, 'schema-internal-reference', 'list[0] contains an unresolved schema reference'], + ['propertyNames', '/object', { propertyName: 'bad-key' }, 'schema-property-name', 'object["bad-key"] is an invalid property name'], ['custom', '/list/0', {}, 'schema-custom', 'list[0] failed custom validation'], ]; for (const [keyword, instancePath, params, code, message] of cases) { diff --git a/tests/unit/layout-registry.test.ts b/tests/unit/layout-registry.test.ts new file mode 100644 index 00000000..581ea53d --- /dev/null +++ b/tests/unit/layout-registry.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { + createLayoutRegistry, defaultLayoutRegistry, flowLayoutRegistration, +} from '../../src/dashboard/layouts/layout-registry.js'; +import { flowLayoutPlugin } from '../../src/dashboard/layouts/flow-layout.js'; +import type { DashboardLayoutPlugin } from '../../src/dashboard/layouts/flow-layout.js'; +import type { DashboardLayoutRegistration } from '../../src/dashboard/layouts/layout-registry.js'; + +const flow = (items: Record> = {}) => + ({ type: 'flow', version: 1, preset: 'full-width', items }); + +// A stub grid plugin for a second registered engine. +const gridPlugin: DashboardLayoutPlugin = { + type: 'grid', version: 2, + normalize: (dashboard) => dashboard, + validatePlacement: () => [], +}; +const gridRegistration: DashboardLayoutRegistration = { + id: 'grid', versions: [2], load: () => Promise.resolve(gridPlugin), +}; + +describe('createLayoutRegistry', () => { + it('always includes the built-in flow@1 engine', () => { + const registry = createLayoutRegistry(); + expect(registry.supports('flow', 1)).toBe(true); + expect(flowLayoutRegistration.id).toBe('flow'); + }); + + it('registers additional engines and ignores a shadowing or duplicate id', async () => { + const shadow: DashboardLayoutRegistration = { + id: 'flow', versions: [1], load: () => Promise.reject(new Error('should never be used')), + }; + const duplicateGrid: DashboardLayoutRegistration = { + id: 'grid', versions: [3], load: () => Promise.resolve(gridPlugin), + }; + const registry = createLayoutRegistry([gridRegistration, shadow, duplicateGrid]); + expect(registry.supports('grid', 2)).toBe(true); + expect(registry.supports('grid', 3)).toBe(false); // duplicate id ignored + // The built-in flow load wins over the shadow registration. + expect(await registry.load('flow', 1)).toBe(flowLayoutPlugin); + }); +}); + +describe('supports', () => { + const registry = createLayoutRegistry([gridRegistration]); + it('is false for a non-string id, non-number version, unknown id, or unknown version', () => { + expect(registry.supports(null, 1)).toBe(false); + expect(registry.supports('flow', '1')).toBe(false); + expect(registry.supports('nope', 1)).toBe(false); + expect(registry.supports('flow', 2)).toBe(false); + }); +}); + +describe('load', () => { + it('returns the plugin for a supported engine and null when unsupported', async () => { + const registry = createLayoutRegistry([gridRegistration]); + expect(await registry.load('grid', 2)).toBe(gridPlugin); + expect(await registry.load('grid', 9)).toBeNull(); + }); + + it('returns null (never throws) when the registration load throws', async () => { + const boom: DashboardLayoutRegistration = { + id: 'boom', versions: [1], load: () => { throw new Error('kaboom'); }, + }; + const registry = createLayoutRegistry([boom]); + expect(await registry.load('boom', 1)).toBeNull(); + }); +}); + +describe('resolve', () => { + it('resolves the primary engine when supported', async () => { + const registry = createLayoutRegistry([gridRegistration]); + const result = await registry.resolve({ type: 'grid', version: 2, fallback: flow() }); + expect(result.ok).toBe(true); + if (result.ok) { expect(result.plugin).toBe(gridPlugin); expect(result.usedFallback).toBe(false); } + }); + + it('renders the valid flow@1 fallback when the primary cannot load', async () => { + const registry = createLayoutRegistry(); // grid not registered + const result = await registry.resolve({ type: 'grid', version: 9, fallback: flow({ a: { span: 1 } }) }); + expect(result.ok).toBe(true); + if (result.ok) { expect(result.plugin).toBe(flowLayoutPlugin); expect(result.usedFallback).toBe(true); } + }); + + it('falls back when a supported primary engine throws on load', async () => { + const boom: DashboardLayoutRegistration = { + id: 'grid', versions: [2], load: () => Promise.reject(new Error('load failed')), + }; + const registry = createLayoutRegistry([boom]); + const result = await registry.resolve({ type: 'grid', version: 2, fallback: flow() }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.usedFallback).toBe(true); + }); + + it('fails closed for an unsupported primary without a valid flow@1 fallback', async () => { + const registry = createLayoutRegistry(); + const noFallback = await registry.resolve({ type: 'grid', version: 9 }); + expect(noFallback.ok).toBe(false); + if (!noFallback.ok) expect(noFallback.diagnostics[0].code).toBe('dashboard-layout-load-failed'); + + const badFallback = await registry.resolve({ type: 'grid', version: 9, fallback: { type: 'flow', version: 2 } }); + expect(badFallback.ok).toBe(false); + + const notObject = await registry.resolve(null, ['dashboard', 'layout']); + expect(notObject.ok).toBe(false); + if (!notObject.ok) expect(notObject.diagnostics[0].path).toEqual(['dashboard', 'layout']); + }); +}); + +describe('defaultLayoutRegistry', () => { + it('exposes only the built-in flow@1 engine', () => { + expect(defaultLayoutRegistry.supports('flow', 1)).toBe(true); + expect(defaultLayoutRegistry.supports('grid', 2)).toBe(false); + }); +}); diff --git a/tests/unit/legacy-migration.test.ts b/tests/unit/legacy-migration.test.ts new file mode 100644 index 00000000..3fc758ac --- /dev/null +++ b/tests/unit/legacy-migration.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { + buildLegacyMigrationCandidate, legacyLayoutToFlowPreset, migrateLegacyWorkspaceIfNeeded, +} from '../../src/workspace/legacy-migration.js'; +import type { LegacyWorkspaceInput } from '../../src/workspace/legacy-migration.js'; +import { createWorkspaceRepository } from '../../src/workspace/workspace-repository.js'; +import type { WorkspaceStore } from '../../src/workspace/workspace-store.types.js'; +import { validateStoredWorkspaceDocument } from '../../src/workspace/stored-workspace.js'; +import type { SavedQueryV2 } from '../../src/generated/json-schema.types.js'; + +function memStore(initial: string | null = null) { + let value = initial; + const store: WorkspaceStore & { readonly value: string | null } = { + read: async () => value, + write: async (t: string) => { value = t; }, + clear: async () => { value = null; }, + get value() { return value; }, + }; + return store; +} +const counter = () => { let n = 0; return () => `id-${++n}`; }; + +const query = (id: string, over: { favorite?: boolean; role?: string } = {}): SavedQueryV2 => ({ + id, sql: 'SELECT 1', specVersion: 1, + spec: { + name: id, + ...(over.favorite === undefined ? {} : { favorite: over.favorite }), + panel: { cfg: { type: 'bar', x: 0, y: [1] } }, + ...(over.role === undefined ? {} : { dashboard: { role: over.role as 'panel' | 'filter' | 'setup' } }), + }, +}); +const legacy = (over: Partial = {}): LegacyWorkspaceInput => ({ + name: 'My Library', queries: [], dashLayout: 'arrange', dashCols: 3, ...over, +}); + +describe('legacyLayoutToFlowPreset', () => { + it('maps every legacy layout preference to a normative flow@1 preset', () => { + expect(legacyLayoutToFlowPreset('wide', 3)).toBe('full-width'); + expect(legacyLayoutToFlowPreset('report', 3)).toBe('report'); + expect(legacyLayoutToFlowPreset('arrange', 2)).toBe('columns-2'); + expect(legacyLayoutToFlowPreset('arrange', 3)).toBe('columns-3'); + }); +}); + +describe('buildLegacyMigrationCandidate', () => { + it('builds a valid aggregate: queries preserved, Dashboard tiles from PANEL favorites in catalog order', () => { + const candidate = buildLegacyMigrationCandidate(legacy({ + dashLayout: 'wide', + queries: [ + query('fav1', { favorite: true }), + query('plain'), + query('fav2', { favorite: true }), + ], + }), counter()); + expect(validateStoredWorkspaceDocument(candidate)).toEqual([]); + expect(candidate.storageVersion).toBe(1); + expect(candidate.id).toBe('id-1'); + expect(candidate.name).toBe('My Library'); + expect(candidate.queries.map((q) => q.id)).toEqual(['fav1', 'plain', 'fav2']); + const dash = candidate.dashboard!; + expect(dash.id).toBe('id-2'); + expect(dash.title).toBe('My Library'); + expect(dash.revision).toBe(1); + expect(dash.layout).toEqual({ type: 'flow', version: 1, preset: 'full-width', items: {} }); + // Only the two favorites became tiles, in catalog order, each with a fresh ID. + expect(dash.tiles).toEqual([ + { id: 'id-3', queryId: 'fav1' }, + { id: 'id-4', queryId: 'fav2' }, + ]); + }); + + it('never turns a favorited filter/setup-role query into a tile', () => { + const candidate = buildLegacyMigrationCandidate(legacy({ + queries: [ + query('panelFav', { favorite: true }), + query('filterFav', { favorite: true, role: 'filter' }), + query('setupFav', { favorite: true, role: 'setup' }), + ], + }), counter()); + expect(candidate.dashboard!.tiles.map((t) => t.queryId)).toEqual(['panelFav']); + // The non-panel favorites stay in the query collection. + expect(candidate.queries.map((q) => q.id)).toEqual(['panelFav', 'filterFav', 'setupFav']); + }); + + it('creates an empty Dashboard (preserving the layout preference) when there are no favorites', () => { + const candidate = buildLegacyMigrationCandidate(legacy({ + dashLayout: 'arrange', dashCols: 2, queries: [query('a')], + }), counter()); + expect(candidate.dashboard!.tiles).toEqual([]); + expect(candidate.dashboard!.layout.preset).toBe('columns-2'); + }); + + it('falls back to the default workspace name for a blank Library name', () => { + const candidate = buildLegacyMigrationCandidate(legacy({ name: ' ' }), counter()); + expect(candidate.name).toBe('SQL Library'); + expect(candidate.dashboard!.title).toBe('SQL Library'); + }); +}); + +describe('migrateLegacyWorkspaceIfNeeded', () => { + const run = (store: WorkspaceStore, over: Partial = {}) => { + const repository = createWorkspaceRepository({ store }); + return migrateLegacyWorkspaceIfNeeded({ store, repository, legacy: legacy(over), genId: counter() }); + }; + + it('skips when an aggregate record already exists (marker = record existence)', async () => { + const store = memStore('{"storageVersion":1,"already":"here"}'); + const outcome = await run(store, { queries: [query('f', { favorite: true })] }); + expect(outcome).toEqual({ migrated: false, reason: 'aggregate-exists' }); + // Existing record untouched — not overwritten by a re-migration. + expect(store.value).toBe('{"storageVersion":1,"already":"here"}'); + }); + + it('runs once when no aggregate exists, persisting one atomic aggregate', async () => { + const store = memStore(null); + const outcome = await run(store, { queries: [query('f', { favorite: true })] }); + expect(outcome.migrated).toBe(true); + if (!outcome.migrated) throw new Error('unreachable'); + expect(outcome.result.workspace.dashboard!.tiles.map((t) => t.queryId)).toEqual(['f']); + expect(outcome.result.dashboardRevision).toBe(1); + expect(store.value).not.toBeNull(); + + // Idempotent: a second run finds the record and skips. + const again = await migrateLegacyWorkspaceIfNeeded({ + store, repository: createWorkspaceRepository({ store }), + legacy: legacy(), genId: counter(), + }); + expect(again).toEqual({ migrated: false, reason: 'aggregate-exists' }); + }); + + it('fails with diagnostics and leaves the store untouched when the candidate is invalid', async () => { + const store = memStore(null); + // Two queries with the same ID make the whole-workspace validation fail. + const outcome = await run(store, { + queries: [query('dup', { favorite: true }), query('dup', { favorite: true })], + }); + expect(outcome.migrated).toBe(false); + if (outcome.migrated || outcome.reason !== 'commit-failed') throw new Error('expected commit-failed'); + expect(outcome.diagnostics.some((d) => d.code === 'workspace-duplicate-query-id')).toBe(true); + expect(store.value).toBeNull(); // legacy keys/store untouched — safe to retry + }); +}); diff --git a/tests/unit/portable-bundle-codec.test.ts b/tests/unit/portable-bundle-codec.test.ts new file mode 100644 index 00000000..5f2043df --- /dev/null +++ b/tests/unit/portable-bundle-codec.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { + CURRENT_PORTABLE_BUNDLE_VERSION, PORTABLE_BUNDLE_FORMAT, PORTABLE_BUNDLE_V1_SCHEMA_ID, + decodePortableBundleJson, encodePortableBundleJson, validatePortableBundleDocument, +} from '../../src/dashboard/model/portable-bundle-codec.js'; +import type { WorkspaceDiagnostic } from '../../src/dashboard/model/workspace-diagnostics.js'; + +const codes = (d: WorkspaceDiagnostic[]): string[] => d.map((x) => x.code); +const has = (d: WorkspaceDiagnostic[], code: string): boolean => d.some((x) => x.code === code); + +const panelQuery = (id: string) => ({ id, sql: 'SELECT 1', specVersion: 1, spec: { name: id, panel: { cfg: { type: 'bar', x: 0, y: [1] } } } }); +const dashboardDoc = (over: Record = {}) => ({ + documentVersion: 1, id: 'd1', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [], ...over, +}); +const bundle = (over: Record = {}) => ({ + format: PORTABLE_BUNDLE_FORMAT, version: 1, exportedAt: '2026-07-17T00:00:00.000Z', + queries: [], dashboards: [], ...over, +}); + +describe('validatePortableBundleDocument', () => { + it('accepts an empty but well-formed bundle and a bundle with a resolvable dashboard', () => { + expect(validatePortableBundleDocument(bundle())).toEqual([]); + const full = bundle({ + queries: [panelQuery('p1')], + dashboards: [dashboardDoc({ tiles: [{ id: 't1', queryId: 'p1' }], layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } } })], + }); + expect(validatePortableBundleDocument(full)).toEqual([]); + }); + + it('fails closed on identity problems', () => { + expect(codes(validatePortableBundleDocument(null))).toEqual(['bundle-invalid-root']); + expect(codes(validatePortableBundleDocument({ format: 'x' }))).toEqual(['bundle-invalid-format']); + expect(codes(validatePortableBundleDocument({ format: PORTABLE_BUNDLE_FORMAT }))).toEqual(['bundle-version-missing']); + expect(codes(validatePortableBundleDocument({ format: PORTABLE_BUNDLE_FORMAT, version: 1.5 }))).toEqual(['bundle-version-invalid']); + expect(codes(validatePortableBundleDocument({ format: PORTABLE_BUNDLE_FORMAT, version: 2 }))).toEqual(['bundle-version-unsupported']); + }); + + it('reports structural schema errors, e.g. a missing required array', () => { + const d = validatePortableBundleDocument({ format: PORTABLE_BUNDLE_FORMAT, version: 1, exportedAt: '2026-07-17T00:00:00.000Z', queries: [] }); + expect(has(d, 'schema-required')).toBe(true); // dashboards required even when empty + }); + + it('fails closed on unknown resource versions and suppresses schema noise for them', () => { + const d = validatePortableBundleDocument(bundle({ + queries: [{ id: 'q', sql: 'x', specVersion: 9, spec: {} }], + dashboards: [dashboardDoc({ documentVersion: 5 })], + })); + expect(has(d, 'spec-version-unsupported')).toBe(true); + expect(has(d, 'dashboard-version-unsupported')).toBe(true); + }); + + it('runs cross-resource semantics once the document is structurally valid', () => { + const d = validatePortableBundleDocument(bundle({ + queries: [panelQuery('dup'), panelQuery('dup')], + })); + expect(has(d, 'workspace-duplicate-query-id')).toBe(true); + }); +}); + +describe('decodePortableBundleJson', () => { + it('parses, validates, and returns the typed value', () => { + const result = decodePortableBundleJson(JSON.stringify(bundle())); + expect(result.ok).toBe(true); + expect(result.ok && result.value.format).toBe(PORTABLE_BUNDLE_FORMAT); + }); + + it('propagates codec-guard failures and validation failures', () => { + expect(decodePortableBundleJson('{bad').ok).toBe(false); + const tooDeep = decodePortableBundleJson('['.repeat(70) + ']'.repeat(70), { maxDepth: 64 }); + expect(tooDeep.ok).toBe(false); + expect(!tooDeep.ok && tooDeep.diagnostics[0].code).toBe('limit-json-depth'); + const invalid = decodePortableBundleJson(JSON.stringify({ format: 'nope' })); + expect(!invalid.ok && invalid.diagnostics[0].code).toBe('bundle-invalid-format'); + }); +}); + +describe('encodePortableBundleJson', () => { + it('builds, validates, and canonically encodes a bundle with a schema hint and metadata', () => { + const result = encodePortableBundleJson({ + queries: [panelQuery('p1')], dashboards: [], metadata: { name: 'n' }, nowISO: '2026-07-17T00:00:00.000Z', + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const parsed = JSON.parse(result.value); + expect(parsed.$schema).toBe(PORTABLE_BUNDLE_V1_SCHEMA_ID); + expect(parsed.version).toBe(CURRENT_PORTABLE_BUNDLE_VERSION); + // Canonical key order: $schema first, queries before dashboards. + expect(result.value.indexOf('"$schema"')).toBeLessThan(result.value.indexOf('"format"')); + expect(result.value.indexOf('"queries"')).toBeLessThan(result.value.indexOf('"dashboards"')); + }); + + it('omits the schema hint when asked and omits metadata when absent', () => { + const result = encodePortableBundleJson({ + queries: [], dashboards: [], nowISO: '2026-07-17T00:00:00.000Z', includeSchemaHint: false, + }); + expect(result.ok && !result.value.includes('$schema')).toBe(true); + expect(result.ok && !result.value.includes('metadata')).toBe(true); + }); + + it('rejects non-array inputs and a missing timestamp', () => { + expect(!encodePortableBundleJson({ queries: 'x' as unknown as unknown[], dashboards: [], nowISO: 'x' }).ok).toBe(true); + expect(!encodePortableBundleJson({ queries: [], dashboards: 'x' as unknown as unknown[], nowISO: 'x' }).ok).toBe(true); + const noStamp = encodePortableBundleJson({ queries: [], dashboards: [], nowISO: '' }); + expect(!noStamp.ok && noStamp.diagnostics[0].code).toBe('schema-required'); + }); + + it('fails when the built bundle is semantically invalid', () => { + const result = encodePortableBundleJson({ + queries: [panelQuery('dup'), panelQuery('dup')], dashboards: [], nowISO: '2026-07-17T00:00:00.000Z', + }); + expect(!result.ok && has(result.diagnostics, 'workspace-duplicate-query-id')).toBe(true); + }); + + it('rejects an encoded document larger than the decoded-JSON byte cap', () => { + // Each spec stays just under the 1 MiB per-spec limit but eleven of them + // sum past the 10 MiB whole-document cap. + // An arbitrary extension field (query-spec is open) inflates each spec to + // just under the 1 MiB per-spec cap; eleven sum past the 10 MiB document cap. + const chunk = 'x'.repeat(1_000_000); + const queries = Array.from({ length: 11 }, (_, i) => ({ + id: `q${i}`, sql: 'SELECT 1', specVersion: 1, spec: { name: `q${i}`, ext: chunk }, + })); + const result = encodePortableBundleJson({ queries, dashboards: [], nowISO: '2026-07-17T00:00:00.000Z' }); + expect(!result.ok && result.diagnostics[0].code).toBe('limit-json-bytes'); + }); +}); diff --git a/tests/unit/portable-limits.test.ts b/tests/unit/portable-limits.test.ts new file mode 100644 index 00000000..8568112f --- /dev/null +++ b/tests/unit/portable-limits.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { PORTABLE_LIMITS } from '../../src/dashboard/model/portable-limits.js'; + +describe('PORTABLE_LIMITS', () => { + it('pins every #280 v1 limit verbatim', () => { + expect(PORTABLE_LIMITS).toEqual({ + maxDecodedJsonBytes: 10485760, + maxJsonDepth: 64, + maxQueries: 1000, + maxDashboards: 32, + maxTilesPerDashboard: 100, + maxFiltersPerDashboard: 32, + maxLayoutItemsPerDashboard: 100, + maxVariantsPerQuery: 32, + maxIdLength: 256, + maxNameLength: 512, + maxTitleLength: 512, + maxDescriptionLength: 16384, + maxSqlLength: 1048576, + maxSerializedQuerySpecBytes: 1048576, + maxSerializedLayoutConfigBytes: 262144, + maxSerializedFilterDefaultBytes: 65536, + }); + // Pinned decision (#283): the Phase-2 repository is IndexedDB-backed, so + // the decoded-JSON cap stays at 10 MiB exactly as specced in #280. + expect(PORTABLE_LIMITS.maxDecodedJsonBytes).toBe(10 * 1024 * 1024); + }); +}); diff --git a/tests/unit/presentation-resolver.test.ts b/tests/unit/presentation-resolver.test.ts new file mode 100644 index 00000000..3ea2cca5 --- /dev/null +++ b/tests/unit/presentation-resolver.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveDashboardPresentations, resolvePresentation, +} from '../../src/dashboard/model/presentation-resolver.js'; +import type { WorkspaceDiagnostic } from '../../src/dashboard/model/workspace-diagnostics.js'; + +const has = (diagnostics: WorkspaceDiagnostic[], code: string): boolean => diagnostics.some((d) => d.code === code); + +const basePanel = () => ({ cfg: { type: 'bar', x: 0, y: [1] }, fieldConfig: { defaults: { unit: 'x' } } }); +const makeQuery = (dashboard?: Record, panel: unknown = basePanel()) => ({ + id: 'q', sql: 'SELECT a,b', specVersion: 1, + spec: { name: 'q', panel, ...(dashboard ? { dashboard } : {}) }, +}); +const tileFor = (presentation?: Record, id: string | undefined = 't1') => ({ + ...(id === undefined ? {} : { id }), queryId: 'q', ...(presentation ? { presentation } : {}), +}); + +describe('resolvePresentation', () => { + it('returns the base panel when no variant or override applies', () => { + const result = resolvePresentation({ query: makeQuery(), tile: tileFor() }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.panel).toEqual(basePanel()); + }); + + it('applies a valid named variant patch', () => { + const query = makeQuery({ variants: { alt: { fieldConfig: { defaults: { unit: 'ms' } } } } }); + const result = resolvePresentation({ query, tile: tileFor({ variant: 'alt' }) }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.panel.fieldConfig).toEqual({ defaults: { unit: 'ms' } }); + }); + + it('applies the tile override after the variant (override wins)', () => { + const query = makeQuery({ variants: { alt: { fieldConfig: { defaults: { unit: 'ms' } } } } }); + const result = resolvePresentation({ + query, tile: tileFor({ variant: 'alt', override: { fieldConfig: { defaults: { unit: 'us', decimals: 2 } } } }), + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.panel.fieldConfig).toEqual({ defaults: { unit: 'us', decimals: 2 } }); + }); + + it('replaces arrays atomically rather than merging by index', () => { + const query = makeQuery(); + const result = resolvePresentation({ query, tile: tileFor({ override: { cfg: { y: [0] } } }) }); + expect(result.ok).toBe(true); + if (result.ok) expect((result.panel.cfg as { y: number[] }).y).toEqual([0]); + }); + + it('deletes an optional property when a patch member is null', () => { + const result = resolvePresentation({ query: makeQuery(), tile: tileFor({ override: { fieldConfig: null } }) }); + expect(result.ok).toBe(true); + if (result.ok) expect('fieldConfig' in result.panel).toBe(false); + }); + + it('fails final validation when a required property is deleted', () => { + const result = resolvePresentation({ query: makeQuery(), tile: tileFor({ override: { cfg: { x: null } } }) }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(has(result.diagnostics, 'schema-required')).toBe(true); + expect(result.diagnostics[0].resource).toBe('t1'); + } + }); + + it('fails when a variant or override changes the renderer type, including deleting it', () => { + const viaVariant = resolvePresentation({ + query: makeQuery({ variants: { alt: { cfg: { type: 'line' } } } }), tile: tileFor({ variant: 'alt' }), + }); + expect(viaVariant.ok).toBe(false); + const viaOverride = resolvePresentation({ query: makeQuery(), tile: tileFor({ override: { cfg: { type: 'pie' } } }) }); + expect(viaOverride.ok).toBe(false); + const viaDelete = resolvePresentation({ query: makeQuery(), tile: tileFor({ override: { cfg: { type: null } } }) }); + expect(viaDelete.ok).toBe(false); + if (!viaDelete.ok) expect(has(viaDelete.diagnostics, 'presentation-renderer-type-change')).toBe(true); + }); + + it('fails when the selected variant name does not exist — no silent fallback', () => { + const query = makeQuery({ defaultVariant: 'd', variants: { d: {} } }); + const result = resolvePresentation({ query, tile: { queryId: 'q', presentation: { variant: 'nope' } } }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(has(result.diagnostics, 'presentation-variant-missing')).toBe(true); + expect(result.diagnostics[0].resource).toBeUndefined(); // tile has no id + } + }); + + it('uses defaultVariant when no variant is selected, else the base panel', () => { + const withDefault = makeQuery({ defaultVariant: 'd', variants: { d: { fieldConfig: { defaults: { unit: 'DEF' } } } } }); + const applied = resolvePresentation({ query: withDefault, tile: tileFor() }); + expect(applied.ok && (applied.panel.fieldConfig as { defaults: { unit: string } }).defaults.unit).toBe('DEF'); + + const brokenDefault = makeQuery({ defaultVariant: 'gone', variants: { other: {} } }); + const fellBack = resolvePresentation({ query: brokenDefault, tile: tileFor() }); + expect(fellBack.ok).toBe(true); + if (fellBack.ok) expect(fellBack.panel).toEqual(basePanel()); + }); + + it('validates result-column roles only when result metadata is available', () => { + const query = makeQuery(); + const twoColumns = [{ name: 'a', type: 'String' }, { name: 'b', type: 'UInt32' }]; + const ok = resolvePresentation({ query, tile: tileFor(), resultColumns: twoColumns }); + expect(ok.ok).toBe(true); + // Only one column: measure index 1 is out of range → semantic failure. + const bad = resolvePresentation({ query, tile: tileFor(), resultColumns: [{ name: 'a', type: 'String' }] }); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(has(bad.diagnostics, 'presentation-resolved-invalid-roles')).toBe(true); + }); + + it('carries no resource on a structural failure for a tile without an id', () => { + const result = resolvePresentation({ query: makeQuery(), tile: { queryId: 'q', presentation: { override: { cfg: { x: null } } } } }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(has(result.diagnostics, 'schema-required')).toBe(true); + expect(result.diagnostics.every((d) => d.resource === undefined)).toBe(true); + } + }); + + it('skips the result-column role check when the resolved panel has no cfg', () => { + const result = resolvePresentation({ + query: makeQuery(undefined, {}), tile: tileFor(), resultColumns: [{ name: 'a', type: 'String' }], + }); + expect(result.ok).toBe(true); + }); + + it('resolves an empty base panel and allows a patch to introduce a renderer type', () => { + const emptyBase = resolvePresentation({ query: null, tile: null }); + expect(emptyBase.ok).toBe(true); + if (emptyBase.ok) expect(emptyBase.panel).toEqual({}); + const introduced = resolvePresentation({ + query: makeQuery(undefined, {}), tile: tileFor({ override: { cfg: { type: 'table' } } }), + }); + expect(introduced.ok).toBe(true); + }); +}); + +describe('resolveDashboardPresentations', () => { + const filterQuery = { id: 'f', sql: "SELECT ['a'] c", specVersion: 1, spec: { dashboard: { role: 'filter' } } }; + + it('returns nothing for a non-object dashboard or one without a tiles array', () => { + expect(resolveDashboardPresentations({ dashboard: null, queries: [] })).toEqual([]); + expect(resolveDashboardPresentations({ dashboard: {}, queries: [] })).toEqual([]); + }); + + it('reports the diagnostics of each invalid panel-tile presentation', () => { + const query = makeQuery({ variants: { alt: {} } }); + const dashboard = { tiles: [tileFor({ variant: 'nope' })] }; + const diagnostics = resolveDashboardPresentations({ dashboard, queries: [query], path: ['dashboard'] }); + expect(has(diagnostics, 'presentation-variant-missing')).toBe(true); + expect(diagnostics[0].path).toEqual(['dashboard', 'tiles', 0, 'presentation', 'variant']); + }); + + it('skips non-panel tiles, tiles without a queryId, and tiles whose query is missing', () => { + const dashboard = { + tiles: [ + 'not-object', + { id: 't1' }, // no queryId + { id: 't2', queryId: 'gone' }, // query missing + { id: 't3', queryId: 'f' }, // filter role + { queryId: 'q' }, // panel tile without id — resolves clean, exercises the id branch + ], + }; + const diagnostics = resolveDashboardPresentations({ dashboard, queries: [makeQuery(), filterQuery] }); + expect(diagnostics).toEqual([]); + }); + + it('applies per-tile result metadata when provided', () => { + const dashboard = { tiles: [tileFor()] }; + const diagnostics = resolveDashboardPresentations({ + dashboard, queries: [makeQuery()], resultColumnsByTileId: { t1: [{ name: 'a', type: 'String' }] }, + }); + expect(has(diagnostics, 'presentation-resolved-invalid-roles')).toBe(true); + }); +}); diff --git a/tests/unit/saved-query-mutation.test.ts b/tests/unit/saved-query-mutation.test.ts new file mode 100644 index 00000000..a9ee82f9 --- /dev/null +++ b/tests/unit/saved-query-mutation.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from 'vitest'; +import { + planSavedQueryMutation, suggestRepairs, +} from '../../src/dashboard/application/saved-query-mutation.js'; +import { jsonSchemaValidationService } from '../../src/core/library-codec.js'; +import { querySpecSchemaService } from '../../src/core/spec-schema.js'; +import type { SavedQueryV2, StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; +import type { WorkspaceDiagnostic } from '../../src/dashboard/model/workspace-diagnostics.js'; + +const panelQuery = (id: string, sql: string, dashboard?: Record): SavedQueryV2 => ({ + id, sql, specVersion: 1, + spec: { name: id, panel: { cfg: { type: 'bar', x: 0, y: [1] } }, ...(dashboard ? { dashboard } : {}) }, +} as SavedQueryV2); +const filterQuery = (id: string): SavedQueryV2 => ({ + id, sql: "SELECT ['a','b'] AS country", specVersion: 1, spec: { name: id, dashboard: { role: 'filter' } }, +} as SavedQueryV2); + +// A valid base workspace: a panel tile p1 (declares `country`), a filter flt +// sourced from f1 targeting that tile, and a spare panel p2 (also declaring +// `country`, so a remap onto it stays valid). +const baseWorkspace = (): StoredWorkspaceV1 => ({ + storageVersion: 1, id: 'ws', name: 'WS', + queries: [ + panelQuery('p1', 'SELECT a,b WHERE c={country:String}'), + panelQuery('p2', 'SELECT a,b WHERE c={country:String}'), + filterQuery('f1'), + ], + dashboard: { + documentVersion: 1, id: 'dash', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', targets: ['t1'] }], + tiles: [{ id: 't1', queryId: 'p1' }], + }, +} as StoredWorkspaceV1); + +const codes = (d: WorkspaceDiagnostic[]): string[] => d.map((x) => x.code); + +describe('planSavedQueryMutation — rejection without repair', () => { + it('accepts an equivalent replacement that keeps the workspace valid', () => { + const plan = planSavedQueryMutation(baseWorkspace(), + { type: 'replace-query', queryId: 'p1', query: panelQuery('p1', 'SELECT a,b WHERE c={country:String}') }); + expect(plan.ok).toBe(true); + expect(plan.candidate).not.toBeNull(); + expect(plan.repairs).toEqual([]); + }); + + it('rejects deleting a referenced query and offers tile repairs', () => { + const plan = planSavedQueryMutation(baseWorkspace(), { type: 'delete-query', queryId: 'p1' }); + expect(plan.ok).toBe(false); + expect(codes(plan.diagnostics)).toContain('dashboard-tile-query-missing'); + expect(plan.repairs).toEqual(expect.arrayContaining(['remove-affected-tiles', 'switch-variant', 'remap-query'])); + }); + + it('rejects a role change that makes a tile incompatible', () => { + const plan = planSavedQueryMutation(baseWorkspace(), + { type: 'replace-query', queryId: 'p1', query: filterQuery('p1') }); + expect(plan.ok).toBe(false); + expect(codes(plan.diagnostics)).toContain('dashboard-tile-role-incompatible'); + }); + + it('rejects a filter source whose role changes', () => { + const plan = planSavedQueryMutation(baseWorkspace(), + { type: 'replace-query', queryId: 'f1', query: panelQuery('f1', 'SELECT 1') }); + expect(plan.ok).toBe(false); + expect(codes(plan.diagnostics)).toContain('filter-source-role'); + expect(plan.repairs).toContain('remove-affected-filters'); + }); + + it('repairs a filter-source role change by removing the affected filter', () => { + const plan = planSavedQueryMutation(baseWorkspace(), + { type: 'replace-query', queryId: 'f1', query: panelQuery('f1', 'SELECT 1') }, + { type: 'remove-affected-filters' }); + expect(plan.ok).toBe(true); + expect(plan.candidate!.dashboard!.filters).toEqual([]); // flt sourced from f1 removed + }); + + it('remaps a filter source reference onto another filter query', () => { + const workspace: StoredWorkspaceV1 = { + storageVersion: 1, id: 'ws', name: 'WS', + queries: [panelQuery('p1', 'SELECT a,b WHERE c={country:String}'), filterQuery('f1'), filterQuery('f2')], + dashboard: { + documentVersion: 1, id: 'dash', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', targets: ['t1'] }], + tiles: [{ id: 't1', queryId: 'p1' }], + }, + } as StoredWorkspaceV1; + const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'f1' }, { type: 'remap-query', to: 'f2' }); + expect(plan.ok).toBe(true); + expect(plan.candidate!.dashboard!.filters[0].sourceQueryId).toBe('f2'); + }); +}); + +describe('planSavedQueryMutation — atomic repair', () => { + it('removes affected tiles (and prunes their placements and filter targets)', () => { + const plan = planSavedQueryMutation(baseWorkspace(), + { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected-tiles' }); + expect(plan.ok).toBe(true); + const dashboard = plan.candidate!.dashboard!; + expect(dashboard.tiles).toEqual([]); + expect(dashboard.layout.items).toEqual({}); // orphan placement pruned + expect(dashboard.filters[0].targets).toEqual([]); // target reference pruned + }); + + it('removes affected filters when a parameter change invalidates a target', () => { + // p1 no longer declares `country`; the filter targeting its tile breaks. + const plan = planSavedQueryMutation(baseWorkspace(), + { type: 'replace-query', queryId: 'p1', query: panelQuery('p1', 'SELECT a,b') }); + expect(plan.ok).toBe(false); + expect(codes(plan.diagnostics)).toContain('filter-parameter-undeclared'); + + const repaired = planSavedQueryMutation(baseWorkspace(), + { type: 'replace-query', queryId: 'p1', query: panelQuery('p1', 'SELECT a,b') }, + { type: 'remove-affected-filters' }); + expect(repaired.ok).toBe(true); + expect(repaired.candidate!.dashboard!.filters).toEqual([]); + }); + + it('switches an affected tile to another valid variant', () => { + const workspace: StoredWorkspaceV1 = { + storageVersion: 1, id: 'ws', name: 'WS', + queries: [panelQuery('p1', 'SELECT a,b', { variants: { alt: {}, other: {} } })], + dashboard: { + documentVersion: 1, id: 'dash', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + filters: [], tiles: [{ id: 't1', queryId: 'p1', presentation: { variant: 'alt' } }], + }, + } as StoredWorkspaceV1; + const deletesAlt = { type: 'replace-query' as const, queryId: 'p1', query: panelQuery('p1', 'SELECT a,b', { variants: { other: {} } }) }; + + const rejected = planSavedQueryMutation(workspace, deletesAlt); + expect(rejected.ok).toBe(false); + expect(codes(rejected.diagnostics)).toContain('dashboard-variant-missing'); + + const repaired = planSavedQueryMutation(workspace, deletesAlt, { type: 'switch-variant', tileVariants: { t1: 'other' } }); + expect(repaired.ok).toBe(true); + }); + + it('remaps references to another query (delete + remap as one candidate)', () => { + const plan = planSavedQueryMutation(baseWorkspace(), + { type: 'delete-query', queryId: 'p1' }, { type: 'remap-query', to: 'p2' }); + expect(plan.ok).toBe(true); + const dashboard = plan.candidate!.dashboard!; + expect(dashboard.tiles[0].queryId).toBe('p2'); + }); + + it('supports remove-affected (tiles and filters together)', () => { + const plan = planSavedQueryMutation(baseWorkspace(), + { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected' }, + { validationService: jsonSchemaValidationService, schemaService: querySpecSchemaService }); + expect(plan.ok).toBe(true); + const dashboard = plan.candidate!.dashboard!; + expect(dashboard.tiles).toEqual([]); + // The filter is sourced from f1 (not p1), so it survives with its now-empty + // target list — remove-affected removed the affected tile and its target ref. + expect(dashboard.filters[0].targets).toEqual([]); + }); +}); + +describe('planSavedQueryMutation — repairs skip unaffected and target-less entries', () => { + const multiTile = (): StoredWorkspaceV1 => ({ + storageVersion: 1, id: 'ws', name: 'WS', + queries: [panelQuery('p1', 'SELECT a,b', { variants: { alt: {}, other: {} } }), panelQuery('p2', 'SELECT a,b')], + dashboard: { + documentVersion: 1, id: 'dash', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {}, t2: {}, t3: {}, t4: {} } }, + filters: [{ id: 'flt', parameter: 'x' }], // no source, no targets + tiles: [ + { id: 't1', queryId: 'p1', presentation: { variant: 'alt' } }, // has a presentation, gets switched + { id: 't2', queryId: 'p2' }, // unaffected by p1 mutations + { id: 't3', queryId: 'p1' }, // affected, no presentation, gets switched (empty-presentation branch) + { id: 't4', queryId: 'p1' }, // affected but unmapped — left untouched + ], + }, + } as StoredWorkspaceV1); + + it('removes only affected tiles and leaves a target-less filter intact', () => { + const plan = planSavedQueryMutation(multiTile(), { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected-tiles' }); + expect(plan.ok).toBe(true); + const dashboard = plan.candidate!.dashboard!; + expect(dashboard.tiles.map((t) => t.id)).toEqual(['t2']); + expect(dashboard.filters).toHaveLength(1); + }); + + it('switches only the mapped affected tile and skips unaffected ones', () => { + const dropsAlt = { type: 'replace-query' as const, queryId: 'p1', query: panelQuery('p1', 'SELECT a,b', { variants: { other: {} } }) }; + // t1 already has a presentation; t3 has none — switching it exercises the + // no-existing-presentation branch. t2 is unaffected and is skipped. + const plan = planSavedQueryMutation(multiTile(), dropsAlt, { type: 'switch-variant', tileVariants: { t1: 'other', t3: 'other' } }); + expect(plan.ok).toBe(true); + const tiles = plan.candidate!.dashboard!.tiles; + expect(tiles.find((t) => t.id === 't3')!.presentation).toEqual({ variant: 'other' }); + }); + + it('remaps only affected tiles and leaves a source-less filter intact', () => { + const workspace: StoredWorkspaceV1 = { + storageVersion: 1, id: 'ws', name: 'WS', + queries: [panelQuery('p1', 'SELECT a,b'), panelQuery('p2', 'SELECT a,b')], + dashboard: { + documentVersion: 1, id: 'dash', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {}, t2: {} } }, + filters: [{ id: 'flt', parameter: 'x' }], // no source + tiles: [{ id: 't1', queryId: 'p1' }, { id: 't2', queryId: 'p2' }], + }, + } as StoredWorkspaceV1; + const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'p1' }, { type: 'remap-query', to: 'p2' }); + expect(plan.ok).toBe(true); + const dashboard = plan.candidate!.dashboard!; + expect(dashboard.tiles.map((t) => t.queryId)).toEqual(['p2', 'p2']); + }); + + it('tolerates malformed tiles and filters while applying a repair', () => { + const malformed = { + storageVersion: 1, id: 'ws', name: 'WS', queries: [panelQuery('p1', 'SELECT a,b')], + dashboard: { + documentVersion: 1, id: 'dash', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + filters: ['bad', { id: 'flt', parameter: 'x' }], tiles: ['bad', { id: 't1', queryId: 'p1' }], + }, + } as unknown as StoredWorkspaceV1; + const plan = planSavedQueryMutation(malformed, { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected' }); + // The malformed entries make the candidate invalid, but the repair helpers + // ran over them without throwing. + expect(plan.ok).toBe(false); + }); +}); + +describe('planSavedQueryMutation — no dashboard, and suggestRepairs', () => { + it('always accepts a mutation when the workspace has no dashboard', () => { + const workspace = { ...baseWorkspace(), dashboard: null } as StoredWorkspaceV1; + const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'p1' }); + expect(plan.ok).toBe(true); + expect(plan.candidate!.dashboard).toBeNull(); + }); + + it('maps diagnostic scopes to repair kinds', () => { + const repairs = suggestRepairs([ + { path: [], severity: 'error', code: 'x', message: '' }, + { path: ['dashboard', 'filters', 0], severity: 'error', code: 'y', message: '' }, + { path: ['dashboard', 'tiles', 0], severity: 'error', code: 'z', message: '' }, + ]); + expect(repairs).toEqual(['remove-affected-filters', 'remove-affected-tiles', 'switch-variant', 'remap-query']); + }); +}); diff --git a/tests/unit/saved-query.test.ts b/tests/unit/saved-query.test.ts index 83589224..472aa11d 100644 --- a/tests/unit/saved-query.test.ts +++ b/tests/unit/saved-query.test.ts @@ -5,15 +5,15 @@ import { upgradeV1Query, cloneV2Query, upgradeSavedQuery, queryContentKey, isPlainObject, } from '../../src/core/saved-query.js'; import type { QueryRoot } from '../../src/core/saved-query.js'; -import type { Dashboard, QuerySpecV1 } from '../../src/generated/json-schema.types.js'; +import type { QueryDashboardPresentationV1, QuerySpecV1 } from '../../src/generated/json-schema.types.js'; const v2 = (spec: QuerySpecV1 = {}): QueryRoot => ({ id: 'q1', sql: 'SELECT 1', specVersion: 1, spec }); describe('saved-query model', () => { it('patches dashboard fields without aliases and supports field/object removal', () => { - // A known runtime shape (Dashboard's own fields are all optional/index-signature + // A known runtime shape (QueryDashboardPresentationV1's own fields are all optional/index-signature // typed) — a single-level cast pins the fixture's actual `future` shape. - interface DashboardFixture extends Dashboard { + interface DashboardFixture extends QueryDashboardPresentationV1 { future: { values: number[] }; } const query = v2({ dashboard: { role: 'filter', future: { values: [1] } }, panel: { cfg: { type: 'line' } } }); @@ -49,7 +49,7 @@ describe('saved-query model', () => { it('reads known fields with safe defaults without stripping extensions', () => { const panel = { cfg: { type: 'table' }, links: [{ url: '/x' }] }; - const dashboard: Dashboard = { role: 'panel', future: { x: 1 } }; + const dashboard: QueryDashboardPresentationV1 = { role: 'panel', future: { x: 1 } }; const q = v2({ name: 'Q', description: 'D', favorite: true, view: 'panel', panel, dashboard }); expect(queryName(q)).toBe('Q'); expect(queryDescription(q)).toBe('D'); diff --git a/tests/unit/schema-build.test.js b/tests/unit/schema-build.test.js index 9d803f06..2dfc68a8 100644 --- a/tests/unit/schema-build.test.js +++ b/tests/unit/schema-build.test.js @@ -19,13 +19,20 @@ describe('multi-schema build', () => { 'schemas/query-spec-v1.schema.json', 'schemas/saved-query-v2.schema.json', 'schemas/library-v2.schema.json', + 'schemas/dashboard-layout-flow-v1.schema.json', + 'schemas/dashboard-v1.schema.json', + 'schemas/stored-workspace-v1.schema.json', + 'schemas/portable-bundle-v1.schema.json', ]); + const KINDS = [ + ['query-spec', 1], ['saved-query', 2], ['library', 2], + ['dashboard-layout-flow', 1], ['dashboard', 1], ['stored-workspace', 1], ['portable-bundle', 1], + ]; const records = await loadRecords(); expect(records.map(({ schema }) => [schema['x-altinity-kind'], schema['x-altinity-version']])) - .toEqual([['query-spec', 1], ['saved-query', 2], ['library', 2]]); + .toEqual(KINDS); const catalog = JSON.parse(readFileSync(resolve(root, 'schemas/generated/schema-catalog.json'), 'utf8')); - expect(catalog.schemas.map(({ kind, version }) => [kind, version])) - .toEqual([['query-spec', 1], ['saved-query', 2], ['library', 2]]); + expect(catalog.schemas.map(({ kind, version }) => [kind, version])).toEqual(KINDS); expect(catalog.schemas[2].bundlePath).toBe('library-v2.bundle.schema.json'); }); @@ -36,6 +43,10 @@ describe('multi-schema build', () => { 'https://altinity.com/schemas/altinity-sql-browser/query-spec-v1.schema.json', 'https://altinity.com/schemas/altinity-sql-browser/saved-query-v2.schema.json', 'https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json', + 'https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json', + 'https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json', + 'https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json', + 'https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json', ]); const ajv = new Ajv2020({ strict: true, allErrors: true }); addFormats(ajv); @@ -84,7 +95,10 @@ describe('multi-schema build', () => { }); it('emits the committed TypeScript artifact with pinned names, openness, and closedness', async () => { - expect(SCHEMA_MANIFEST.map((entry) => entry.typeExport)).toEqual(['QuerySpecV1', 'SavedQueryV2', 'LibraryV2']); + expect(SCHEMA_MANIFEST.map((entry) => entry.typeExport)).toEqual([ + 'QuerySpecV1', 'SavedQueryV2', 'LibraryV2', + 'FlowLayoutV1', 'DashboardDocumentV1', 'StoredWorkspaceV1', 'PortableBundleV1', + ]); const sources = await generatedSources(); const types = Object.entries(sources).find(([path]) => path.endsWith('json-schema.types.ts'))[1]; expect(types.startsWith('// Generated by build/compile-json-schemas.mjs. Do not edit.\n')).toBe(true); @@ -105,6 +119,18 @@ describe('multi-schema build', () => { // The composition-only styledChartCfg helper never becomes a public type. expect(types).not.toContain('StyledChartCfg'); expect(types).toContain('export interface BarPanelCfg extends ChartCfg {'); + // Dashboard v1 contracts (#283): pinned roots, the fallback alias, and + // the closed flow@1 placement. + expect(types).toContain('export interface DashboardDocumentV1'); + expect(types).toContain('export interface StoredWorkspaceV1'); + expect(types).toContain('export interface PortableBundleV1'); + expect(types).toContain('export type DashboardLayoutFallbackV1 = FlowLayoutV1;'); + expect(types).toContain('export type QueryPresentationPatchV1 = Record;'); + expect(block('FlowTilePlacementV1')).not.toContain('[k: string]'); + expect(block('DashboardDocumentV1')).not.toContain('[k: string]'); + expect(block('PortableBundleV1')).toContain('dashboards: DashboardDocumentV1[];'); + expect(block('StoredWorkspaceV1')).toContain('dashboard: DashboardDocumentV1 | null;'); + expect(block('QueryDashboardPresentationV1')).toContain('variants?: Record;'); }); it('validates manifest typeExport pins', async () => { diff --git a/tests/unit/spec-completion.test.ts b/tests/unit/spec-completion.test.ts index 3e7ac6ad..a99a69f3 100644 --- a/tests/unit/spec-completion.test.ts +++ b/tests/unit/spec-completion.test.ts @@ -27,6 +27,18 @@ describe('pure Spec completion', () => { .toEqual(['description', 'favorite', 'view', 'dashboard']); }); + it('picks up the QueryDashboardPresentationV1 fields under dashboard (#283)', () => { + // The Spec editor autocomplete (#221) derives from the schema, so the new + // additive dashboard presentation fields must surface without extra wiring. + expect(complete({ rootValue: { dashboard: {} }, path: ['dashboard'] }).map((item) => item.label)) + .toEqual(['role', 'defaultVariant', 'variants', 'sizeHints']); + expect(complete({ + rootValue: { dashboard: { role: '' } }, path: ['dashboard', 'role'], positionKind: 'property-value', + }).map((item) => item.insert)).toEqual(['"panel"', '"filter"', '"setup"']); + expect(complete({ rootValue: { dashboard: { sizeHints: {} } }, path: ['dashboard', 'sizeHints'] }).map((item) => item.label)) + .toEqual(['preferred', 'minimum']); + }); + it('keeps unresolved discriminator keys quiet and selects branch-specific keys from siblings', () => { expect(complete({ rootValue: { panel: { cfg: {} } }, path: ['panel', 'cfg'], diff --git a/tests/unit/stored-workspace.test.ts b/tests/unit/stored-workspace.test.ts new file mode 100644 index 00000000..5c54ce86 --- /dev/null +++ b/tests/unit/stored-workspace.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { + CURRENT_STORED_WORKSPACE_VERSION, STORED_WORKSPACE_V1_SCHEMA_ID, + decodeStoredWorkspaceJson, encodeStoredWorkspaceJson, validateStoredWorkspaceDocument, +} from '../../src/workspace/stored-workspace.js'; +import type { WorkspaceDiagnostic } from '../../src/dashboard/model/workspace-diagnostics.js'; + +const codes = (d: WorkspaceDiagnostic[]): string[] => d.map((x) => x.code); +const has = (d: WorkspaceDiagnostic[], code: string): boolean => d.some((x) => x.code === code); + +const panelQuery = (id: string) => ({ id, sql: 'SELECT 1', specVersion: 1, spec: { name: id, panel: { cfg: { type: 'bar', x: 0, y: [1] } } } }); +const dashboardDoc = (over: Record = {}) => ({ + documentVersion: 1, id: 'd1', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [], ...over, +}); +const workspace = (over: Record = {}) => ({ + storageVersion: 1, id: 'w1', name: 'W', queries: [], dashboard: null, ...over, +}); + +describe('validateStoredWorkspaceDocument', () => { + it('accepts an empty workspace, a query-only workspace, and one with a resolvable dashboard', () => { + expect(validateStoredWorkspaceDocument(workspace())).toEqual([]); + expect(validateStoredWorkspaceDocument(workspace({ queries: [panelQuery('p1')] }))).toEqual([]); + const withDashboard = workspace({ + queries: [panelQuery('p1')], + dashboard: dashboardDoc({ tiles: [{ id: 't1', queryId: 'p1' }], layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } } }), + }); + expect(validateStoredWorkspaceDocument(withDashboard)).toEqual([]); + }); + + it('fails closed on identity problems', () => { + expect(codes(validateStoredWorkspaceDocument(null))).toEqual(['workspace-invalid-root']); + expect(codes(validateStoredWorkspaceDocument({}))).toEqual(['workspace-version-missing']); + expect(codes(validateStoredWorkspaceDocument({ storageVersion: 1.5 }))).toEqual(['workspace-version-invalid']); + expect(codes(validateStoredWorkspaceDocument({ storageVersion: 2 }))).toEqual(['workspace-version-unsupported']); + }); + + it('reports structural schema errors, e.g. a missing required field', () => { + const d = validateStoredWorkspaceDocument({ storageVersion: 1, id: 'w', name: 'W', queries: [] }); + expect(has(d, 'schema-required')).toBe(true); // dashboard required (may be null) + }); + + it('fails closed on unknown query and dashboard versions, suppressing schema noise', () => { + const d = validateStoredWorkspaceDocument(workspace({ + queries: [{ id: 'q', sql: 'x', specVersion: 9, spec: {} }], + dashboard: dashboardDoc({ documentVersion: 4 }), + })); + expect(has(d, 'spec-version-unsupported')).toBe(true); + expect(has(d, 'dashboard-version-unsupported')).toBe(true); + expect(d.find((x) => x.code === 'dashboard-version-unsupported')!.path[0]).toBe('dashboard'); + }); + + it('runs whole-workspace cross-resource semantics when structurally valid', () => { + const d = validateStoredWorkspaceDocument(workspace({ queries: [panelQuery('dup'), panelQuery('dup')] })); + expect(has(d, 'workspace-duplicate-query-id')).toBe(true); + // Dashboard-side semantics run against the workspace queries. + const bad = validateStoredWorkspaceDocument(workspace({ + dashboard: dashboardDoc({ tiles: [{ id: 't1', queryId: 'gone' }], layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } } }), + })); + expect(has(bad, 'dashboard-tile-query-missing')).toBe(true); + }); +}); + +describe('decodeStoredWorkspaceJson', () => { + it('parses, validates, and returns the typed value', () => { + const result = decodeStoredWorkspaceJson(JSON.stringify(workspace())); + expect(result.ok).toBe(true); + expect(result.ok && result.value.storageVersion).toBe(CURRENT_STORED_WORKSPACE_VERSION); + }); + + it('propagates codec-guard and validation failures', () => { + expect(decodeStoredWorkspaceJson('{bad').ok).toBe(false); + const invalid = decodeStoredWorkspaceJson(JSON.stringify({ storageVersion: 2 })); + expect(!invalid.ok && invalid.diagnostics[0].code).toBe('workspace-version-unsupported'); + }); +}); + +describe('encodeStoredWorkspaceJson', () => { + it('validates and canonically encodes with deterministic key order', () => { + const result = encodeStoredWorkspaceJson(workspace({ queries: [panelQuery('p1')] })); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.indexOf('"storageVersion"')).toBeLessThan(result.value.indexOf('"id"')); + expect(result.value.indexOf('"queries"')).toBeLessThan(result.value.indexOf('"dashboard"')); + // Reference schema id is exported for callers/Phase 2. + expect(STORED_WORKSPACE_V1_SCHEMA_ID).toContain('stored-workspace-v1'); + }); + + it('rejects an invalid workspace before encoding', () => { + const result = encodeStoredWorkspaceJson({ storageVersion: 2 }); + expect(!result.ok && result.diagnostics[0].code).toBe('workspace-version-unsupported'); + }); + + it('rejects an encoded workspace larger than the decoded-JSON byte cap', () => { + // An arbitrary extension field (query-spec is open) inflates each spec to + // just under the 1 MiB per-spec cap; eleven sum past the 10 MiB document cap. + const chunk = 'x'.repeat(1_000_000); + const queries = Array.from({ length: 11 }, (_, i) => ({ + id: `q${i}`, sql: 'SELECT 1', specVersion: 1, spec: { name: `q${i}`, ext: chunk }, + })); + const result = encodeStoredWorkspaceJson(workspace({ queries })); + expect(!result.ok && result.diagnostics[0].code).toBe('limit-json-bytes'); + }); +}); diff --git a/tests/unit/workspace-diagnostics.test.ts b/tests/unit/workspace-diagnostics.test.ts new file mode 100644 index 00000000..49f3766e --- /dev/null +++ b/tests/unit/workspace-diagnostics.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + comparePathSegments, comparePaths, compareDiagnostics, diagnostic, sortDiagnostics, +} from '../../src/dashboard/model/workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from '../../src/dashboard/model/workspace-diagnostics.js'; + +const d = (over: Partial = {}): WorkspaceDiagnostic => ({ + path: [], severity: 'error', code: 'c', message: 'm', ...over, +}); + +describe('diagnostic factory', () => { + it('builds error diagnostics with and without a resource id', () => { + expect(diagnostic(['a', 0], 'code-x', 'boom')).toEqual({ + path: ['a', 0], severity: 'error', code: 'code-x', message: 'boom', + }); + expect(diagnostic([], 'code-x', 'boom', 'q1')).toEqual({ + path: [], severity: 'error', code: 'code-x', message: 'boom', resource: 'q1', + }); + }); +}); + +describe('numeric-aware path comparison', () => { + it('compares numeric segments numerically, before string segments', () => { + expect(comparePathSegments(2, 10)).toBeLessThan(0); + expect(comparePathSegments(10, 2)).toBeGreaterThan(0); + expect(comparePathSegments(2, 2)).toBe(0); + expect(comparePathSegments('2', '10')).toBeLessThan(0); // digit strings are numeric + expect(comparePathSegments(3, 'alpha')).toBeLessThan(0); + expect(comparePathSegments('alpha', 3)).toBeGreaterThan(0); + expect(comparePathSegments('alpha', 'beta')).toBeLessThan(0); + expect(comparePathSegments('beta', 'alpha')).toBeGreaterThan(0); + expect(comparePathSegments('alpha', 'alpha')).toBe(0); + }); + + it('compares full paths segment-wise with prefixes first', () => { + expect(comparePaths(['tiles'], ['tiles', 0])).toBeLessThan(0); + expect(comparePaths(['tiles', 2], ['tiles', 10])).toBeLessThan(0); + expect(comparePaths(['tiles', 10], ['tiles', 2, 'id'])).toBeGreaterThan(0); + expect(comparePaths([], [])).toBe(0); + }); +}); + +describe('deterministic diagnostic sorter', () => { + it('orders by severity, numeric-aware path, code, resource id, then message', () => { + const sorted = sortDiagnostics([ + d({ severity: 'information', message: 'info' }), + d({ severity: 'warning', message: 'warn' }), + d({ path: ['queries', 10], message: 'ten' }), + d({ path: ['queries', 2], message: 'two' }), + d({ path: ['queries', 2], code: 'a-code', message: 'two-a' }), + d({ path: ['queries', 2], code: 'a-code', resource: 'r2', message: 'r2' }), + d({ path: ['queries', 2], code: 'a-code', resource: 'r1', message: 'r1' }), + d({ path: ['queries', 2], code: 'a-code', resource: 'r1', message: 'a-first' }), + d({ severity: 'error', message: 'root' }), + ]); + expect(sorted.map((item) => item.message)).toEqual([ + 'root', 'two-a', 'a-first', 'r1', 'r2', 'two', 'ten', 'warn', 'info', + ]); + }); + + it('is deterministic for shuffled equivalent inputs and never mutates its input', () => { + const items = [ + d({ path: ['b'] }), d({ path: [1] }), d({ path: ['a', 2] }), d({ path: ['a', '10'] }), + d({ severity: 'warning' }), d({ severity: 'information' }), + ]; + const reversed = [...items].reverse(); + expect(sortDiagnostics(items)).toEqual(sortDiagnostics(reversed)); + expect(sortDiagnostics(items).map((item) => item.path)).toEqual([ + [1], ['a', 2], ['a', '10'], ['b'], [], [], + ]); + expect(items[0].path).toEqual(['b']); // input untouched + }); + + it('breaks resource ties with a missing resource sorting first', () => { + expect(compareDiagnostics(d({}), d({ resource: 'x' }))).toBeLessThan(0); + expect(compareDiagnostics(d({ resource: 'x' }), d({}))).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/workspace-operations.test.ts b/tests/unit/workspace-operations.test.ts new file mode 100644 index 00000000..51a69440 --- /dev/null +++ b/tests/unit/workspace-operations.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { + CURRENT_STORAGE_VERSION, DEFAULT_WORKSPACE_NAME, + createNewWorkspace, generateWorkspaceId, importQueries, + renameWorkspace, replaceWorkspaceContents, +} from '../../src/workspace/workspace-operations.js'; +import type { SavedQueryV2, StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; + +const query = (id: string, favorite = false): SavedQueryV2 => ({ + id, sql: 'SELECT 1', specVersion: 1, spec: { name: id, favorite }, +}); +const dashboard = (): NonNullable => ({ + documentVersion: 1, id: 'd1', title: 'Dash', revision: 3, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], +}); +const base = (over: Partial = {}): StoredWorkspaceV1 => ({ + storageVersion: 1, id: 'w1', name: 'Workspace', queries: [query('a')], dashboard: dashboard(), ...over, +}); + +// A deterministic counter ID generator for the tests. +const counter = () => { + let n = 0; + return () => `id-${++n}`; +}; + +describe('generateWorkspaceId', () => { + it('mints a fresh ID on every call — two same-named imports get distinct IDs', () => { + const genId = counter(); + const first = createNewWorkspace(genId, 'Sales'); + const second = createNewWorkspace(genId, 'Sales'); + expect(first.id).not.toBe(second.id); + expect(generateWorkspaceId(genId)).toBe('id-3'); + }); +}); + +describe('renameWorkspace', () => { + it('changes only the workspace name and never renames the Dashboard', () => { + const ws = base(); + const renamed = renameWorkspace(ws, 'New name'); + expect(renamed.name).toBe('New name'); + expect(renamed.dashboard).toBe(ws.dashboard); // same Dashboard, title unchanged + expect(renamed.dashboard?.title).toBe('Dash'); + expect(renamed.queries).toEqual(ws.queries); + }); + + it('falls back to the default name for a blank/non-string name', () => { + expect(renameWorkspace(base(), ' ').name).toBe(DEFAULT_WORKSPACE_NAME); + expect(renameWorkspace(base(), 42 as unknown).name).toBe(DEFAULT_WORKSPACE_NAME); + }); +}); + +describe('createNewWorkspace', () => { + it('builds a fresh empty workspace with a new ID, no queries, and no Dashboard', () => { + const ws = createNewWorkspace(counter(), 'Fresh'); + expect(ws).toEqual({ + storageVersion: CURRENT_STORAGE_VERSION, id: 'id-1', name: 'Fresh', queries: [], dashboard: null, + }); + }); + + it('defaults the name when omitted', () => { + expect(createNewWorkspace(counter()).name).toBe(DEFAULT_WORKSPACE_NAME); + }); +}); + +describe('importQueries', () => { + it('replaces the query collection only and leaves the Dashboard byte-for-byte unchanged', () => { + const ws = base(); + const incoming = [query('x', true), query('y', true)]; + const next = importQueries(ws, incoming); + expect(next.queries.map((q) => q.id)).toEqual(['x', 'y']); + // imported favorite flags do NOT add tiles — the Dashboard is untouched. + expect(next.dashboard).toBe(ws.dashboard); + expect(next.dashboard?.tiles).toEqual([]); + expect(next.id).toBe('w1'); + // A fresh array (input not aliased). + expect(next.queries).not.toBe(incoming); + }); +}); + +describe('replaceWorkspaceContents', () => { + it('replaces queries and Dashboard atomically while preserving identity', () => { + const ws = base(); + const newDash = { ...dashboard(), id: 'd2', title: 'Imported' }; + const next = replaceWorkspaceContents(ws, { queries: [query('z')], dashboard: newDash }); + expect(next.id).toBe('w1'); // identity preserved + expect(next.name).toBe('Workspace'); + expect(next.queries.map((q) => q.id)).toEqual(['z']); + expect(next.dashboard).toEqual(newDash); + }); + + it('can clear the Dashboard as part of an atomic replacement', () => { + expect(replaceWorkspaceContents(base(), { queries: [], dashboard: null }).dashboard).toBeNull(); + }); +}); diff --git a/tests/unit/workspace-repository.test.ts b/tests/unit/workspace-repository.test.ts new file mode 100644 index 00000000..e877a345 --- /dev/null +++ b/tests/unit/workspace-repository.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createWorkspaceRepository, +} from '../../src/workspace/workspace-repository.js'; +import type { WorkspaceStore } from '../../src/workspace/workspace-store.types.js'; +import { encodeStoredWorkspaceJson } from '../../src/workspace/stored-workspace.js'; +import { jsonSchemaValidationService } from '../../src/core/library-codec.js'; +import type { StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; + +// ── An in-memory fake for the injected IndexedDB seam ──────────────────────── +// A single-record store, exactly the WorkspaceStore contract, plus test hooks +// (the persisted text, every write, and a write-failure switch) so a commit's +// atomicity and last-write-wins behavior are directly observable. +function memStore(initial: string | null = null) { + let value = initial; + const writes: string[] = []; + let failWrite = false; + const store: WorkspaceStore & { + readonly value: string | null; readonly writes: string[]; setFailWrite(f: boolean): void; + } = { + read: async () => value, + write: async (text: string) => { + if (failWrite) throw new Error('quota exceeded'); + writes.push(text); + value = text; + }, + clear: async () => { value = null; }, + get value() { return value; }, + get writes() { return writes; }, + setFailWrite(f: boolean) { failWrite = f; }, + }; + return store; +} + +const panelQuery = (id: string): StoredWorkspaceV1['queries'][number] => ({ + id, sql: 'SELECT 1', specVersion: 1, + spec: { name: id, favorite: true, panel: { cfg: { type: 'bar', x: 0, y: [1] } } }, +}); +const workspace = (over: Partial = {}): StoredWorkspaceV1 => ({ + storageVersion: 1, id: 'w1', name: 'W', queries: [], dashboard: null, ...over, +}); +const withDashboard = (over: Record = {}): StoredWorkspaceV1 => workspace({ + queries: [panelQuery('p1')], + dashboard: { + documentVersion: 1, id: 'd1', title: 'D', revision: 7, + layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + filters: [], tiles: [{ id: 't1', queryId: 'p1' }], + }, + ...over, +}); + +describe('createWorkspaceRepository.loadCurrent', () => { + it('returns null when no aggregate record exists', async () => { + const repo = createWorkspaceRepository({ store: memStore(null) }); + expect(await repo.loadCurrent()).toBeNull(); + }); + + it('decodes and returns a valid stored aggregate', async () => { + const ws = withDashboard(); + const encoded = encodeStoredWorkspaceJson(ws); + if (!encoded.ok) throw new Error('fixture should encode'); + const repo = createWorkspaceRepository({ store: memStore(encoded.value) }); + expect(await repo.loadCurrent()).toEqual(ws); + }); + + it('reads a present-but-invalid record as null (never returns a corrupt aggregate)', async () => { + const repo = createWorkspaceRepository({ store: memStore('{"storageVersion":2}') }); + expect(await repo.loadCurrent()).toBeNull(); + }); +}); + +describe('createWorkspaceRepository.commit', () => { + it('validates the whole candidate BEFORE writing; an invalid one is never persisted', async () => { + const store = memStore(null); + const repo = createWorkspaceRepository({ store }); + // A tile referencing a missing query fails whole-workspace semantics. + const bad = workspace({ + dashboard: { + documentVersion: 1, id: 'd', title: 'D', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + filters: [], tiles: [{ id: 't1', queryId: 'gone' }], + }, + }); + const result = await repo.commit(bad); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('unreachable'); + expect(result.diagnostics.some((d) => d.code === 'dashboard-tile-query-missing')).toBe(true); + expect(store.value).toBeNull(); // nothing written + expect(store.writes).toHaveLength(0); + }); + + it('atomically persists a valid candidate and publishes the committed state + revision', async () => { + const store = memStore(null); + const repo = createWorkspaceRepository({ store }); + const ws = withDashboard(); + const result = await repo.commit(ws); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('unreachable'); + expect(result.workspace).toEqual(ws); + expect(result.dashboardRevision).toBe(7); + // Persisted text is exactly the canonical encoding (one atomic write). + const encoded = encodeStoredWorkspaceJson(ws); + expect(encoded.ok && store.value).toBe(encoded.ok ? encoded.value : ''); + expect(store.writes).toHaveLength(1); + }); + + it('reports a null dashboardRevision when the workspace has no Dashboard', async () => { + const repo = createWorkspaceRepository({ store: memStore(null) }); + const result = await repo.commit(workspace({ queries: [panelQuery('p1')] })); + expect(result.ok && result.dashboardRevision).toBeNull(); + }); + + it('on a failed write leaves the previously stored workspace intact and does not increment revision', async () => { + const previous = withDashboard({ id: 'prev', name: 'Prev' }); + const encodedPrev = encodeStoredWorkspaceJson(previous); + if (!encodedPrev.ok) throw new Error('fixture'); + const store = memStore(encodedPrev.value); + store.setFailWrite(true); + const repo = createWorkspaceRepository({ store }); + const result = await repo.commit(withDashboard({ id: 'next', name: 'Next', dashboard: null })); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('unreachable'); + expect(result.diagnostics.map((d) => d.code)).toEqual(['workspace-persist-failed']); + // Previous stored workspace untouched; no new write recorded. + expect(store.value).toBe(encodedPrev.value); + expect(store.writes).toHaveLength(0); + // Repository never touched revision — the stored dashboard revision is still 7. + const reloaded = await repo.loadCurrent(); + expect(reloaded?.dashboard?.revision).toBe(7); + }); + + it('stringifies a non-Error write rejection into the persist-failed diagnostic', async () => { + const store: WorkspaceStore = { + read: async () => null, + // Deliberately throwing a non-Error to exercise the String(error) branch. + write: async () => { throw 'disk full'; }, + clear: async () => {}, + }; + const repo = createWorkspaceRepository({ store }); + const result = await repo.commit(workspace()); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('unreachable'); + expect(result.diagnostics[0].message).toContain('disk full'); + }); + + it('honors an injected validation-service override', async () => { + const store = memStore(null); + const repo = createWorkspaceRepository({ store, validationService: jsonSchemaValidationService }); + const result = await repo.commit(workspace()); + expect(result.ok).toBe(true); + expect(store.writes).toHaveLength(1); + }); +}); + +describe('createWorkspaceRepository.clearCurrent', () => { + it('delegates to the store clear', async () => { + const store = memStore('{"storageVersion":1}'); + const clear = vi.spyOn(store, 'clear'); + const repo = createWorkspaceRepository({ store }); + await repo.clearCurrent(); + expect(clear).toHaveBeenCalledTimes(1); + expect(store.value).toBeNull(); + }); +}); + +describe('multi-tab last-successful-commit-wins (never a partially-mixed workspace)', () => { + it('a later commit fully replaces the earlier one — the record is one complete aggregate', async () => { + const store = memStore(null); + const repo = createWorkspaceRepository({ store }); + const a = withDashboard({ id: 'A', name: 'Alpha', queries: [panelQuery('qa')] }); + // Re-key the dashboard tile onto qa/qb so a "mix" would be structurally detectable. + a.dashboard!.tiles = [{ id: 't1', queryId: 'qa' }]; + const b = withDashboard({ id: 'B', name: 'Beta', queries: [panelQuery('qb')] }); + b.dashboard!.tiles = [{ id: 't1', queryId: 'qb' }]; + + await repo.commit(a); + await repo.commit(b); + + const loaded = await repo.loadCurrent(); + expect(loaded).toEqual(b); + // The persisted record is exactly B's canonical encoding — never a blend + // of A's and B's fields. + const encodedB = encodeStoredWorkspaceJson(b); + expect(encodedB.ok && store.value).toBe(encodedB.ok ? encodedB.value : ''); + expect(loaded?.queries.map((q) => q.id)).toEqual(['qb']); + }); + + it('under interleaved (un-awaited) commits the final record is exactly one complete aggregate', async () => { + const store = memStore(null); + const repo = createWorkspaceRepository({ store }); + const a = workspace({ id: 'A', name: 'Alpha', queries: [panelQuery('qa')] }); + const b = workspace({ id: 'B', name: 'Beta', queries: [panelQuery('qb')] }); + await Promise.all([repo.commit(a), repo.commit(b)]); + const loaded = await repo.loadCurrent(); + // Whichever won, it is internally consistent — the id and its query match + // the SAME source workspace, proving no partial mix. + expect(loaded).not.toBeNull(); + const encodedA = encodeStoredWorkspaceJson(a); + const encodedB = encodeStoredWorkspaceJson(b); + const winners = [encodedA, encodedB].filter((e) => e.ok).map((e) => (e.ok ? e.value : '')); + expect(winners).toContain(store.value); + }); +}); diff --git a/tests/unit/workspace-semantics.test.ts b/tests/unit/workspace-semantics.test.ts new file mode 100644 index 00000000..bdf5df54 --- /dev/null +++ b/tests/unit/workspace-semantics.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from 'vitest'; +import { + isSupportedLayout, queryDashboardRole, + unsupportedDashboardVersionDiagnostics, unsupportedSpecVersionDiagnostics, + validateDashboardCollectionSemantics, validateDashboardSemantics, + validateQueryCollectionSemantics, +} from '../../src/dashboard/model/workspace-semantics.js'; +import { PORTABLE_LIMITS } from '../../src/dashboard/model/portable-limits.js'; +import type { WorkspaceDiagnostic } from '../../src/dashboard/model/workspace-diagnostics.js'; + +const codes = (diagnostics: WorkspaceDiagnostic[]): string[] => diagnostics.map((d) => d.code); +const has = (diagnostics: WorkspaceDiagnostic[], code: string): boolean => diagnostics.some((d) => d.code === code); + +// A panel-role saved query. `role` undefined defaults to panel. +const panelQuery = (id: string, over: Record = {}, dashboard?: Record) => ({ + id, sql: 'SELECT 1', specVersion: 1, + spec: { name: id, panel: { cfg: { type: 'bar', x: 0, y: [1] } }, ...(dashboard ? { dashboard } : {}), ...over }, +}); +const filterQuery = (id: string, sql = "SELECT ['a','b'] AS country") => ({ + id, sql, specVersion: 1, spec: { name: id, dashboard: { role: 'filter' } }, +}); +const flowLayout = (items: Record = {}) => ({ type: 'flow', version: 1, preset: 'full-width', items }); +const dashboardDoc = (over: Record = {}) => ({ + documentVersion: 1, id: 'd1', title: 'D', revision: 1, + layout: flowLayout(), filters: [], tiles: [], ...over, +}); +const tile = (id: string, queryId: string, over: Record = {}) => ({ id, queryId, ...over }); + +describe('helper predicates', () => { + it('isSupportedLayout accepts only flow@1', () => { + expect(isSupportedLayout('flow', 1)).toBe(true); + expect(isSupportedLayout('flow', 2)).toBe(false); + expect(isSupportedLayout('grid', 1)).toBe(false); + }); + + it('queryDashboardRole defaults to panel through every non-role shape', () => { + expect(queryDashboardRole(null)).toBe('panel'); + expect(queryDashboardRole({ spec: null })).toBe('panel'); + expect(queryDashboardRole({ spec: { dashboard: null } })).toBe('panel'); + expect(queryDashboardRole({ spec: { dashboard: { role: 5 } } })).toBe('panel'); + expect(queryDashboardRole({ spec: { dashboard: { role: 'filter' } } })).toBe('filter'); + }); +}); + +describe('fail-closed version pre-scans', () => { + it('flags queries with an unsupported specVersion only', () => { + const diagnostics = unsupportedSpecVersionDiagnostics([ + { id: 'ok', specVersion: 1 }, { id: 'bad', specVersion: 7 }, 'not-object', { specVersion: 1.5 }, + ]); + expect(codes(diagnostics)).toEqual(['spec-version-unsupported']); + expect(diagnostics[0].path).toEqual(['queries', 1, 'specVersion']); + expect(diagnostics[0].resource).toBe('bad'); + }); + + it('flags dashboards whose documentVersion is a known integer other than 1', () => { + const diagnostics = unsupportedDashboardVersionDiagnostics([ + { id: 'ok', documentVersion: 1 }, { id: 'future', documentVersion: 2 }, 'x', { documentVersion: 'nope' }, + ]); + expect(codes(diagnostics)).toEqual(['dashboard-version-unsupported']); + expect(diagnostics[0].resource).toBe('future'); + }); +}); + +describe('validateQueryCollectionSemantics', () => { + it('accepts a clean collection', () => { + expect(validateQueryCollectionSemantics([panelQuery('q1'), panelQuery('q2')])).toEqual([]); + }); + + it('flags duplicate ids and skips non-object entries', () => { + const diagnostics = validateQueryCollectionSemantics([panelQuery('dup'), 'nope', panelQuery('dup')]); + expect(has(diagnostics, 'workspace-duplicate-query-id')).toBe(true); + }); + + it('enforces the collection-count, id, sql, and name length limits', () => { + expect(has(validateQueryCollectionSemantics( + Array.from({ length: PORTABLE_LIMITS.maxQueries + 1 }, (_, i) => panelQuery(`q${i}`)), + ), 'limit-query-count')).toBe(true); + const long = 'x'.repeat(PORTABLE_LIMITS.maxIdLength + 1); + expect(has(validateQueryCollectionSemantics([panelQuery(long)]), 'limit-id-length')).toBe(true); + expect(has(validateQueryCollectionSemantics([ + { id: 'q', sql: 's'.repeat(PORTABLE_LIMITS.maxSqlLength + 1), specVersion: 1, spec: {} }, + ]), 'limit-sql-length')).toBe(true); + expect(has(validateQueryCollectionSemantics([ + { id: 'q', sql: 'x', specVersion: 1, spec: { name: 'n'.repeat(PORTABLE_LIMITS.maxNameLength + 1) } }, + ]), 'limit-name-length')).toBe(true); + }); + + it('re-checks the query description length at boundary and boundary+1', () => { + const atLimit = { id: 'q', sql: 'x', specVersion: 1, spec: { name: 'q', description: 'd'.repeat(PORTABLE_LIMITS.maxDescriptionLength) } }; + expect(has(validateQueryCollectionSemantics([atLimit]), 'limit-description-length')).toBe(false); + const overLimit = { id: 'q', sql: 'x', specVersion: 1, spec: { name: 'q', description: 'd'.repeat(PORTABLE_LIMITS.maxDescriptionLength + 1) } }; + const diagnostics = validateQueryCollectionSemantics([overLimit]); + expect(has(diagnostics, 'limit-description-length')).toBe(true); + expect(diagnostics.find((x) => x.code === 'limit-description-length')!.path) + .toEqual(['queries', 0, 'spec', 'description']); + }); + + it('enforces the serialized-Spec byte limit', () => { + const big = { id: 'q', sql: 'x', specVersion: 1, spec: { description: 'x'.repeat(PORTABLE_LIMITS.maxSerializedQuerySpecBytes + 10) } }; + expect(has(validateQueryCollectionSemantics([big]), 'limit-spec-bytes')).toBe(true); + }); + + it('skips queries whose spec is not an object', () => { + expect(validateQueryCollectionSemantics([{ id: 'q', sql: 'x', specVersion: 1, spec: 'no' }])).toEqual([]); + }); + + it('validates variant count, defaultVariant existence, and static renderer-type changes', () => { + const tooMany: Record = {}; + for (let i = 0; i <= PORTABLE_LIMITS.maxVariantsPerQuery; i++) tooMany[`v${i}`] = {}; + expect(has(validateQueryCollectionSemantics([panelQuery('q', {}, { variants: tooMany })]), 'limit-variant-count')).toBe(true); + + expect(has(validateQueryCollectionSemantics([panelQuery('q', {}, { defaultVariant: 'missing', variants: { present: {} } })]), + 'query-default-variant-missing')).toBe(true); + expect(validateQueryCollectionSemantics([panelQuery('q', {}, { defaultVariant: 'present', variants: { present: {} } })])) + .toEqual([]); + + // A variant whose patch changes cfg.type from the base bar renderer. + expect(has(validateQueryCollectionSemantics([panelQuery('q', {}, { variants: { alt: { cfg: { type: 'line' } } } })]), + 'presentation-renderer-type-change')).toBe(true); + // A same-type patch and a patch without a cfg.type are fine. + expect(validateQueryCollectionSemantics([panelQuery('q', {}, { variants: { same: { cfg: { type: 'bar' } }, nocfg: { title: 't' } } })])) + .toEqual([]); + }); + + it('ignores a defaultVariant that is not a string', () => { + expect(validateQueryCollectionSemantics([panelQuery('q', {}, { defaultVariant: 5 })])).toEqual([]); + }); +}); + +describe('validateDashboardSemantics', () => { + it('accepts a clean dashboard with resolvable tiles and filters', () => { + const queries = [panelQuery('p1'), filterQuery('f1')]; + const dashboard = dashboardDoc({ + tiles: [tile('t1', 'p1')], + layout: flowLayout({ t1: { span: 1, height: 'medium' } }), + filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', targets: ['t1'] }], + }); + // t1's query p1 must declare parameter `country` for the target check. + queries[0] = panelQuery('p1', {}, undefined); + queries[0].sql = 'SELECT * WHERE c = {country:String}'; + expect(validateDashboardSemantics(dashboard, { queries })).toEqual([]); + }); + + it('returns nothing for a non-object dashboard and fails closed on a bad documentVersion', () => { + expect(validateDashboardSemantics(null)).toEqual([]); + const diagnostics = validateDashboardSemantics(dashboardDoc({ documentVersion: 2 })); + expect(codes(diagnostics)).toEqual(['dashboard-version-unsupported']); + expect(diagnostics[0].resource).toBe('d1'); + }); + + it('enforces tile-count limit, duplicate tile ids, missing queries, and role compatibility', () => { + const many = dashboardDoc({ tiles: Array.from({ length: PORTABLE_LIMITS.maxTilesPerDashboard + 1 }, (_, i) => tile(`t${i}`, 'p1')) }); + expect(has(validateDashboardSemantics(many, { queries: [panelQuery('p1')] }), 'limit-tile-count')).toBe(true); + + const dup = dashboardDoc({ tiles: [tile('t', 'p1'), tile('t', 'p1')], layout: flowLayout({ t: {} }) }); + expect(has(validateDashboardSemantics(dup, { queries: [panelQuery('p1')] }), 'dashboard-duplicate-tile-id')).toBe(true); + + const missing = dashboardDoc({ tiles: [tile('t1', 'gone')], layout: flowLayout({ t1: {} }) }); + expect(has(validateDashboardSemantics(missing), 'dashboard-tile-query-missing')).toBe(true); + + const setupQ = panelQuery('s1', {}, { role: 'setup' }); + const setupTile = dashboardDoc({ tiles: [tile('t1', 's1')], layout: flowLayout({ t1: {} }) }); + expect(has(validateDashboardSemantics(setupTile, { queries: [setupQ] }), 'dashboard-setup-reference')).toBe(true); + + const filterAsTile = dashboardDoc({ tiles: [tile('t1', 'f1')], layout: flowLayout({ t1: {} }) }); + expect(has(validateDashboardSemantics(filterAsTile, { queries: [filterQuery('f1')] }), 'dashboard-tile-role-incompatible')).toBe(true); + }); + + it('skips non-object tiles and tiles without a queryId', () => { + const dashboard = dashboardDoc({ tiles: ['x', { id: 't1' }], layout: flowLayout() }); + // No query resolution runs; a tile without queryId still counts for its id. + expect(validateDashboardSemantics(dashboard)).toEqual([]); + }); + + it('validates selected variant existence and tile override renderer-type changes', () => { + const q = panelQuery('p1', {}, { variants: { v: {} } }); + const good = dashboardDoc({ tiles: [tile('t1', 'p1', { presentation: { variant: 'v' } })], layout: flowLayout({ t1: {} }) }); + expect(validateDashboardSemantics(good, { queries: [q] })).toEqual([]); + const bad = dashboardDoc({ tiles: [tile('t1', 'p1', { presentation: { variant: 'nope' } })], layout: flowLayout({ t1: {} }) }); + expect(has(validateDashboardSemantics(bad, { queries: [q] }), 'dashboard-variant-missing')).toBe(true); + const override = dashboardDoc({ tiles: [tile('t1', 'p1', { presentation: { override: { cfg: { type: 'pie' } } } })], layout: flowLayout({ t1: {} }) }); + expect(has(validateDashboardSemantics(override, { queries: [panelQuery('p1')] }), 'presentation-renderer-type-change')).toBe(true); + // An empty presentation object and an override without a cfg.type are fine. + const neutral = dashboardDoc({ tiles: [tile('t1', 'p1', { presentation: {} })], layout: flowLayout({ t1: {} }) }); + expect(validateDashboardSemantics(neutral, { queries: [panelQuery('p1')] })).toEqual([]); + }); + + it('enforces layout item limits, orphan placements, and flow@1 schema validity', () => { + const orphan = dashboardDoc({ tiles: [tile('t1', 'p1')], layout: flowLayout({ ghost: {} }) }); + const d = validateDashboardSemantics(orphan, { queries: [panelQuery('p1')] }); + expect(has(d, 'layout-orphan-placement')).toBe(true); + expect(has(d, 'layout-items-exceed-tiles')).toBe(false); // 1 item, 1 tile + + const exceed = dashboardDoc({ tiles: [], layout: flowLayout({ a: {}, b: {} }) }); + expect(has(validateDashboardSemantics(exceed), 'layout-items-exceed-tiles')).toBe(true); + + // Invalid flow layout: unknown placement field fails the closed placement schema. + const badPlacement = dashboardDoc({ tiles: [tile('t1', 'p1')], layout: flowLayout({ t1: { span: 1, bogus: true } }) }); + expect(has(validateDashboardSemantics(badPlacement, { queries: [panelQuery('p1')] }), 'schema-unknown-property')).toBe(true); + }); + + it('requires a valid flow@1 fallback for an unsupported layout', () => { + const noFallback = dashboardDoc({ tiles: [tile('t1', 'p1')], layout: { type: 'grid', version: 1, items: {} } }); + expect(has(validateDashboardSemantics(noFallback, { queries: [panelQuery('p1')] }), 'layout-unsupported-without-fallback')).toBe(true); + + const nullFallback = dashboardDoc({ tiles: [tile('t1', 'p1')], layout: { type: 'grid', version: 1, items: {}, fallback: null } }); + expect(has(validateDashboardSemantics(nullFallback, { queries: [panelQuery('p1')] }), 'layout-unsupported-without-fallback')).toBe(true); + + const goodFallback = dashboardDoc({ + tiles: [tile('t1', 'p1')], + layout: { type: 'grid', version: 9, items: {}, fallback: flowLayout({ t1: { span: 2 } }) }, + }); + expect(validateDashboardSemantics(goodFallback, { queries: [panelQuery('p1')] })).toEqual([]); + + const badFallback = dashboardDoc({ + tiles: [tile('t1', 'p1')], + layout: { type: 'grid', version: 9, items: {}, fallback: { type: 'flow', version: 1, preset: 'nope', items: {} } }, + }); + expect(has(validateDashboardSemantics(badFallback, { queries: [panelQuery('p1')] }), 'schema-invalid-enum')).toBe(true); + + const orphanFallback = dashboardDoc({ + tiles: [tile('t1', 'p1')], + layout: { type: 'grid', version: 9, items: {}, fallback: flowLayout({ ghost: {} }) }, + }); + expect(has(validateDashboardSemantics(orphanFallback, { queries: [panelQuery('p1')] }), 'layout-orphan-placement')).toBe(true); + }); + + it('enforces the layout item-count limit independently of the tile count', () => { + const items: Record = {}; + const tiles = []; + for (let i = 0; i <= PORTABLE_LIMITS.maxLayoutItemsPerDashboard; i++) { items[`t${i}`] = {}; tiles.push(tile(`t${i}`, 'p1')); } + const d = validateDashboardSemantics(dashboardDoc({ tiles, layout: flowLayout(items) }), { queries: [panelQuery('p1')] }); + expect(has(d, 'limit-layout-item-count')).toBe(true); + expect(has(d, 'layout-items-exceed-tiles')).toBe(false); // items == tiles + }); + + it('enforces the serialized layout-config byte limit', () => { + const layout = { type: 'flow', version: 1, preset: 'full-width', items: {}, config: { blob: 'x'.repeat(PORTABLE_LIMITS.maxSerializedLayoutConfigBytes + 10) } }; + expect(has(validateDashboardSemantics(dashboardDoc({ layout })), 'limit-layout-config-bytes')).toBe(true); + }); + + it('skips layout item checks when items is not an object', () => { + const layout = { type: 'flow', version: 1, preset: 'full-width' }; + // Missing `items` is a schema error, but checkItems must not throw. + expect(has(validateDashboardSemantics(dashboardDoc({ layout })), 'schema-required')).toBe(true); + }); + + it('validates filter identity, source role/uniqueness, and target/parameter resolution', () => { + const queries = [panelQuery('p1'), filterQuery('f1'), filterQuery('f2')]; + queries[0].sql = 'SELECT {country:String}'; + + const dupFilter = dashboardDoc({ + tiles: [tile('t1', 'p1')], layout: flowLayout({ t1: {} }), + filters: [{ id: 'x', parameter: 'country' }, { id: 'x', parameter: 'country' }], + }); + expect(has(validateDashboardSemantics(dupFilter, { queries }), 'dashboard-duplicate-filter-id')).toBe(true); + + const missingSource = dashboardDoc({ filters: [{ id: 'flt', parameter: 'p', sourceQueryId: 'gone' }] }); + expect(has(validateDashboardSemantics(missingSource, { queries }), 'filter-source-missing')).toBe(true); + + const setupSource = dashboardDoc({ filters: [{ id: 'flt', parameter: 'p', sourceQueryId: 's1' }] }); + expect(has(validateDashboardSemantics(setupSource, { queries: [panelQuery('s1', {}, { role: 'setup' })] }), 'dashboard-setup-reference')).toBe(true); + + const panelSource = dashboardDoc({ filters: [{ id: 'flt', parameter: 'p', sourceQueryId: 'p1' }] }); + expect(has(validateDashboardSemantics(panelSource, { queries }), 'filter-source-role')).toBe(true); + + const sourceIsTile = dashboardDoc({ + tiles: [tile('t1', 'f1')], layout: flowLayout({ t1: {} }), + filters: [{ id: 'flt', parameter: 'p', sourceQueryId: 'f1' }], + }); + // f1 is filter-role so it is a role-incompatible tile AND a source-is-tile. + expect(has(validateDashboardSemantics(sourceIsTile, { queries }), 'filter-source-is-tile')).toBe(true); + }); + + it('checks targets exist and declare the parameter, and detects type conflicts', () => { + const qString = panelQuery('a'); qString.sql = 'SELECT {country:String}'; + const qInt = panelQuery('b'); qInt.sql = 'SELECT {country:UInt32}'; + const qNone = panelQuery('c'); qNone.sql = 'SELECT 1'; + const dashboard = dashboardDoc({ + tiles: [tile('ta', 'a'), tile('tb', 'b'), tile('tc', 'c')], + layout: flowLayout({ ta: {}, tb: {}, tc: {} }), + filters: [{ id: 'flt', parameter: 'country', targets: ['ta', 'tb', 'tc', 'missing'] }], + }); + const d = validateDashboardSemantics(dashboard, { queries: [qString, qInt, qNone] }); + expect(has(d, 'filter-target-missing')).toBe(true); + expect(has(d, 'filter-parameter-undeclared')).toBe(true); // tc's query + expect(has(d, 'filter-parameter-type-conflict')).toBe(true); // String vs UInt32 + }); + + it('skips target parameter checks when parameter is absent and tolerates unknown target queries', () => { + const dashboard = dashboardDoc({ + tiles: [tile('t1', 'gone')], layout: flowLayout({ t1: {} }), + filters: [{ id: 'flt', targets: ['t1'] }], + }); + // parameter absent → no undeclared/type check; queryId resolves to a missing + // query (already reported at the tile), so the target loop `continue`s. + const d = validateDashboardSemantics(dashboard); + expect(has(d, 'filter-parameter-undeclared')).toBe(false); + expect(has(d, 'dashboard-tile-query-missing')).toBe(true); + }); + + it('enforces filter-count and serialized filter-default byte limits', () => { + const many = dashboardDoc({ filters: Array.from({ length: PORTABLE_LIMITS.maxFiltersPerDashboard + 1 }, (_, i) => ({ id: `f${i}`, parameter: 'p' })) }); + expect(has(validateDashboardSemantics(many), 'limit-filter-count')).toBe(true); + const bigDefault = dashboardDoc({ filters: [{ id: 'f', parameter: 'p', defaultValue: 'x'.repeat(PORTABLE_LIMITS.maxSerializedFilterDefaultBytes + 10) }] }); + expect(has(validateDashboardSemantics(bigDefault), 'limit-filter-default-bytes')).toBe(true); + }); + + it('skips non-object filters and non-object dashboards without a layout', () => { + const noLayout = { documentVersion: 1, id: 'd', title: 'T', revision: 1, filters: ['x'], tiles: [] }; + expect(validateDashboardSemantics(noLayout)).toEqual([]); + }); + + it('omits the resource id when the dashboard has no id (schema errors mapped too)', () => { + const badPlacement = { documentVersion: 1, title: 'T', revision: 1, tiles: [tile('t1', 'p1')], filters: [], layout: flowLayout({ t1: { bogus: true } }) }; + const d = validateDashboardSemantics(badPlacement, { queries: [panelQuery('p1')] }); + expect(d.some((x) => x.code === 'schema-unknown-property' && x.resource === undefined)).toBe(true); + }); +}); + +describe('validateDashboardCollectionSemantics', () => { + it('flags duplicate dashboard ids and the dashboard-count limit', () => { + const one = dashboardDoc({ id: 'dup', tiles: [], layout: flowLayout() }); + const two = dashboardDoc({ id: 'dup', tiles: [], layout: flowLayout() }); + expect(has(validateDashboardCollectionSemantics([one, two]), 'workspace-duplicate-dashboard-id')).toBe(true); + + const many = Array.from({ length: PORTABLE_LIMITS.maxDashboards + 1 }, (_, i) => dashboardDoc({ id: `d${i}` })); + expect(has(validateDashboardCollectionSemantics(many), 'limit-dashboard-count')).toBe(true); + }); + + it('tolerates non-object and id-less dashboards', () => { + expect(validateDashboardCollectionSemantics(['x', dashboardDoc({ id: undefined })])).toEqual([]); + }); +});